diff options
Diffstat (limited to 'apps/web/src')
| -rw-r--r-- | apps/web/src/app/(auth)/login/page.tsx | 176 | ||||
| -rw-r--r-- | apps/web/src/app/(auth)/signup/page.tsx | 201 | ||||
| -rw-r--r-- | apps/web/src/app/auth/login/page.tsx | 98 | ||||
| -rw-r--r-- | apps/web/src/app/auth/signup/page.tsx | 108 | ||||
| -rw-r--r-- | apps/web/src/app/favicon.ico | bin | 4286 -> 4286 bytes | |||
| -rw-r--r-- | apps/web/src/components/auth-form.tsx | 398 | ||||
| -rw-r--r-- | apps/web/src/components/editor/properties-panel.tsx | 1 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline.tsx | 24 | ||||
| -rw-r--r-- | apps/web/src/components/header.tsx | 33 | ||||
| -rw-r--r-- | apps/web/src/components/icons.tsx | 22 | ||||
| -rw-r--r-- | apps/web/src/components/landing/hero.tsx | 23 | ||||
| -rw-r--r-- | apps/web/src/lib/auth-client.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/lib/auth.ts | 6 | ||||
| -rw-r--r-- | apps/web/src/lib/fetchGhStars.ts | 29 | ||||
| -rw-r--r-- | apps/web/src/lib/ffmpeg-utils.ts | 64 | ||||
| -rw-r--r-- | apps/web/src/middleware.ts | 2 |
16 files changed, 953 insertions, 234 deletions
diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx new file mode 100644 index 00000000..5f37ced9 --- /dev/null +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -0,0 +1,176 @@ +"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="/signup" + className="font-medium text-primary underline-offset-4 hover:underline" + > + Sign up + </Link> + </div> + </CardContent> + </Card> + </div> + ); +} diff --git a/apps/web/src/app/(auth)/signup/page.tsx b/apps/web/src/app/(auth)/signup/page.tsx new file mode 100644 index 00000000..d1feece2 --- /dev/null +++ b/apps/web/src/app/(auth)/signup/page.tsx @@ -0,0 +1,201 @@ +"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("/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="/login" + className="font-medium text-primary underline-offset-4 hover:underline" + > + Sign in + </Link> + </div> + </CardContent> + </Card> + </div> + ); +} diff --git a/apps/web/src/app/auth/login/page.tsx b/apps/web/src/app/auth/login/page.tsx deleted file mode 100644 index 5eadb85c..00000000 --- a/apps/web/src/app/auth/login/page.tsx +++ /dev/null @@ -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> - ); -} diff --git a/apps/web/src/app/auth/signup/page.tsx b/apps/web/src/app/auth/signup/page.tsx deleted file mode 100644 index f328851c..00000000 --- a/apps/web/src/app/auth/signup/page.tsx +++ /dev/null @@ -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> - ); -} diff --git a/apps/web/src/app/favicon.ico b/apps/web/src/app/favicon.ico Binary files differindex 66b4ee0f..cb20127b 100644 --- a/apps/web/src/app/favicon.ico +++ b/apps/web/src/app/favicon.ico diff --git a/apps/web/src/components/auth-form.tsx b/apps/web/src/components/auth-form.tsx new file mode 100644 index 00000000..3dd56036 --- /dev/null +++ b/apps/web/src/components/auth-form.tsx @@ -0,0 +1,398 @@ +"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 { useState } from "react";
+import { Input } from "@/components/ui/input";
+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";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+
+// Zod schemas
+const loginSchema = z.object({
+ email: z.string().email("Please enter a valid email address"),
+ password: z.string().min(1, "Password is required"),
+});
+
+const signupSchema = z.object({
+ name: z.string().min(2, "Name must be at least 2 characters"),
+ email: z.string().email("Please enter a valid email address"),
+ password: z.string().min(8, "Password must be at least 8 characters"),
+});
+
+type LoginFormData = z.infer<typeof loginSchema>;
+type SignupFormData = z.infer<typeof signupSchema>;
+
+interface AuthFormProps {
+ mode: "login" | "signup";
+}
+
+const authConfig = {
+ login: {
+ title: "Welcome back",
+ description: "Sign in to your account to continue",
+ buttonText: "Sign in",
+ linkText: "Don't have an account?",
+ linkHref: "/signup",
+ linkLabel: "Sign up",
+ successRedirect: "/editor",
+ },
+ signup: {
+ title: "Create your account",
+ description: "Get started with your free account today",
+ buttonText: "Create account",
+ linkText: "Already have an account?",
+ linkHref: "/login",
+ linkLabel: "Sign in",
+ successRedirect: "/login",
+ },
+} as const;
+
+interface AuthFormContentProps {
+ error: string | null;
+ setError: (error: string | null) => void;
+ isGoogleLoading: boolean;
+ config: typeof authConfig.login | typeof authConfig.signup;
+ router: ReturnType<typeof useRouter>;
+}
+
+function LoginFormContent({
+ error,
+ setError,
+ isGoogleLoading,
+ config,
+ router,
+}: AuthFormContentProps) {
+ const form = useForm<LoginFormData>({
+ resolver: zodResolver(loginSchema),
+ defaultValues: { email: "", password: "" },
+ });
+
+ const { isSubmitting } = form.formState;
+ const isAnyLoading = isSubmitting || isGoogleLoading;
+
+ const onSubmit = async (data: LoginFormData) => {
+ setError(null);
+
+ try {
+ const { error } = await signIn.email({
+ email: data.email,
+ password: data.password,
+ });
+
+ if (error) {
+ setError(error.message || "An unexpected error occurred.");
+ return;
+ }
+
+ router.push(config.successRedirect);
+ } catch (error) {
+ setError("An unexpected error occurred. Please try again.");
+ }
+ };
+
+ return (
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Email</FormLabel>
+ <FormControl>
+ <Input
+ type="email"
+ placeholder="m@example.com"
+ disabled={isAnyLoading}
+ className="h-11"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="password"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Password</FormLabel>
+ <FormControl>
+ <Input
+ type="password"
+ disabled={isAnyLoading}
+ className="h-11"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Button
+ type="submit"
+ disabled={isAnyLoading}
+ className="w-full h-11"
+ size="lg"
+ >
+ {isSubmitting ? (
+ <Loader2 className="animate-spin" />
+ ) : (
+ config.buttonText
+ )}
+ </Button>
+ </form>
+ </Form>
+ );
+}
+
+function SignupFormContent({
+ error,
+ setError,
+ isGoogleLoading,
+ config,
+ router,
+}: AuthFormContentProps) {
+ const form = useForm<SignupFormData>({
+ resolver: zodResolver(signupSchema),
+ defaultValues: { email: "", password: "", name: "" },
+ });
+
+ const { isSubmitting } = form.formState;
+ const isAnyLoading = isSubmitting || isGoogleLoading;
+
+ const onSubmit = async (data: SignupFormData) => {
+ setError(null);
+
+ try {
+ const { error } = await signUp.email({
+ name: data.name,
+ email: data.email,
+ password: data.password,
+ });
+
+ if (error) {
+ setError(error.message || "An unexpected error occurred.");
+ return;
+ }
+
+ router.push(config.successRedirect);
+ } catch (error) {
+ setError("An unexpected error occurred. Please try again.");
+ }
+ };
+
+ return (
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Full Name</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="John Doe"
+ disabled={isAnyLoading}
+ className="h-11"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Email</FormLabel>
+ <FormControl>
+ <Input
+ type="email"
+ placeholder="m@example.com"
+ disabled={isAnyLoading}
+ className="h-11"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="password"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Password</FormLabel>
+ <FormControl>
+ <Input
+ type="password"
+ placeholder="Create a strong password"
+ disabled={isAnyLoading}
+ className="h-11"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Button
+ type="submit"
+ disabled={isAnyLoading}
+ className="w-full h-11"
+ size="lg"
+ >
+ {isSubmitting ? (
+ <Loader2 className="animate-spin" />
+ ) : (
+ config.buttonText
+ )}
+ </Button>
+ </form>
+ </Form>
+ );
+}
+
+export function AuthForm({ mode }: AuthFormProps) {
+ const router = useRouter();
+ const [error, setError] = useState<string | null>(null);
+ const [isGoogleLoading, setIsGoogleLoading] = useState(false);
+ const config = authConfig[mode];
+
+ const handleGoogleAuth = async () => {
+ setError(null);
+ setIsGoogleLoading(true);
+
+ try {
+ await signIn.social({
+ provider: "google",
+ });
+
+ router.push(config.successRedirect);
+ } catch (error) {
+ setError(
+ `Failed to ${mode === "login" ? "sign in" : "sign up"} with Google. Please try again.`
+ );
+ setIsGoogleLoading(false);
+ }
+ };
+
+ 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">
+ {config.title}
+ </CardTitle>
+ <CardDescription className="text-base">
+ {config.description}
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="pt-0">
+ <div className="flex flex-col space-y-6">
+ {error && (
+ <Alert variant="destructive">
+ <AlertTitle>Error</AlertTitle>
+ <AlertDescription>{error}</AlertDescription>
+ </Alert>
+ )}
+
+ <Button
+ onClick={handleGoogleAuth}
+ variant="outline"
+ size="lg"
+ disabled={isGoogleLoading}
+ >
+ {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>
+
+ {mode === "login" ? (
+ <LoginFormContent
+ error={error}
+ setError={setError}
+ isGoogleLoading={isGoogleLoading}
+ config={config}
+ router={router}
+ />
+ ) : (
+ <SignupFormContent
+ error={error}
+ setError={setError}
+ isGoogleLoading={isGoogleLoading}
+ config={config}
+ router={router}
+ />
+ )}
+ </div>
+
+ <div className="mt-6 text-center text-sm">
+ {config.linkText}{" "}
+ <Link
+ href={config.linkHref}
+ className="font-medium text-primary underline-offset-4 hover:underline"
+ >
+ {config.linkLabel}
+ </Link>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/properties-panel.tsx b/apps/web/src/components/editor/properties-panel.tsx index 2b211c21..c784dab8 100644 --- a/apps/web/src/components/editor/properties-panel.tsx +++ b/apps/web/src/components/editor/properties-panel.tsx @@ -38,7 +38,6 @@ export function PropertiesPanel() { ? mediaItems.find((item) => item.id === firstVideoClip.mediaId) : null; - // Get the first image clip for preview (simplified) const firstImageClip = tracks .flatMap((track) => track.clips) .find((clip) => { diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index cf6b57e6..db810d30 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -571,8 +571,26 @@ export function Timeline() { {/* Playhead in ruler */}
<div
- className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-none z-10"
+ className="absolute top-0 bottom-0 w-0.5 bg-red-500 cursor-ew-resize z-1000" // Remove pointer-events-none to allow dragging
style={{ left: `${currentTime * 50 * zoomLevel}px` }}
+ onMouseDown={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const handleMouseMove = (e: MouseEvent) => {
+ const timeline = timelineRef.current; // Get timeline element ref to track the position
+ if (!timeline) return; // If no timeline element, exit
+ const rect = timeline.getBoundingClientRect(); // Get the bounding rect of the timeline element
+ const mouseX = Math.max(0, e.clientX - rect.left); // Calculate the mouse position relative to the timeline element
+ const newTime = mouseX / (50 * zoomLevel); // Calculate the time based on the mouse position
+ seek(newTime); // Set the current time
+ };
+ const handleMouseUp = () => {
+ window.removeEventListener("mousemove", handleMouseMove); // Remove the mousemove event listener
+ window.removeEventListener("mouseup", handleMouseUp); // Remove the mouseup event listener
+ };
+ window.addEventListener("mousemove", handleMouseMove); // Add the mousemove event listener
+ window.addEventListener("mouseup", handleMouseUp); // Add the mouseup event listener
+ }}
>
<div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-red-500 rounded-full border-2 border-white shadow-sm" />
</div>
@@ -684,7 +702,7 @@ export function Timeline() { {/* Playhead for tracks area */}
<div
- className="absolute top-0 w-0.5 bg-red-500 pointer-events-none z-20"
+ className="absolute top-0 w-0.5 bg-red-500 cursor-ew-resize z-20"
style={{
left: `${currentTime * 50 * zoomLevel}px`,
height: `${tracks.length * 60}px`,
@@ -1524,4 +1542,4 @@ function TimelineTrackContent({ </div>
</div>
);
-}
\ No newline at end of file +}
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 004863d9..9f842b24 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -5,8 +5,28 @@ import Image from "next/image"; import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react";
import { HeaderBase } from "./header-base";
+import { useSession } from "@/lib/auth-client";
+import { getStars } from "@/lib/fetchGhStars";
+import { Star } from "lucide-react";
+import { useEffect, useState } from "react";
export function Header() {
+ const { data: session } = useSession();
+ const [star, setStar] = useState<string>("");
+
+ useEffect(() => {
+ const fetchStars = async () => {
+ try {
+ const data = await getStars();
+ setStar(data);
+ } catch (err) {
+ console.error("Failed to fetch GitHub stars", err);
+ }
+ };
+
+ fetchStars();
+ }, []);
+
const leftContent = (
<Link href="/" className="flex items-center gap-3">
<Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
@@ -17,11 +37,18 @@ export function Header() { const rightContent = (
<nav className="flex items-center">
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
- <Button variant="ghost" className="text-sm">
- GitHub
+ <Button
+ variant="ghost"
+ className="flex items-center text-sm text-muted-foreground hover:text-foreground"
+ >
+ <span className="hidden sm:inline">GitHub</span>
+ <span className="text-foreground flex items-center">
+ {star}+
+ <Star className="w-4 h-4 ml-1" />
+ </span>
</Button>
</Link>
- <Link href="/editor">
+ <Link href={session ? "/editor" : "/login"}>
<Button size="sm" className="text-sm ml-4">
Start editing
<ArrowRight className="h-4 w-4" />
diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx new file mode 100644 index 00000000..5cc24157 --- /dev/null +++ b/apps/web/src/components/icons.tsx @@ -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> + ); +} diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index f2c70f5d..3fa959b5 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -5,18 +5,33 @@ import { Button } from "../ui/button"; import { Input } from "../ui/input";
import { ArrowRight } from "lucide-react";
import Link from "next/link";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { useToast } from "@/hooks/use-toast";
+import { getStars } from "@/lib/fetchGhStars";
interface HeroProps {
signupCount: number;
}
export function Hero({ signupCount }: HeroProps) {
+ const [star, setStar] = useState<string>();
const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
+ useEffect(() => {
+ const fetchStars = async () => {
+ try {
+ const data = await getStars();
+ setStar(data);
+ } catch (err) {
+ console.error("Failed to fetch GitHub stars", err);
+ }
+ };
+
+ fetchStars();
+ }, []);
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -84,12 +99,12 @@ export function Hero({ signupCount }: HeroProps) { The open source
</h1>
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter mt-2">
- CapCut alternative.
+ video editor
</h1>
</motion.div>
<motion.p
- className="mt-12 text-lg sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
+ className="mt-10 text-lg sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4, duration: 0.8 }}
@@ -152,7 +167,7 @@ export function Hero({ signupCount }: HeroProps) { href="https://github.com/OpenCut-app/OpenCut"
className="text-foreground underline"
>
- GitHub
+ GitHub {star}+
</Link>
</motion.div>
</div>
diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index 7c467e7e..1ab4e636 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -1,4 +1,4 @@ import { createAuthClient } from "better-auth/react";
-export const authClient = createAuthClient({
+export const { signIn, signUp, useSession } = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL!,
});
diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 4cfaa6f2..d0175c97 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -16,6 +16,12 @@ export const auth = betterAuth({ emailAndPassword: {
enabled: true,
},
+ socialProviders: {
+ google: {
+ clientId: process.env.GOOGLE_CLIENT_ID as string,
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
+ },
+ },
appName: "OpenCut",
trustedOrigins: ["http://localhost:3000"],
});
diff --git a/apps/web/src/lib/fetchGhStars.ts b/apps/web/src/lib/fetchGhStars.ts new file mode 100644 index 00000000..e0236840 --- /dev/null +++ b/apps/web/src/lib/fetchGhStars.ts @@ -0,0 +1,29 @@ +export async function getStars(): Promise<string> { + try { + const res = await fetch( + "https://api.github.com/repos/OpenCut-app/OpenCut", + { + next: { revalidate: 3600 }, + } + ); + + if (!res.ok) { + throw new Error(`GitHub API error: ${res.status} ${res.statusText}`); + } + const data = await res.json(); + const count = data.stargazers_count; + + if (typeof count !== "number") { + throw new Error("Invalid stargazers_count from GitHub API"); + } + + if (count >= 1_000_000) + return (count / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M"; + if (count >= 1_000) + return (count / 1_000).toFixed(1).replace(/\.0$/, "") + "k"; + return count.toString(); + } catch (error) { + console.error("Failed to fetch GitHub stars:", error); + return "1.5k"; + } +} diff --git a/apps/web/src/lib/ffmpeg-utils.ts b/apps/web/src/lib/ffmpeg-utils.ts index 56085a21..ae0c267b 100644 --- a/apps/web/src/lib/ffmpeg-utils.ts +++ b/apps/web/src/lib/ffmpeg-utils.ts @@ -102,26 +102,60 @@ export const getVideoInfo = async (videoFile: File): Promise<{ fps: number; }> => { const ffmpeg = await initFFmpeg(); - + const inputName = 'input.mp4'; - + // Write input file await ffmpeg.writeFile(inputName, new Uint8Array(await videoFile.arrayBuffer())); - - // Get video info - await ffmpeg.exec(['-i', inputName, '-f', 'null', '-']); - - // Note: In a real implementation, you'd parse the FFmpeg output - // For now, we'll return default values and enhance this later - + + // Capture FFmpeg stderr output with a one-time listener pattern + let ffmpegOutput = ''; + let listening = true; + const listener = (data: string) => { + 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 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 { - duration: 10, // Placeholder - would parse from FFmpeg output - width: 1920, // Placeholder - height: 1080, // Placeholder - fps: 30 // Placeholder + duration, + width, + height, + fps }; }; @@ -194,4 +228,4 @@ export const extractAudio = async ( await ffmpeg.deleteFile(outputName); return blob; -};
\ No newline at end of file +};
\ No newline at end of file diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 7f096cd5..f2d3f590 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -7,7 +7,7 @@ export async function middleware(request: NextRequest) { const session = getSessionCookie(request);
if (path === "/editor" && !session && process.env.NODE_ENV === "production") {
- const loginUrl = new URL("/auth/login", request.url);
+ const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", request.url);
return NextResponse.redirect(loginUrl);
}
|
