Merge branch 'main' into main
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@ -19,3 +19,6 @@
|
|||||||
# typescript
|
# typescript
|
||||||
/apps/web/next-env.d.ts
|
/apps/web/next-env.d.ts
|
||||||
/apps/web/yarn.lock
|
/apps/web/yarn.lock
|
||||||
|
|
||||||
|
# asdf version management
|
||||||
|
.tool-versions
|
||||||
|
87
README.md
87
README.md
@ -26,29 +26,74 @@ A free, open-source video editor for web, desktop, and mobile.
|
|||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
1. **Clone the repository:**
|
### Prerequisites
|
||||||
```bash
|
|
||||||
git clone <repo-url>
|
Before you begin, ensure you have the following installed on your system:
|
||||||
cd OpenCut
|
|
||||||
```
|
- [Bun](https://bun.sh/docs/installation)
|
||||||
2. **Install dependencies:**
|
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
|
||||||
```bash
|
- [Node.js](https://nodejs.org/en/) (for `npm` alternative)
|
||||||
cd apps/web
|
|
||||||
npm install
|
### Setup
|
||||||
# or, with Bun
|
|
||||||
bun install
|
1. **Clone the repository**
|
||||||
```
|
```bash
|
||||||
3. **Run the development server:**
|
git clone <repo-url>
|
||||||
```bash
|
cd OpenCut
|
||||||
npm run dev
|
```
|
||||||
# or, with Bun
|
|
||||||
bun run dev
|
2. **Start backend services**
|
||||||
```
|
From the project root, start the PostgreSQL and Redis services:
|
||||||
4. **Open in browser:**
|
```bash
|
||||||
Visit [http://localhost:3000](http://localhost:3000)
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Set up environment variables**
|
||||||
|
Navigate into the web app's directory and create a `.env` file from the example:
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
*The default values in the `.env` file should work for local development.*
|
||||||
|
|
||||||
|
4. **Install dependencies**
|
||||||
|
Install the project dependencies using `bun` (recommended) or `npm`.
|
||||||
|
```bash
|
||||||
|
# With bun
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Or with npm
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Run database migrations**
|
||||||
|
Apply the database schema to your local database:
|
||||||
|
```bash
|
||||||
|
# With bun
|
||||||
|
bun run db:push:local
|
||||||
|
|
||||||
|
# Or with npm
|
||||||
|
npm run db:push:local
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Start the development server**
|
||||||
|
```bash
|
||||||
|
# With bun
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# Or with npm
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at [http://localhost:3000](http://localhost:3000).
|
||||||
|
|
||||||
|
=======
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
||||||
|
=======
|
||||||
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
|
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
|
||||||
|
|
||||||
Quick start for contributors:
|
Quick start for contributors:
|
||||||
@ -59,4 +104,4 @@ Quick start for contributors:
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT [Details](LICENSE)
|
[MIT LICENSE](LICENSE)
|
||||||
|
36
apps/web/Dockerfile
Normal file
36
apps/web/Dockerfile
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
FROM oven/bun:latest AS base
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# Production image
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["bun", "server.js"]
|
BIN
apps/web/bun.lockb
Executable file
BIN
apps/web/bun.lockb
Executable file
Binary file not shown.
176
apps/web/src/app/(auth)/login/page.tsx
Normal file
176
apps/web/src/app/(auth)/login/page.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
201
apps/web/src/app/(auth)/signup/page.tsx
Normal file
201
apps/web/src/app/(auth)/signup/page.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -1,5 +0,0 @@
|
|||||||
import { AuthForm } from "@/components/auth-form";
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
|
||||||
return <AuthForm mode="login" />;
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
import { AuthForm } from "@/components/auth-form";
|
|
||||||
|
|
||||||
export default function SignUpPage() {
|
|
||||||
return <AuthForm mode="signup" />;
|
|
||||||
}
|
|
4
apps/web/src/app/editor/editor.css
Normal file
4
apps/web/src/app/editor/editor.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/* Prevent scroll jumping on Mac devices when using the editor */
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import "./editor.css";
|
||||||
import {
|
import {
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
|
@ -54,7 +54,7 @@ const authConfig = {
|
|||||||
description: "Sign in to your account to continue",
|
description: "Sign in to your account to continue",
|
||||||
buttonText: "Sign in",
|
buttonText: "Sign in",
|
||||||
linkText: "Don't have an account?",
|
linkText: "Don't have an account?",
|
||||||
linkHref: "/auth/signup",
|
linkHref: "/signup",
|
||||||
linkLabel: "Sign up",
|
linkLabel: "Sign up",
|
||||||
successRedirect: "/editor",
|
successRedirect: "/editor",
|
||||||
},
|
},
|
||||||
@ -63,9 +63,9 @@ const authConfig = {
|
|||||||
description: "Get started with your free account today",
|
description: "Get started with your free account today",
|
||||||
buttonText: "Create account",
|
buttonText: "Create account",
|
||||||
linkText: "Already have an account?",
|
linkText: "Already have an account?",
|
||||||
linkHref: "/auth/login",
|
linkHref: "/login",
|
||||||
linkLabel: "Sign in",
|
linkLabel: "Sign in",
|
||||||
successRedirect: "/auth/login",
|
successRedirect: "/login",
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
@ -38,7 +38,6 @@ export function PropertiesPanel() {
|
|||||||
? mediaItems.find((item) => item.id === firstVideoClip.mediaId)
|
? mediaItems.find((item) => item.id === firstVideoClip.mediaId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Get the first image clip for preview (simplified)
|
|
||||||
const firstImageClip = tracks
|
const firstImageClip = tracks
|
||||||
.flatMap((track) => track.clips)
|
.flatMap((track) => track.clips)
|
||||||
.find((clip) => {
|
.find((clip) => {
|
||||||
|
@ -40,7 +40,7 @@ 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, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips } =
|
const { tracks, addTrack, addClipToTrack, removeTrack, toggleTrackMute, removeClipFromTrack, moveClipToTrack, getTotalDuration, selectedClips, selectClip, deselectClip, clearSelectedClips, setSelectedClips, updateClipTrim, undo, redo } =
|
||||||
useTimelineStore();
|
useTimelineStore();
|
||||||
const { mediaItems, addMediaItem } = useMediaStore();
|
const { mediaItems, addMediaItem } = useMediaStore();
|
||||||
const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore();
|
const { currentTime, duration, seek, setDuration, isPlaying, play, pause, toggle, setSpeed, speed } = usePlaybackStore();
|
||||||
@ -102,6 +102,33 @@ export function Timeline() {
|
|||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [selectedClips, removeClipFromTrack, clearSelectedClips]);
|
}, [selectedClips, removeClipFromTrack, clearSelectedClips]);
|
||||||
|
|
||||||
|
// Keyboard event for undo (Cmd+Z)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
undo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [undo]);
|
||||||
|
|
||||||
|
// Keyboard event for redo (Cmd+Shift+Z or Cmd+Y)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
redo();
|
||||||
|
} else if ((e.metaKey || e.ctrlKey) && e.key === "y") {
|
||||||
|
e.preventDefault();
|
||||||
|
redo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [redo]);
|
||||||
|
|
||||||
// Mouse down on timeline background to start marquee
|
// Mouse down on timeline background to start marquee
|
||||||
const handleTimelineMouseDown = (e: React.MouseEvent) => {
|
const handleTimelineMouseDown = (e: React.MouseEvent) => {
|
||||||
if (e.target === e.currentTarget && e.button === 0) {
|
if (e.target === e.currentTarget && e.button === 0) {
|
||||||
@ -294,18 +321,32 @@ export function Timeline() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Deselect all clips when clicking empty timeline area
|
const handleSeekToPosition = (e: React.MouseEvent) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const clickX = e.clientX - rect.left;
|
||||||
|
const clickedTime = clickX / (50 * zoomLevel);
|
||||||
|
const clampedTime = Math.max(0, Math.min(duration, clickedTime));
|
||||||
|
|
||||||
|
seek(clampedTime);
|
||||||
|
};
|
||||||
|
|
||||||
const handleTimelineAreaClick = (e: React.MouseEvent) => {
|
const handleTimelineAreaClick = (e: React.MouseEvent) => {
|
||||||
// Only clear selection if the click target is the timeline background (not a child/clip)
|
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
clearSelectedClips();
|
clearSelectedClips();
|
||||||
|
|
||||||
|
// Calculate the clicked time position and seek to it
|
||||||
|
handleSeekToPosition(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWheel = (e: React.WheelEvent) => {
|
const handleWheel = (e: React.WheelEvent) => {
|
||||||
e.preventDefault();
|
// Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
|
||||||
const delta = e.deltaY > 0 ? -0.05 : 0.05;
|
if (e.ctrlKey || e.metaKey) {
|
||||||
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? -0.05 : 0.05;
|
||||||
|
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
|
||||||
|
}
|
||||||
|
// Otherwise, allow normal scrolling
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Playhead Scrubbing Handlers ---
|
// --- Playhead Scrubbing Handlers ---
|
||||||
@ -350,6 +391,92 @@ export function Timeline() {
|
|||||||
onDrop: handleDrop,
|
onDrop: handleDrop,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Action handlers for toolbar
|
||||||
|
const handleSplitSelected = () => {
|
||||||
|
if (selectedClips.length === 0) {
|
||||||
|
toast.error("No clips selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedClips.forEach(({ trackId, clipId }) => {
|
||||||
|
const track = tracks.find(t => t.id === trackId);
|
||||||
|
const clip = track?.clips.find(c => c.id === clipId);
|
||||||
|
if (clip && track) {
|
||||||
|
const splitTime = currentTime;
|
||||||
|
const effectiveStart = clip.startTime;
|
||||||
|
const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||||
|
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
|
||||||
|
updateClipTrim(track.id, clip.id, clip.trimStart, clip.trimEnd + (effectiveEnd - splitTime));
|
||||||
|
addClipToTrack(track.id, {
|
||||||
|
mediaId: clip.mediaId,
|
||||||
|
name: clip.name + " (split)",
|
||||||
|
duration: clip.duration,
|
||||||
|
startTime: splitTime,
|
||||||
|
trimStart: clip.trimStart + (splitTime - effectiveStart),
|
||||||
|
trimEnd: clip.trimEnd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
toast.success("Split selected clip(s)");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicateSelected = () => {
|
||||||
|
if (selectedClips.length === 0) {
|
||||||
|
toast.error("No clips selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedClips.forEach(({ trackId, clipId }) => {
|
||||||
|
const track = tracks.find(t => t.id === trackId);
|
||||||
|
const clip = track?.clips.find(c => c.id === clipId);
|
||||||
|
if (clip && track) {
|
||||||
|
addClipToTrack(track.id, {
|
||||||
|
mediaId: clip.mediaId,
|
||||||
|
name: clip.name + " (copy)",
|
||||||
|
duration: clip.duration,
|
||||||
|
startTime: clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd) + 0.1,
|
||||||
|
trimStart: clip.trimStart,
|
||||||
|
trimEnd: clip.trimEnd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
toast.success("Duplicated selected clip(s)");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFreezeSelected = () => {
|
||||||
|
if (selectedClips.length === 0) {
|
||||||
|
toast.error("No clips selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedClips.forEach(({ trackId, clipId }) => {
|
||||||
|
const track = tracks.find(t => t.id === trackId);
|
||||||
|
const clip = track?.clips.find(c => c.id === clipId);
|
||||||
|
if (clip && track) {
|
||||||
|
// Add a new freeze frame clip at the playhead
|
||||||
|
addClipToTrack(track.id, {
|
||||||
|
mediaId: clip.mediaId,
|
||||||
|
name: clip.name + " (freeze)",
|
||||||
|
duration: 1, // 1 second freeze frame
|
||||||
|
startTime: currentTime,
|
||||||
|
trimStart: 0,
|
||||||
|
trimEnd: clip.duration - 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
toast.success("Freeze frame added for selected clip(s)");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSelected = () => {
|
||||||
|
if (selectedClips.length === 0) {
|
||||||
|
toast.error("No clips selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedClips.forEach(({ trackId, clipId }) => {
|
||||||
|
removeClipFromTrack(trackId, clipId);
|
||||||
|
});
|
||||||
|
clearSelectedClips();
|
||||||
|
toast.success("Deleted selected clip(s)");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`h-full flex flex-col transition-colors duration-200 relative ${isDragOver ? "bg-accent/30 border-accent" : ""}`}
|
className={`h-full flex flex-col transition-colors duration-200 relative ${isDragOver ? "bg-accent/30 border-accent" : ""}`}
|
||||||
@ -422,7 +549,7 @@ export function Timeline() {
|
|||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon" onClick={handleSplitSelected}>
|
||||||
<Scissors className="h-4 w-4" />
|
<Scissors className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@ -458,7 +585,7 @@ export function Timeline() {
|
|||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon" onClick={handleDuplicateSelected}>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@ -467,7 +594,7 @@ export function Timeline() {
|
|||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon" onClick={handleFreezeSelected}>
|
||||||
<Snowflake className="h-4 w-4" />
|
<Snowflake className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@ -476,7 +603,7 @@ export function Timeline() {
|
|||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon" onClick={handleDeleteSelected}>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@ -526,10 +653,14 @@ export function Timeline() {
|
|||||||
<div className="flex-1 relative overflow-hidden">
|
<div className="flex-1 relative overflow-hidden">
|
||||||
<ScrollArea className="w-full">
|
<ScrollArea className="w-full">
|
||||||
<div
|
<div
|
||||||
className="relative h-12 bg-muted/30"
|
className="relative h-12 bg-muted/30 cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
|
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
|
||||||
}}
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Calculate the clicked time position and seek to it
|
||||||
|
handleSeekToPosition(e);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Time markers */}
|
{/* Time markers */}
|
||||||
{(() => {
|
{(() => {
|
||||||
@ -653,15 +784,14 @@ export function Timeline() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline Tracks Content */}
|
{/* Timeline Tracks Content */}
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative overflow-hidden">
|
||||||
<ScrollArea className="h-full w-full">
|
<div className="w-full h-[600px] overflow-hidden flex" ref={timelineRef} style={{ position: 'relative' }}>
|
||||||
|
{/* Timeline grid and clips area (with left margin for sidebar) */}
|
||||||
<div
|
<div
|
||||||
ref={timelineRef}
|
className="relative flex-1"
|
||||||
className="relative cursor-pointer select-none"
|
|
||||||
style={{
|
style={{
|
||||||
|
height: `${tracks.length * 60}px`,
|
||||||
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
|
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
|
||||||
minHeight:
|
|
||||||
tracks.length > 0 ? `${tracks.length * 60}px` : "200px",
|
|
||||||
}}
|
}}
|
||||||
onClick={handleTimelineAreaClick}
|
onClick={handleTimelineAreaClick}
|
||||||
onMouseDown={handleTimelineMouseDown}
|
onMouseDown={handleTimelineMouseDown}
|
||||||
@ -704,7 +834,6 @@ export function Timeline() {
|
|||||||
zoomLevel={zoomLevel}
|
zoomLevel={zoomLevel}
|
||||||
setContextMenu={setContextMenu}
|
setContextMenu={setContextMenu}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@ -722,7 +851,7 @@ export function Timeline() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -788,7 +917,7 @@ export function Timeline() {
|
|||||||
const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||||
|
|
||||||
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
|
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
|
||||||
useTimelineStore.getState().updateClipTrim(
|
updateClipTrim(
|
||||||
track.id,
|
track.id,
|
||||||
clip.id,
|
clip.id,
|
||||||
clip.trimStart,
|
clip.trimStart,
|
||||||
@ -1452,9 +1581,18 @@ function TimelineTrackContent({
|
|||||||
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
|
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
const isSelected = selectedClips.some(
|
||||||
|
(c) => c.trackId === track.id && c.clipId === clip.id
|
||||||
|
);
|
||||||
|
|
||||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||||
|
// Multi-selection mode: toggle the clip
|
||||||
selectClip(track.id, clip.id, true);
|
selectClip(track.id, clip.id, true);
|
||||||
|
} else if (isSelected) {
|
||||||
|
// If clip is already selected, deselect it
|
||||||
|
deselectClip(track.id, clip.id);
|
||||||
} else {
|
} else {
|
||||||
|
// If clip is not selected, select it (replacing other selections)
|
||||||
selectClip(track.id, clip.id, false);
|
selectClip(track.id, clip.id, false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -1527,8 +1665,7 @@ function TimelineTrackContent({
|
|||||||
{/* Drop position indicator */}
|
{/* Drop position indicator */}
|
||||||
{isDraggedOver && dropPosition !== null && (
|
{isDraggedOver && dropPosition !== null && (
|
||||||
<div
|
<div
|
||||||
className={`absolute top-0 bottom-0 w-1 pointer-events-none z-30 transition-all duration-75 ease-out ${wouldOverlap ? "bg-red-500" : "bg-blue-500"
|
className={`absolute top-0 bottom-0 w-1 pointer-events-none z-30 transition-all duration-75 ease-out ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
left: `${dropPosition * 50 * zoomLevel}px`,
|
left: `${dropPosition * 50 * zoomLevel}px`,
|
||||||
transform: "translateX(-50%)",
|
transform: "translateX(-50%)",
|
||||||
@ -1553,4 +1690,4 @@ function TimelineTrackContent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -6,9 +6,27 @@ 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";
|
import { useSession } from "@/lib/auth-client";
|
||||||
|
import { getStars } from "@/lib/fetchGhStars";
|
||||||
|
import { Star } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { data: session } = useSession();
|
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 = (
|
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} />
|
||||||
@ -19,11 +37,18 @@ export function Header() {
|
|||||||
const rightContent = (
|
const rightContent = (
|
||||||
<nav className="flex items-center">
|
<nav className="flex items-center">
|
||||||
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
|
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
|
||||||
<Button variant="ghost" className="text-sm">
|
<Button
|
||||||
GitHub
|
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>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={session ? "/editor" : "/auth/login"}>
|
<Link href={session ? "/editor" : "/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" />
|
||||||
|
@ -5,18 +5,33 @@ import { Button } from "../ui/button";
|
|||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { getStars } from "@/lib/fetchGhStars";
|
||||||
|
|
||||||
interface HeroProps {
|
interface HeroProps {
|
||||||
signupCount: number;
|
signupCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Hero({ signupCount }: HeroProps) {
|
export function Hero({ signupCount }: HeroProps) {
|
||||||
|
const [star, setStar] = useState<string>();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { toast } = useToast();
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@ -67,7 +82,7 @@ export function Hero({ signupCount }: HeroProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen flex flex-col items-center justify-center text-center px-4">
|
<div className="relative min-h-[calc(100vh-4rem)] flex flex-col items-center justify-center text-center px-4">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
@ -152,7 +167,7 @@ export function Hero({ signupCount }: HeroProps) {
|
|||||||
href="https://github.com/OpenCut-app/OpenCut"
|
href="https://github.com/OpenCut-app/OpenCut"
|
||||||
className="text-foreground underline"
|
className="text-foreground underline"
|
||||||
>
|
>
|
||||||
GitHub
|
GitHub {star}+
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
29
apps/web/src/lib/fetchGhStars.ts
Normal file
29
apps/web/src/lib/fetchGhStars.ts
Normal file
@ -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";
|
||||||
|
}
|
||||||
|
}
|
@ -7,7 +7,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
const session = getSessionCookie(request);
|
const session = getSessionCookie(request);
|
||||||
|
|
||||||
if (path === "/editor" && !session && process.env.NODE_ENV === "production") {
|
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);
|
loginUrl.searchParams.set("redirect", request.url);
|
||||||
return NextResponse.redirect(loginUrl);
|
return NextResponse.redirect(loginUrl);
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,8 @@ export interface TimelineTrack {
|
|||||||
|
|
||||||
interface TimelineStore {
|
interface TimelineStore {
|
||||||
tracks: TimelineTrack[];
|
tracks: TimelineTrack[];
|
||||||
|
history: TimelineTrack[][];
|
||||||
|
redoStack: TimelineTrack[][];
|
||||||
|
|
||||||
// Multi-selection
|
// Multi-selection
|
||||||
selectedClips: { trackId: string; clipId: string }[];
|
selectedClips: { trackId: string; clipId: string }[];
|
||||||
@ -53,12 +55,39 @@ interface TimelineStore {
|
|||||||
|
|
||||||
// Computed values
|
// Computed values
|
||||||
getTotalDuration: () => number;
|
getTotalDuration: () => number;
|
||||||
|
|
||||||
|
// New actions
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
pushHistory: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
||||||
tracks: [],
|
tracks: [],
|
||||||
|
history: [],
|
||||||
|
redoStack: [],
|
||||||
selectedClips: [],
|
selectedClips: [],
|
||||||
|
|
||||||
|
pushHistory: () => {
|
||||||
|
const { tracks, history, redoStack } = get();
|
||||||
|
// Deep copy tracks
|
||||||
|
set({
|
||||||
|
history: [...history, JSON.parse(JSON.stringify(tracks))],
|
||||||
|
redoStack: [] // Clear redo stack when new action is performed
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
undo: () => {
|
||||||
|
const { history, redoStack, tracks } = get();
|
||||||
|
if (history.length === 0) return;
|
||||||
|
const prev = history[history.length - 1];
|
||||||
|
set({
|
||||||
|
tracks: prev,
|
||||||
|
history: history.slice(0, -1),
|
||||||
|
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))] // Add current state to redo stack
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
selectClip: (trackId, clipId, multi = false) => {
|
selectClip: (trackId, clipId, multi = false) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const exists = state.selectedClips.some(
|
const exists = state.selectedClips.some(
|
||||||
@ -86,6 +115,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
setSelectedClips: (clips) => set({ selectedClips: clips }),
|
setSelectedClips: (clips) => set({ selectedClips: clips }),
|
||||||
|
|
||||||
addTrack: (type) => {
|
addTrack: (type) => {
|
||||||
|
get().pushHistory();
|
||||||
const newTrack: TimelineTrack = {
|
const newTrack: TimelineTrack = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
|
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
|
||||||
@ -100,12 +130,14 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
removeTrack: (trackId) => {
|
removeTrack: (trackId) => {
|
||||||
|
get().pushHistory();
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
tracks: state.tracks.filter((track) => track.id !== trackId),
|
tracks: state.tracks.filter((track) => track.id !== trackId),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
addClipToTrack: (trackId, clipData) => {
|
addClipToTrack: (trackId, clipData) => {
|
||||||
|
get().pushHistory();
|
||||||
const newClip: TimelineClip = {
|
const newClip: TimelineClip = {
|
||||||
...clipData,
|
...clipData,
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@ -124,19 +156,21 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
removeClipFromTrack: (trackId, clipId) => {
|
removeClipFromTrack: (trackId, clipId) => {
|
||||||
|
get().pushHistory();
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
tracks: state.tracks.map((track) =>
|
tracks: state.tracks
|
||||||
track.id === trackId
|
.map((track) =>
|
||||||
? {
|
track.id === trackId
|
||||||
...track,
|
? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) }
|
||||||
clips: track.clips.filter((clip) => clip.id !== clipId),
|
: track
|
||||||
}
|
)
|
||||||
: track
|
// Remove track if it becomes empty
|
||||||
),
|
.filter((track) => track.clips.length > 0),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
|
moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
|
||||||
|
get().pushHistory();
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
|
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
|
||||||
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
|
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
|
||||||
@ -144,25 +178,29 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
if (!clipToMove) return state;
|
if (!clipToMove) return state;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tracks: state.tracks.map((track) => {
|
tracks: state.tracks
|
||||||
if (track.id === fromTrackId) {
|
.map((track) => {
|
||||||
return {
|
if (track.id === fromTrackId) {
|
||||||
...track,
|
return {
|
||||||
clips: track.clips.filter((clip) => clip.id !== clipId),
|
...track,
|
||||||
};
|
clips: track.clips.filter((clip) => clip.id !== clipId),
|
||||||
} else if (track.id === toTrackId) {
|
};
|
||||||
return {
|
} else if (track.id === toTrackId) {
|
||||||
...track,
|
return {
|
||||||
clips: [...track.clips, clipToMove],
|
...track,
|
||||||
};
|
clips: [...track.clips, clipToMove],
|
||||||
}
|
};
|
||||||
return track;
|
}
|
||||||
}),
|
return track;
|
||||||
|
})
|
||||||
|
// Remove track if it becomes empty
|
||||||
|
.filter((track) => track.clips.length > 0),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
|
updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
|
||||||
|
get().pushHistory();
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
tracks: state.tracks.map((track) =>
|
tracks: state.tracks.map((track) =>
|
||||||
track.id === trackId
|
track.id === trackId
|
||||||
@ -178,6 +216,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateClipStartTime: (trackId, clipId, startTime) => {
|
updateClipStartTime: (trackId, clipId, startTime) => {
|
||||||
|
get().pushHistory();
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
tracks: state.tracks.map((track) =>
|
tracks: state.tracks.map((track) =>
|
||||||
track.id === trackId
|
track.id === trackId
|
||||||
@ -193,6 +232,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
toggleTrackMute: (trackId) => {
|
toggleTrackMute: (trackId) => {
|
||||||
|
get().pushHistory();
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
tracks: state.tracks.map((track) =>
|
tracks: state.tracks.map((track) =>
|
||||||
track.id === trackId ? { ...track, muted: !track.muted } : track
|
track.id === trackId ? { ...track, muted: !track.muted } : track
|
||||||
@ -214,4 +254,11 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({
|
|||||||
|
|
||||||
return Math.max(...trackEndTimes, 0);
|
return Math.max(...trackEndTimes, 0);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
redo: () => {
|
||||||
|
const { redoStack } = get();
|
||||||
|
if (redoStack.length === 0) return;
|
||||||
|
const next = redoStack[redoStack.length - 1];
|
||||||
|
set({ tracks: next, redoStack: redoStack.slice(0, -1) });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
@ -18,7 +18,8 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@ -36,12 +37,46 @@ services:
|
|||||||
SRH_MODE: env
|
SRH_MODE: env
|
||||||
SRH_TOKEN: example_token
|
SRH_TOKEN: example_token
|
||||||
SRH_CONNECTION_STRING: "redis://redis:6379"
|
SRH_CONNECTION_STRING: "redis://redis:6379"
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget --spider -q http://127.0.0.1:80 || exit 1"]
|
test: ["CMD-SHELL", "wget --spider -q http://127.0.0.1:80 || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
web:
|
||||||
volumes:
|
build:
|
||||||
|
context: ./apps/web
|
||||||
|
dockerfile: ./apps/web/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATABASE_URL=postgresql://opencut:opencutthegoat@db:5432/opencut
|
||||||
|
- BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
- BETTER_AUTH_SECRET=your-production-secret-key-here
|
||||||
|
- UPSTASH_REDIS_REST_URL=http://serverless-redis-http:80
|
||||||
|
- UPSTASH_REDIS_REST_TOKEN=example_token
|
||||||
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||||
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
serverless-redis-http:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -f http://localhost:3000/api/health || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: opencut-network
|
||||||
|
2
package-lock.json
generated
2
package-lock.json
generated
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Opencut",
|
"name": "OpenCut",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {}
|
"packages": {}
|
||||||
|
Reference in New Issue
Block a user