Merge branch 'main' of https://github.com/mazeincoding/AppCut
This commit is contained in:
@ -7,9 +7,13 @@ DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
|
|||||||
BETTER_AUTH_URL=http://localhost:3000
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
BETTER_AUTH_SECRET=your-secret-key-here
|
BETTER_AUTH_SECRET=your-secret-key-here
|
||||||
|
|
||||||
|
#Google Clients
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
# Development Environment
|
# Development Environment
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
# Redis
|
# Redis
|
||||||
UPSTASH_REDIS_REST_URL=http://localhost:8079
|
UPSTASH_REDIS_REST_URL=http://localhost:8079
|
||||||
UPSTASH_REDIS_REST_TOKEN=example_token
|
UPSTASH_REDIS_REST_TOKEN=example_token
|
||||||
|
899
apps/web/package-lock.json
generated
899
apps/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
162
apps/web/src/app/(auth)/login/page.tsx
Normal file
162
apps/web/src/app/(auth)/login/page.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { signIn } from "@/lib/auth-client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Suspense, useState } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||||
|
import { GoogleIcon } from "@/components/icons";
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||||
|
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
setError(null);
|
||||||
|
setIsEmailLoading(true);
|
||||||
|
|
||||||
|
const { error } = await signIn.email({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setError(error.message || "An unexpected error occurred.");
|
||||||
|
setIsEmailLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push("/editor");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleLogin = async () => {
|
||||||
|
setError(null);
|
||||||
|
setIsGoogleLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signIn.social({
|
||||||
|
provider: "google",
|
||||||
|
});
|
||||||
|
router.push("/editor");
|
||||||
|
} catch (error) {
|
||||||
|
setError("Failed to sign in with Google. Please try again.");
|
||||||
|
setIsGoogleLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAnyLoading = isEmailLoading || isGoogleLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col space-y-6">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleGoogleLogin}
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
disabled={isAnyLoading}
|
||||||
|
>
|
||||||
|
{isGoogleLoading ? <Loader2 className="animate-spin" /> : (<GoogleIcon />)} Continue with Google
|
||||||
|
</Button>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<Separator className="w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
|
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="m@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={isAnyLoading}
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isAnyLoading}
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={isAnyLoading || !email || !password}
|
||||||
|
className="w-full h-11"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{isEmailLoading ? <Loader2 className="animate-spin" /> : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center relative">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="absolute top-6 left-6"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" /> Back
|
||||||
|
</Button>
|
||||||
|
<Card className="w-[400px] shadow-lg border-0">
|
||||||
|
<CardHeader className="text-center pb-4">
|
||||||
|
<CardTitle className="text-2xl font-semibold">Welcome back</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
Sign in to your account to continue
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<Suspense fallback={<div className="text-center">
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
</div>}>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
<div className="mt-6 text-center text-sm">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link href="/auth/signup" className="font-medium text-primary underline-offset-4 hover:underline">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
181
apps/web/src/app/(auth)/signup/page.tsx
Normal file
181
apps/web/src/app/(auth)/signup/page.tsx
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { signUp, signIn } from "@/lib/auth-client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Suspense, useState } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Loader2, ArrowLeft } from "lucide-react";
|
||||||
|
import { GoogleIcon } from "@/components/icons";
|
||||||
|
|
||||||
|
function SignUpForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||||
|
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSignUp = async () => {
|
||||||
|
setError(null);
|
||||||
|
setIsEmailLoading(true);
|
||||||
|
|
||||||
|
const { error } = await signUp.email({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setError(error.message || "An unexpected error occurred.");
|
||||||
|
setIsEmailLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push("/auth/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoogleSignUp = async () => {
|
||||||
|
setError(null);
|
||||||
|
setIsGoogleLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signIn.social({
|
||||||
|
provider: "google",
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push("/editor");
|
||||||
|
} catch (error) {
|
||||||
|
setError("Failed to sign up with Google. Please try again.");
|
||||||
|
setIsGoogleLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAnyLoading = isEmailLoading || isGoogleLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col space-y-6">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleGoogleSignUp}
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
disabled={isAnyLoading}
|
||||||
|
>
|
||||||
|
{isGoogleLoading ? <Loader2 className="animate-spin" /> : (<GoogleIcon />)} Continue with Google
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<Separator className="w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
|
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Full Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="John Doe"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
disabled={isAnyLoading}
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="m@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={isAnyLoading}
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Create a strong password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isAnyLoading}
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleSignUp}
|
||||||
|
disabled={isAnyLoading || !name || !email || !password}
|
||||||
|
className="w-full h-11"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{isEmailLoading ? <Loader2 className="animate-spin" /> : "Create account"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SignUpPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center relative">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="absolute top-6 left-6"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" /> Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Card className="w-[400px] shadow-lg border-0">
|
||||||
|
<CardHeader className="text-center pb-4">
|
||||||
|
<CardTitle className="text-2xl font-semibold">Create your account</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
Get started with your free account today
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<Suspense fallback={<div className="text-center">
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
</div>}>
|
||||||
|
<SignUpForm />
|
||||||
|
</Suspense>
|
||||||
|
<div className="mt-6 text-center text-sm">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link href="/auth/login" className="font-medium text-primary underline-offset-4 hover:underline">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,98 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Suspense, useState } from "react";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
|
|
||||||
function LoginForm() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const redirectUrl = searchParams.get("redirect");
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
setError(null);
|
|
||||||
const { error } = await authClient.signIn.email({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
setError(error.message || "An unexpected error occurred.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push(redirectUrl || "/");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col space-y-4">
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="m@example.com"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleLogin}>Login</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen items-center justify-center">
|
|
||||||
<Card className="w-[350px]">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Login</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Enter your email and password to login.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
|
||||||
<LoginForm />
|
|
||||||
</Suspense>
|
|
||||||
<div className="mt-4 text-center text-sm">
|
|
||||||
Don't have an account?{" "}
|
|
||||||
<Link href="/auth/signup" className="underline">
|
|
||||||
Sign up
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,108 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Suspense, useState } from "react";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
|
|
||||||
function SignUpForm() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const redirectUrl = searchParams.get("redirect");
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleSignUp = async () => {
|
|
||||||
setError(null);
|
|
||||||
const { error } = await authClient.signUp.email({
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
setError(error.message || "An unexpected error occurred.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push(redirectUrl || "/");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col space-y-4">
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
type="text"
|
|
||||||
placeholder="John Doe"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="m@example.com"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleSignUp}>Sign Up</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SignUpPage() {
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen items-center justify-center">
|
|
||||||
<Card className="w-[350px]">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Sign Up</CardTitle>
|
|
||||||
<CardDescription>Create an account to get started.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
|
||||||
<SignUpForm />
|
|
||||||
</Suspense>
|
|
||||||
<div className="mt-4 text-center text-sm">
|
|
||||||
Already have an account?{" "}
|
|
||||||
<Link href="/auth/login" className="underline">
|
|
||||||
Login
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,197 +1,218 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
import { Label } from "../ui/label";
|
import { Label } from "../ui/label";
|
||||||
import { Slider } from "../ui/slider";
|
import { Slider } from "../ui/slider";
|
||||||
import { ScrollArea } from "../ui/scroll-area";
|
import { ScrollArea } from "../ui/scroll-area";
|
||||||
import { Separator } from "../ui/separator";
|
import { Separator } from "../ui/separator";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "../ui/select";
|
} from "../ui/select";
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
import { useMediaStore } from "@/stores/media-store";
|
import { useMediaStore } from "@/stores/media-store";
|
||||||
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
|
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { SpeedControl } from "./speed-control";
|
||||||
export function PropertiesPanel() {
|
|
||||||
const { tracks } = useTimelineStore();
|
export function PropertiesPanel() {
|
||||||
const { mediaItems } = useMediaStore();
|
const { tracks } = useTimelineStore();
|
||||||
const [backgroundType, setBackgroundType] = useState<
|
const { mediaItems } = useMediaStore();
|
||||||
"blur" | "mirror" | "color"
|
const [backgroundType, setBackgroundType] = useState<
|
||||||
>("blur");
|
"blur" | "mirror" | "color"
|
||||||
const [backgroundColor, setBackgroundColor] = useState("#000000");
|
>("blur");
|
||||||
|
const [backgroundColor, setBackgroundColor] = useState("#000000");
|
||||||
// Get the first image clip for preview (simplified)
|
|
||||||
const firstImageClip = tracks
|
// Get the first video clip for preview (simplified)
|
||||||
.flatMap((track) => track.clips)
|
const firstVideoClip = tracks
|
||||||
.find((clip) => {
|
.flatMap((track) => track.clips)
|
||||||
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
|
.find((clip) => {
|
||||||
return mediaItem?.type === "image";
|
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
|
||||||
});
|
return mediaItem?.type === "video";
|
||||||
|
});
|
||||||
const firstImageItem = firstImageClip
|
|
||||||
? mediaItems.find((item) => item.id === firstImageClip.mediaId)
|
const firstVideoItem = firstVideoClip
|
||||||
: null;
|
? mediaItems.find((item) => item.id === firstVideoClip.mediaId)
|
||||||
|
: null;
|
||||||
return (
|
|
||||||
<ScrollArea className="h-full">
|
// Get the first image clip for preview (simplified)
|
||||||
<div className="space-y-6 p-5">
|
const firstImageClip = tracks
|
||||||
{/* Image Treatment - only show if an image is selected */}
|
.flatMap((track) => track.clips)
|
||||||
{firstImageItem && (
|
.find((clip) => {
|
||||||
<>
|
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
|
||||||
<div className="space-y-4">
|
return mediaItem?.type === "image";
|
||||||
<h3 className="text-sm font-medium">Image Treatment</h3>
|
});
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Preview */}
|
const firstImageItem = firstImageClip
|
||||||
<div className="space-y-2">
|
? mediaItems.find((item) => item.id === firstImageClip.mediaId)
|
||||||
<Label>Preview</Label>
|
: null;
|
||||||
<div className="w-full aspect-video max-w-48">
|
|
||||||
<ImageTimelineTreatment
|
return (
|
||||||
src={firstImageItem.url}
|
<ScrollArea className="h-full">
|
||||||
alt={firstImageItem.name}
|
<div className="space-y-6 p-5">
|
||||||
targetAspectRatio={16 / 9}
|
{/* Image Treatment - only show if an image is selected */}
|
||||||
className="rounded-sm border"
|
{firstImageItem && (
|
||||||
backgroundType={backgroundType}
|
<>
|
||||||
backgroundColor={backgroundColor}
|
<div className="space-y-4">
|
||||||
/>
|
<h3 className="text-sm font-medium">Image Treatment</h3>
|
||||||
</div>
|
<div className="space-y-4">
|
||||||
</div>
|
{/* Preview */}
|
||||||
|
<div className="space-y-2">
|
||||||
{/* Background Type */}
|
<Label>Preview</Label>
|
||||||
<div className="space-y-2">
|
<div className="w-full aspect-video max-w-48">
|
||||||
<Label htmlFor="bg-type">Background Type</Label>
|
<ImageTimelineTreatment
|
||||||
<Select
|
src={firstImageItem.url}
|
||||||
value={backgroundType}
|
alt={firstImageItem.name}
|
||||||
onValueChange={(value: any) => setBackgroundType(value)}
|
targetAspectRatio={16 / 9}
|
||||||
>
|
className="rounded-sm border"
|
||||||
<SelectTrigger>
|
backgroundType={backgroundType}
|
||||||
<SelectValue placeholder="Select background type" />
|
backgroundColor={backgroundColor}
|
||||||
</SelectTrigger>
|
/>
|
||||||
<SelectContent>
|
</div>
|
||||||
<SelectItem value="blur">Blur</SelectItem>
|
</div>
|
||||||
<SelectItem value="mirror">Mirror</SelectItem>
|
|
||||||
<SelectItem value="color">Solid Color</SelectItem>
|
{/* Background Type */}
|
||||||
</SelectContent>
|
<div className="space-y-2">
|
||||||
</Select>
|
<Label htmlFor="bg-type">Background Type</Label>
|
||||||
</div>
|
<Select
|
||||||
|
value={backgroundType}
|
||||||
{/* Background Color - only show for color type */}
|
onValueChange={(value: any) => setBackgroundType(value)}
|
||||||
{backgroundType === "color" && (
|
>
|
||||||
<div className="space-y-2">
|
<SelectTrigger>
|
||||||
<Label htmlFor="bg-color">Background Color</Label>
|
<SelectValue placeholder="Select background type" />
|
||||||
<div className="flex gap-2">
|
</SelectTrigger>
|
||||||
<Input
|
<SelectContent>
|
||||||
id="bg-color"
|
<SelectItem value="blur">Blur</SelectItem>
|
||||||
type="color"
|
<SelectItem value="mirror">Mirror</SelectItem>
|
||||||
value={backgroundColor}
|
<SelectItem value="color">Solid Color</SelectItem>
|
||||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
</SelectContent>
|
||||||
className="w-16 h-10 p-1"
|
</Select>
|
||||||
/>
|
</div>
|
||||||
<Input
|
|
||||||
value={backgroundColor}
|
{/* Background Color - only show for color type */}
|
||||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
{backgroundType === "color" && (
|
||||||
placeholder="#000000"
|
<div className="space-y-2">
|
||||||
className="flex-1"
|
<Label htmlFor="bg-color">Background Color</Label>
|
||||||
/>
|
<div className="flex gap-2">
|
||||||
</div>
|
<Input
|
||||||
</div>
|
id="bg-color"
|
||||||
)}
|
type="color"
|
||||||
</div>
|
value={backgroundColor}
|
||||||
</div>
|
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||||
|
className="w-16 h-10 p-1"
|
||||||
<Separator />
|
/>
|
||||||
</>
|
<Input
|
||||||
)}
|
value={backgroundColor}
|
||||||
|
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||||
{/* Transform */}
|
placeholder="#000000"
|
||||||
<div className="space-y-4">
|
className="flex-1"
|
||||||
<h3 className="text-sm font-medium">Transform</h3>
|
/>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
</div>
|
||||||
<div className="space-y-1">
|
)}
|
||||||
<Label htmlFor="x">X Position</Label>
|
</div>
|
||||||
<Input id="x" type="number" defaultValue="0" />
|
</div>
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
<Separator />
|
||||||
<Label htmlFor="y">Y Position</Label>
|
</>
|
||||||
<Input id="y" type="number" defaultValue="0" />
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
{/* Video Controls - only show if a video is selected */}
|
||||||
<div className="space-y-1">
|
{firstVideoItem && (
|
||||||
<Label htmlFor="rotation">Rotation</Label>
|
<>
|
||||||
<Slider
|
<SpeedControl />
|
||||||
id="rotation"
|
<Separator />
|
||||||
max={360}
|
</>
|
||||||
step={1}
|
)}
|
||||||
defaultValue={[0]}
|
|
||||||
className="mt-2"
|
{/* Transform */}
|
||||||
/>
|
<div className="space-y-4">
|
||||||
</div>
|
<h3 className="text-sm font-medium">Transform</h3>
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
</div>
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
<Separator />
|
<Label htmlFor="x">X Position</Label>
|
||||||
|
<Input id="x" type="number" defaultValue="0" />
|
||||||
{/* Effects */}
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium">Effects</h3>
|
<Label htmlFor="y">Y Position</Label>
|
||||||
<div className="space-y-4">
|
<Input id="y" type="number" defaultValue="0" />
|
||||||
<div className="space-y-1">
|
</div>
|
||||||
<Label htmlFor="opacity">Opacity</Label>
|
</div>
|
||||||
<Slider
|
<div className="space-y-1">
|
||||||
id="opacity"
|
<Label htmlFor="rotation">Rotation</Label>
|
||||||
max={100}
|
<Slider
|
||||||
step={1}
|
id="rotation"
|
||||||
defaultValue={[100]}
|
max={360}
|
||||||
className="mt-2"
|
step={1}
|
||||||
/>
|
defaultValue={[0]}
|
||||||
</div>
|
className="mt-2"
|
||||||
<div className="space-y-1">
|
/>
|
||||||
<Label htmlFor="blur">Blur</Label>
|
</div>
|
||||||
<Slider
|
</div>
|
||||||
id="blur"
|
</div>
|
||||||
max={20}
|
|
||||||
step={0.5}
|
<Separator />
|
||||||
defaultValue={[0]}
|
|
||||||
className="mt-2"
|
{/* Effects */}
|
||||||
/>
|
<div className="space-y-4">
|
||||||
</div>
|
<h3 className="text-sm font-medium">Effects</h3>
|
||||||
</div>
|
<div className="space-y-4">
|
||||||
</div>
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="opacity">Opacity</Label>
|
||||||
<Separator />
|
<Slider
|
||||||
|
id="opacity"
|
||||||
{/* Timing */}
|
max={100}
|
||||||
<div className="space-y-4">
|
step={1}
|
||||||
<h3 className="text-sm font-medium">Timing</h3>
|
defaultValue={[100]}
|
||||||
<div className="space-y-2">
|
className="mt-2"
|
||||||
<div className="space-y-1">
|
/>
|
||||||
<Label htmlFor="duration">Duration (seconds)</Label>
|
</div>
|
||||||
<Input
|
<div className="space-y-1">
|
||||||
id="duration"
|
<Label htmlFor="blur">Blur</Label>
|
||||||
type="number"
|
<Slider
|
||||||
min="0"
|
id="blur"
|
||||||
step="0.1"
|
max={20}
|
||||||
defaultValue="5"
|
step={0.5}
|
||||||
/>
|
defaultValue={[0]}
|
||||||
</div>
|
className="mt-2"
|
||||||
<div className="space-y-1">
|
/>
|
||||||
<Label htmlFor="delay">Delay (seconds)</Label>
|
</div>
|
||||||
<Input
|
</div>
|
||||||
id="delay"
|
</div>
|
||||||
type="number"
|
|
||||||
min="0"
|
<Separator />
|
||||||
step="0.1"
|
|
||||||
defaultValue="0"
|
{/* Timing */}
|
||||||
/>
|
<div className="space-y-4">
|
||||||
</div>
|
<h3 className="text-sm font-medium">Timing</h3>
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
</div>
|
<div className="space-y-1">
|
||||||
</div>
|
<Label htmlFor="duration">Duration (seconds)</Label>
|
||||||
</ScrollArea>
|
<Input
|
||||||
);
|
id="duration"
|
||||||
}
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
defaultValue="5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="delay">Delay (seconds)</Label>
|
||||||
|
<Input
|
||||||
|
id="delay"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
defaultValue="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
46
apps/web/src/components/editor/speed-control.tsx
Normal file
46
apps/web/src/components/editor/speed-control.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { Slider } from "../ui/slider";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
|
|
||||||
|
const SPEED_PRESETS = [
|
||||||
|
{ label: "0.5x", value: 0.5 },
|
||||||
|
{ label: "1x", value: 1.0 },
|
||||||
|
{ label: "1.5x", value: 1.5 },
|
||||||
|
{ label: "2x", value: 2.0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SpeedControl() {
|
||||||
|
const { speed, setSpeed } = usePlaybackStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium">Playback Speed</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{SPEED_PRESETS.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.value}
|
||||||
|
variant={speed === preset.value ? "default" : "outline"}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setSpeed(preset.value)}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Custom ({speed.toFixed(1)}x)</Label>
|
||||||
|
<Slider
|
||||||
|
value={[speed]}
|
||||||
|
min={0.1}
|
||||||
|
max={2.0}
|
||||||
|
step={0.1}
|
||||||
|
onValueChange={(value) => setSpeed(value[0])}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -28,15 +28,22 @@ import { usePlaybackStore } from "@/stores/playback-store";
|
|||||||
import { processMediaFiles } from "@/lib/media-processing";
|
import { processMediaFiles } from "@/lib/media-processing";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../ui/select";
|
||||||
|
|
||||||
export function Timeline() {
|
export function Timeline() {
|
||||||
// Timeline shows all tracks (video, audio, effects) and their clips.
|
// Timeline shows all tracks (video, audio, effects) and their clips.
|
||||||
// You can drag media here to add it to your project.
|
// You can drag media here to add it to your project.
|
||||||
// Clips can be trimmed, deleted, and moved.
|
// Clips can be trimmed, deleted, and moved.
|
||||||
const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration } =
|
const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips } =
|
||||||
useTimelineStore();
|
useTimelineStore();
|
||||||
const { mediaItems, addMediaItem } = useMediaStore();
|
const { mediaItems, addMediaItem } = useMediaStore();
|
||||||
const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle } = usePlaybackStore();
|
const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore();
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [zoomLevel, setZoomLevel] = useState(1);
|
const [zoomLevel, setZoomLevel] = useState(1);
|
||||||
@ -52,6 +59,16 @@ export function Timeline() {
|
|||||||
y: number;
|
y: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// Marquee selection state
|
||||||
|
const [marquee, setMarquee] = useState<{
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
endX: number;
|
||||||
|
endY: number;
|
||||||
|
active: boolean;
|
||||||
|
additive: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Update timeline duration when tracks change
|
// Update timeline duration when tracks change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const totalDuration = getTotalDuration();
|
const totalDuration = getTotalDuration();
|
||||||
@ -67,6 +84,106 @@ export function Timeline() {
|
|||||||
}
|
}
|
||||||
}, [contextMenu]);
|
}, [contextMenu]);
|
||||||
|
|
||||||
|
// Keyboard event for deleting selected clips
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.key === "Delete" || e.key === "Backspace") && selectedClips.length > 0) {
|
||||||
|
selectedClips.forEach(({ trackId, clipId }) => {
|
||||||
|
removeClipFromTrack(trackId, clipId);
|
||||||
|
});
|
||||||
|
clearSelectedClips();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [selectedClips, removeClipFromTrack, clearSelectedClips]);
|
||||||
|
|
||||||
|
// Mouse down on timeline background to start marquee
|
||||||
|
const handleTimelineMouseDown = (e: React.MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget && e.button === 0) {
|
||||||
|
setMarquee({
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
endX: e.clientX,
|
||||||
|
endY: e.clientY,
|
||||||
|
active: true,
|
||||||
|
additive: e.metaKey || e.ctrlKey || e.shiftKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mouse move to update marquee
|
||||||
|
useEffect(() => {
|
||||||
|
if (!marquee || !marquee.active) return;
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
setMarquee((prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY });
|
||||||
|
};
|
||||||
|
const handleMouseUp = (e: MouseEvent) => {
|
||||||
|
setMarquee((prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY, active: false });
|
||||||
|
};
|
||||||
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
|
window.addEventListener("mouseup", handleMouseUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
window.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [marquee]);
|
||||||
|
|
||||||
|
// On marquee end, select clips in box
|
||||||
|
useEffect(() => {
|
||||||
|
if (!marquee || marquee.active) return;
|
||||||
|
const timeline = timelineRef.current;
|
||||||
|
if (!timeline) return;
|
||||||
|
const rect = timeline.getBoundingClientRect();
|
||||||
|
const x1 = Math.min(marquee.startX, marquee.endX) - rect.left;
|
||||||
|
const x2 = Math.max(marquee.startX, marquee.endX) - rect.left;
|
||||||
|
const y1 = Math.min(marquee.startY, marquee.endY) - rect.top;
|
||||||
|
const y2 = Math.max(marquee.startY, marquee.endY) - rect.top;
|
||||||
|
// Validation: skip if too small
|
||||||
|
if (Math.abs(x2 - x1) < 5 || Math.abs(y2 - y1) < 5) {
|
||||||
|
setMarquee(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Clamp to timeline bounds
|
||||||
|
const clamp = (val: number, min: number, max: number) => Math.max(min, Math.min(max, val));
|
||||||
|
const bx1 = clamp(x1, 0, rect.width);
|
||||||
|
const bx2 = clamp(x2, 0, rect.width);
|
||||||
|
const by1 = clamp(y1, 0, rect.height);
|
||||||
|
const by2 = clamp(y2, 0, rect.height);
|
||||||
|
let newSelection: { trackId: string; clipId: string }[] = [];
|
||||||
|
tracks.forEach((track, trackIdx) => {
|
||||||
|
track.clips.forEach((clip) => {
|
||||||
|
const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
|
||||||
|
const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
|
||||||
|
const clipLeft = clip.startTime * 50 * zoomLevel;
|
||||||
|
const clipTop = trackIdx * 60;
|
||||||
|
const clipBottom = clipTop + 60;
|
||||||
|
const clipRight = clipLeft + clipWidth;
|
||||||
|
if (
|
||||||
|
bx1 < clipRight &&
|
||||||
|
bx2 > clipLeft &&
|
||||||
|
by1 < clipBottom &&
|
||||||
|
by2 > clipTop
|
||||||
|
) {
|
||||||
|
newSelection.push({ trackId: track.id, clipId: clip.id });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (newSelection.length > 0) {
|
||||||
|
if (marquee.additive) {
|
||||||
|
const selectedSet = new Set(selectedClips.map((c) => c.trackId + ':' + c.clipId));
|
||||||
|
newSelection = [
|
||||||
|
...selectedClips,
|
||||||
|
...newSelection.filter((c) => !selectedSet.has(c.trackId + ':' + c.clipId)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
setSelectedClips(newSelection);
|
||||||
|
} else if (!marquee.additive) {
|
||||||
|
clearSelectedClips();
|
||||||
|
}
|
||||||
|
setMarquee(null);
|
||||||
|
}, [marquee, tracks, zoomLevel, selectedClips, setSelectedClips, clearSelectedClips]);
|
||||||
|
|
||||||
const handleDragEnter = (e: React.DragEvent) => {
|
const handleDragEnter = (e: React.DragEvent) => {
|
||||||
// When something is dragged over the timeline, show overlay
|
// When something is dragged over the timeline, show overlay
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -173,17 +290,12 @@ export function Timeline() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTimelineClick = (e: React.MouseEvent) => {
|
// Deselect all clips when clicking empty timeline area
|
||||||
const timeline = timelineRef.current;
|
const handleTimelineAreaClick = (e: React.MouseEvent) => {
|
||||||
if (!timeline || duration === 0) return;
|
// Only clear selection if the click target is the timeline background (not a child/clip)
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
const rect = timeline.getBoundingClientRect();
|
clearSelectedClips();
|
||||||
const x = e.clientX - rect.left;
|
}
|
||||||
const timelineWidth = rect.width;
|
|
||||||
const visibleDuration = duration / zoomLevel;
|
|
||||||
const clickedTime = (x / timelineWidth) * visibleDuration;
|
|
||||||
|
|
||||||
seek(Math.max(0, Math.min(duration, clickedTime)));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWheel = (e: React.WheelEvent) => {
|
const handleWheel = (e: React.WheelEvent) => {
|
||||||
@ -331,6 +443,29 @@ export function Timeline() {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Delete clip (Delete)</TooltipContent>
|
<TooltipContent>Delete clip (Delete)</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-border mx-1" />
|
||||||
|
|
||||||
|
{/* Speed Control */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Select
|
||||||
|
value={speed.toFixed(1)}
|
||||||
|
onValueChange={(value) => setSpeed(parseFloat(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[90px] h-8">
|
||||||
|
<SelectValue placeholder="1.0x" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0.5">0.5x</SelectItem>
|
||||||
|
<SelectItem value="1.0">1.0x</SelectItem>
|
||||||
|
<SelectItem value="1.5">1.5x</SelectItem>
|
||||||
|
<SelectItem value="2.0">2.0x</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Playback Speed</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -488,7 +623,8 @@ export function Timeline() {
|
|||||||
minHeight:
|
minHeight:
|
||||||
tracks.length > 0 ? `${tracks.length * 60}px` : "200px",
|
tracks.length > 0 ? `${tracks.length * 60}px` : "200px",
|
||||||
}}
|
}}
|
||||||
onClick={handleTimelineClick}
|
onClick={handleTimelineAreaClick}
|
||||||
|
onMouseDown={handleTimelineMouseDown}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
>
|
>
|
||||||
{tracks.length === 0 ? (
|
{tracks.length === 0 ? (
|
||||||
@ -699,6 +835,9 @@ function TimelineTrackContent({
|
|||||||
addClipToTrack,
|
addClipToTrack,
|
||||||
removeClipFromTrack,
|
removeClipFromTrack,
|
||||||
toggleTrackMute,
|
toggleTrackMute,
|
||||||
|
selectedClips,
|
||||||
|
selectClip,
|
||||||
|
deselectClip,
|
||||||
} = useTimelineStore();
|
} = useTimelineStore();
|
||||||
const { currentTime } = usePlaybackStore();
|
const { currentTime } = usePlaybackStore();
|
||||||
const [isDropping, setIsDropping] = useState(false);
|
const [isDropping, setIsDropping] = useState(false);
|
||||||
@ -1260,12 +1399,23 @@ function TimelineTrackContent({
|
|||||||
effectiveDuration * 50 * zoomLevel
|
effectiveDuration * 50 * zoomLevel
|
||||||
);
|
);
|
||||||
const clipLeft = clip.startTime * 50 * zoomLevel;
|
const clipLeft = clip.startTime * 50 * zoomLevel;
|
||||||
|
const isSelected = selectedClips.some(
|
||||||
|
(c) => c.trackId === track.id && c.clipId === clip.id
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={clip.id}
|
key={clip.id}
|
||||||
className={`timeline-clip absolute h-full rounded-sm border transition-all duration-200 ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg`}
|
className={`timeline-clip absolute h-full rounded-sm border transition-all duration-200 ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg ${isSelected ? "ring-2 ring-blue-500 z-10" : ""}`}
|
||||||
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
|
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||||
|
selectClip(track.id, clip.id, true);
|
||||||
|
} else {
|
||||||
|
selectClip(track.id, clip.id, false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -1342,16 +1492,13 @@ function TimelineTrackContent({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`absolute -top-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
|
className={`absolute -top-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
|
className={`absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white px-1 py-0.5 rounded whitespace-nowrap ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
|
className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white px-1 py-0.5 rounded whitespace-nowrap ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{wouldOverlap ? "⚠️" : ""}
|
{wouldOverlap ? "⚠️" : ""}
|
||||||
{dropPosition.toFixed(1)}s
|
{dropPosition.toFixed(1)}s
|
||||||
|
@ -5,8 +5,10 @@ import Image from "next/image";
|
|||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { HeaderBase } from "./header-base";
|
import { HeaderBase } from "./header-base";
|
||||||
|
import { useSession } from "@/lib/auth-client";
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
|
const { data: session } = useSession();
|
||||||
const leftContent = (
|
const leftContent = (
|
||||||
<Link href="/" className="flex items-center gap-3">
|
<Link href="/" className="flex items-center gap-3">
|
||||||
<Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
|
<Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
|
||||||
@ -21,7 +23,7 @@ export function Header() {
|
|||||||
GitHub
|
GitHub
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/editor">
|
<Link href={session ? "/editor" : "/auth/login"}>
|
||||||
<Button size="sm" className="text-sm ml-4">
|
<Button size="sm" className="text-sm ml-4">
|
||||||
Start editing
|
Start editing
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
22
apps/web/src/components/icons.tsx
Normal file
22
apps/web/src/components/icons.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
export function GoogleIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { type DialogProps } from "radix-ui";
|
import { DialogProps } from "@radix-ui/react-dialog";
|
||||||
import { Command as CommandPrimitive } from "cmdk";
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ export function VideoPlayer({
|
|||||||
clipDuration
|
clipDuration
|
||||||
}: VideoPlayerProps) {
|
}: VideoPlayerProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const { isPlaying, currentTime, volume, play, pause, setVolume } = usePlaybackStore();
|
const { isPlaying, currentTime, volume, speed, play, pause, setVolume } = usePlaybackStore();
|
||||||
|
|
||||||
// Calculate if we're within this clip's timeline range
|
// Calculate if we're within this clip's timeline range
|
||||||
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
|
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
|
||||||
@ -59,18 +59,26 @@ export function VideoPlayer({
|
|||||||
timelineTime - clipStartTime + trimStart
|
timelineTime - clipStartTime + trimStart
|
||||||
));
|
));
|
||||||
|
|
||||||
// Only sync if there's a significant difference
|
// Only sync if there's a significant difference to avoid micro-adjustments
|
||||||
if (Math.abs(video.currentTime - targetVideoTime) > 0.2) {
|
if (Math.abs(video.currentTime - targetVideoTime) > 0.5) {
|
||||||
video.currentTime = targetVideoTime;
|
video.currentTime = targetVideoTime;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSpeedEvent = (e: CustomEvent) => {
|
||||||
|
if (!isInClipRange) return;
|
||||||
|
// Set playbackRate directly without any additional checks
|
||||||
|
video.playbackRate = e.detail.speed;
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
|
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
|
||||||
window.addEventListener("playback-update", handleUpdateEvent as EventListener);
|
window.addEventListener("playback-update", handleUpdateEvent as EventListener);
|
||||||
|
window.addEventListener("playback-speed", handleSpeedEvent as EventListener);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("playback-seek", handleSeekEvent as EventListener);
|
window.removeEventListener("playback-seek", handleSeekEvent as EventListener);
|
||||||
window.removeEventListener("playback-update", handleUpdateEvent as EventListener);
|
window.removeEventListener("playback-update", handleUpdateEvent as EventListener);
|
||||||
|
window.removeEventListener("playback-speed", handleSpeedEvent as EventListener);
|
||||||
};
|
};
|
||||||
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
|
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
|
||||||
|
|
||||||
@ -93,6 +101,13 @@ export function VideoPlayer({
|
|||||||
video.volume = volume;
|
video.volume = volume;
|
||||||
}, [volume]);
|
}, [volume]);
|
||||||
|
|
||||||
|
// Sync speed immediately when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
video.playbackRate = speed;
|
||||||
|
}, [speed]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative group ${className}`}>
|
<div className={`relative group ${className}`}>
|
||||||
<video
|
<video
|
||||||
@ -101,7 +116,7 @@ export function VideoPlayer({
|
|||||||
poster={poster}
|
poster={poster}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
playsInline
|
playsInline
|
||||||
preload="metadata"
|
preload="auto"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
<div className="absolute inset-0 bg-black/20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { createAuthClient } from "better-auth/react";
|
import { createAuthClient } from "better-auth/react";
|
||||||
export const authClient = createAuthClient({
|
export const { signIn, signUp, useSession } = createAuthClient({
|
||||||
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL!,
|
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL!,
|
||||||
});
|
});
|
||||||
|
@ -16,6 +16,12 @@ export const auth = betterAuth({
|
|||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
socialProviders: {
|
||||||
|
google: {
|
||||||
|
clientId: process.env.GOOGLE_CLIENT_ID as string,
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
|
||||||
|
},
|
||||||
|
},
|
||||||
appName: "OpenCut",
|
appName: "OpenCut",
|
||||||
trustedOrigins: ["http://localhost:3000"],
|
trustedOrigins: ["http://localhost:3000"],
|
||||||
});
|
});
|
||||||
|
@ -102,26 +102,60 @@ export const getVideoInfo = async (videoFile: File): Promise<{
|
|||||||
fps: number;
|
fps: number;
|
||||||
}> => {
|
}> => {
|
||||||
const ffmpeg = await initFFmpeg();
|
const ffmpeg = await initFFmpeg();
|
||||||
|
|
||||||
const inputName = 'input.mp4';
|
const inputName = 'input.mp4';
|
||||||
|
|
||||||
// Write input file
|
// Write input file
|
||||||
await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer()));
|
await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer()));
|
||||||
|
|
||||||
// Get video info
|
// Capture FFmpeg stderr output with a one-time listener pattern
|
||||||
await ffmpeg.exec(['-i', inputName, '-f', 'null', '-']);
|
let ffmpegOutput = '';
|
||||||
|
let listening = true;
|
||||||
// Note: In a real implementation, you'd parse the FFmpeg output
|
const listener = (data: string) => {
|
||||||
// For now, we'll return default values and enhance this later
|
if (listening) ffmpegOutput += data;
|
||||||
|
};
|
||||||
|
ffmpeg.on('log', ({ message }) => listener(message));
|
||||||
|
|
||||||
|
// Run ffmpeg to get info (stderr will contain the info)
|
||||||
|
try {
|
||||||
|
await ffmpeg.exec(['-i', inputName, '-f', 'null', '-']);
|
||||||
|
} catch (error) {
|
||||||
|
listening = false;
|
||||||
|
await ffmpeg.deleteFile(inputName);
|
||||||
|
console.error('FFmpeg execution failed:', error);
|
||||||
|
throw new Error('Failed to extract video info. The file may be corrupted or in an unsupported format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable listener after exec completes
|
||||||
|
listening = false;
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
await ffmpeg.deleteFile(inputName);
|
await ffmpeg.deleteFile(inputName);
|
||||||
|
|
||||||
|
// Parse output for duration, resolution, and fps
|
||||||
|
// Example: Duration: 00:00:10.00, start: 0.000000, bitrate: 1234 kb/s
|
||||||
|
// Example: Stream #0:0: Video: h264 (High), yuv420p(progressive), 1920x1080 [SAR 1:1 DAR 16:9], 30 fps, 30 tbr, 90k tbn, 60 tbc
|
||||||
|
|
||||||
|
const durationMatch = ffmpegOutput.match(/Duration: (\d+):(\d+):([\d.]+)/);
|
||||||
|
let duration = 0;
|
||||||
|
if (durationMatch) {
|
||||||
|
const [, h, m, s] = durationMatch;
|
||||||
|
duration = parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoStreamMatch = ffmpegOutput.match(/Video:.* (\d+)x(\d+)[^,]*, ([\d.]+) fps/);
|
||||||
|
let width = 0, height = 0, fps = 0;
|
||||||
|
if (videoStreamMatch) {
|
||||||
|
width = parseInt(videoStreamMatch[1]);
|
||||||
|
height = parseInt(videoStreamMatch[2]);
|
||||||
|
fps = parseFloat(videoStreamMatch[3]);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
duration: 10, // Placeholder - would parse from FFmpeg output
|
duration,
|
||||||
width: 1920, // Placeholder
|
width,
|
||||||
height: 1080, // Placeholder
|
height,
|
||||||
fps: 30 // Placeholder
|
fps
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -194,4 +228,4 @@ export const extractAudio = async (
|
|||||||
await ffmpeg.deleteFile(outputName);
|
await ffmpeg.deleteFile(outputName);
|
||||||
|
|
||||||
return blob;
|
return blob;
|
||||||
};
|
};
|
@ -6,15 +6,20 @@ interface PlaybackStore extends PlaybackState, PlaybackControls {
|
|||||||
setCurrentTime: (time: number) => void;
|
setCurrentTime: (time: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let playbackTimer: NodeJS.Timeout | null = null;
|
let playbackTimer: number | null = null;
|
||||||
|
|
||||||
const startTimer = (store: any) => {
|
const startTimer = (store: any) => {
|
||||||
if (playbackTimer) clearInterval(playbackTimer);
|
if (playbackTimer) cancelAnimationFrame(playbackTimer);
|
||||||
|
|
||||||
playbackTimer = setInterval(() => {
|
// Use requestAnimationFrame for smoother updates
|
||||||
|
const updateTime = () => {
|
||||||
const state = store();
|
const state = store();
|
||||||
if (state.isPlaying && state.currentTime < state.duration) {
|
if (state.isPlaying && state.currentTime < state.duration) {
|
||||||
const newTime = state.currentTime + 0.1;
|
const now = performance.now();
|
||||||
|
const delta = (now - lastUpdate) / 1000; // Convert to seconds
|
||||||
|
lastUpdate = now;
|
||||||
|
|
||||||
|
const newTime = state.currentTime + (delta * state.speed);
|
||||||
if (newTime >= state.duration) {
|
if (newTime >= state.duration) {
|
||||||
state.pause();
|
state.pause();
|
||||||
} else {
|
} else {
|
||||||
@ -23,12 +28,16 @@ const startTimer = (store: any) => {
|
|||||||
window.dispatchEvent(new CustomEvent('playback-update', { detail: { time: newTime } }));
|
window.dispatchEvent(new CustomEvent('playback-update', { detail: { time: newTime } }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 100);
|
playbackTimer = requestAnimationFrame(updateTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
let lastUpdate = performance.now();
|
||||||
|
playbackTimer = requestAnimationFrame(updateTime);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopTimer = () => {
|
const stopTimer = () => {
|
||||||
if (playbackTimer) {
|
if (playbackTimer) {
|
||||||
clearInterval(playbackTimer);
|
cancelAnimationFrame(playbackTimer);
|
||||||
playbackTimer = null;
|
playbackTimer = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -38,6 +47,7 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
|
|||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
volume: 1,
|
volume: 1,
|
||||||
|
speed: 1.0,
|
||||||
|
|
||||||
play: () => {
|
play: () => {
|
||||||
set({ isPlaying: true });
|
set({ isPlaying: true });
|
||||||
@ -64,10 +74,20 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
|
|||||||
set({ currentTime: clampedTime });
|
set({ currentTime: clampedTime });
|
||||||
|
|
||||||
// Notify video elements to seek
|
// Notify video elements to seek
|
||||||
window.dispatchEvent(new CustomEvent('playback-seek', { detail: { time: clampedTime } }));
|
const event = new CustomEvent('playback-seek', { detail: { time: clampedTime } });
|
||||||
|
window.dispatchEvent(event);
|
||||||
},
|
},
|
||||||
|
|
||||||
setVolume: (volume: number) => set({ volume: Math.max(0, Math.min(1, volume)) }),
|
setVolume: (volume: number) => set({ volume: Math.max(0, Math.min(1, volume)) }),
|
||||||
|
|
||||||
|
setSpeed: (speed: number) => {
|
||||||
|
const newSpeed = Math.max(0.1, Math.min(2.0, speed));
|
||||||
|
set({ speed: newSpeed });
|
||||||
|
|
||||||
|
const event = new CustomEvent('playback-speed', { detail: { speed: newSpeed } });
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
},
|
||||||
|
|
||||||
setDuration: (duration: number) => set({ duration }),
|
setDuration: (duration: number) => set({ duration }),
|
||||||
setCurrentTime: (time: number) => set({ currentTime: time }),
|
setCurrentTime: (time: number) => set({ currentTime: time }),
|
||||||
}));
|
}));
|
@ -21,6 +21,13 @@ export interface TimelineTrack {
|
|||||||
interface TimelineStore {
|
interface TimelineStore {
|
||||||
tracks: TimelineTrack[];
|
tracks: TimelineTrack[];
|
||||||
|
|
||||||
|
// Multi-selection
|
||||||
|
selectedClips: { trackId: string; clipId: string }[];
|
||||||
|
selectClip: (trackId: string, clipId: string, multi?: boolean) => void;
|
||||||
|
deselectClip: (trackId: string, clipId: string) => void;
|
||||||
|
clearSelectedClips: () => void;
|
||||||
|
setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
addTrack: (type: "video" | "audio" | "effects") => string;
|
addTrack: (type: "video" | "audio" | "effects") => string;
|
||||||
removeTrack: (trackId: string) => void;
|
removeTrack: (trackId: string) => void;
|
||||||
@ -50,6 +57,33 @@ interface TimelineStore {
|
|||||||
|
|
||||||
export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
||||||
tracks: [],
|
tracks: [],
|
||||||
|
selectedClips: [],
|
||||||
|
|
||||||
|
selectClip: (trackId, clipId, multi = false) => {
|
||||||
|
set((state) => {
|
||||||
|
const exists = state.selectedClips.some(
|
||||||
|
(c) => c.trackId === trackId && c.clipId === clipId
|
||||||
|
);
|
||||||
|
if (multi) {
|
||||||
|
// Toggle selection
|
||||||
|
return exists
|
||||||
|
? { selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)) }
|
||||||
|
: { selectedClips: [...state.selectedClips, { trackId, clipId }] };
|
||||||
|
} else {
|
||||||
|
return { selectedClips: [{ trackId, clipId }] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deselectClip: (trackId, clipId) => {
|
||||||
|
set((state) => ({
|
||||||
|
selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
clearSelectedClips: () => {
|
||||||
|
set({ selectedClips: [] });
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedClips: (clips) => set({ selectedClips: clips }),
|
||||||
|
|
||||||
addTrack: (type) => {
|
addTrack: (type) => {
|
||||||
const newTrack: TimelineTrack = {
|
const newTrack: TimelineTrack = {
|
||||||
|
@ -3,6 +3,7 @@ export interface PlaybackState {
|
|||||||
currentTime: number;
|
currentTime: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
|
speed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlaybackControls {
|
export interface PlaybackControls {
|
||||||
@ -10,5 +11,6 @@ export interface PlaybackControls {
|
|||||||
pause: () => void;
|
pause: () => void;
|
||||||
seek: (time: number) => void;
|
seek: (time: number) => void;
|
||||||
setVolume: (volume: number) => void;
|
setVolume: (volume: number) => void;
|
||||||
|
setSpeed: (speed: number) => void;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
}
|
}
|
Reference in New Issue
Block a user