Compare commits
1 Commits
main
...
revert-141
Author | SHA1 | Date | |
---|---|---|---|
8433324f5a |
5
.github/CONTRIBUTING.md
vendored
5
.github/CONTRIBUTING.md
vendored
@ -10,11 +10,6 @@ Thank you for your interest in contributing to OpenCut! This document provides g
|
||||
4. Install dependencies: `bun install`
|
||||
5. Start the development server: `bun run dev`
|
||||
|
||||
> **Note:** If you see an error like `Unsupported URL Type "workspace:*"` when running `npm install`, you have two options:
|
||||
>
|
||||
> 1. Upgrade to a recent npm version (v9 or later), which has full workspace protocol support.
|
||||
> 2. Use an alternative package manager such as **bun** or **pnpm**.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
4
.github/workflows/bun-ci.yml
vendored
4
.github/workflows/bun-ci.yml
vendored
@ -31,13 +31,13 @@ jobs:
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
|
||||
with:
|
||||
bun-version: 1.2.18
|
||||
bun-version: 1.2.17
|
||||
|
||||
- name: Cache Bun modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-1.2.18-${{ hashFiles('apps/web/bun.lock') }}
|
||||
key: ${{ runner.os }}-bun-1.2.17-${{ hashFiles('apps/web/bun.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/web
|
||||
|
2
.npmrc
2
.npmrc
@ -1,2 +1,2 @@
|
||||
install-strategy="nested"
|
||||
node-linker=isolated
|
||||
node-linker=isolated
|
151
README.md
151
README.md
@ -10,6 +10,10 @@
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## Why?
|
||||
|
||||
- **Privacy**: Your videos stay on your device
|
||||
@ -45,122 +49,81 @@ Before you begin, ensure you have the following installed on your system:
|
||||
|
||||
### Setup
|
||||
|
||||
## Getting Started
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd OpenCut
|
||||
```
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork locally
|
||||
3. Navigate to the web app directory: `cd apps/web`
|
||||
4. Install dependencies: `bun install`
|
||||
5. Start the development server: `bun run dev`
|
||||
2. **Start backend services**
|
||||
From the project root, start the PostgreSQL and Redis services:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Development Setup
|
||||
3. **Set up environment variables**
|
||||
Navigate into the web app's directory and create a `.env` file from the example:
|
||||
```bash
|
||||
cd apps/web
|
||||
|
||||
### Prerequisites
|
||||
|
||||
# Unix/Linux/Mac
|
||||
cp .env.example .env.local
|
||||
|
||||
- Node.js 18+
|
||||
- Bun (latest version)
|
||||
- Docker (for local database)
|
||||
# Windows Command Prompt
|
||||
copy .env.example .env.local
|
||||
|
||||
# Windows PowerShell
|
||||
Copy-Item .env.example .env.local
|
||||
```
|
||||
*The default values in the `.env` file should work for local development.*
|
||||
|
||||
### Local Development
|
||||
4. **Install dependencies**
|
||||
Install the project dependencies using `bun` (recommended) or `npm`.
|
||||
```bash
|
||||
# With bun
|
||||
bun install
|
||||
|
||||
1. Start the database and Redis services:
|
||||
# Or with npm
|
||||
npm install
|
||||
```
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
docker-compose up -d
|
||||
```
|
||||
5. **Run database migrations**
|
||||
Apply the database schema to your local database:
|
||||
```bash
|
||||
# With bun
|
||||
bun run db:push:local
|
||||
|
||||
2. Navigate to the web app directory:
|
||||
# Or with npm
|
||||
npm run db:push:local
|
||||
```
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
```
|
||||
6. **Start the development server**
|
||||
```bash
|
||||
# With bun
|
||||
bun run dev
|
||||
|
||||
3. Copy `.env.example` to `.env.local`:
|
||||
|
||||
```bash
|
||||
# Unix/Linux/Mac
|
||||
cp .env.example .env.local
|
||||
|
||||
# Windows Command Prompt
|
||||
copy .env.example .env.local
|
||||
|
||||
# Windows PowerShell
|
||||
Copy-Item .env.example .env.local
|
||||
```
|
||||
|
||||
4. Configure required environment variables in `.env.local`:
|
||||
|
||||
**Required Variables:**
|
||||
|
||||
```bash
|
||||
# Database (matches docker-compose.yaml)
|
||||
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
|
||||
|
||||
# Generate a secure secret for Better Auth
|
||||
BETTER_AUTH_SECRET="your-generated-secret-here"
|
||||
BETTER_AUTH_URL="http://localhost:3000"
|
||||
|
||||
# Redis (matches docker-compose.yaml)
|
||||
UPSTASH_REDIS_REST_URL="http://localhost:8079"
|
||||
UPSTASH_REDIS_REST_TOKEN="example_token"
|
||||
|
||||
# Development
|
||||
NODE_ENV="development"
|
||||
```
|
||||
|
||||
**Generate BETTER_AUTH_SECRET:**
|
||||
|
||||
```bash
|
||||
# Unix/Linux/Mac
|
||||
openssl rand -base64 32
|
||||
|
||||
# Windows PowerShell (simple method)
|
||||
[System.Web.Security.Membership]::GeneratePassword(32, 0)
|
||||
|
||||
# Cross-platform (using Node.js)
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
|
||||
# Or use an online generator: https://generate-secret.vercel.app/32
|
||||
```
|
||||
|
||||
**Optional Variables (for Google OAuth):**
|
||||
|
||||
```bash
|
||||
# Only needed if you want to test Google login
|
||||
GOOGLE_CLIENT_ID="your-google-client-id"
|
||||
GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
||||
```
|
||||
|
||||
5. Run database migrations: `bun run db:migrate` from (inside apps/web)
|
||||
6. Start the development server: `bun run dev` from (inside apps/web)
|
||||
# Or with npm
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The application will be available at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
**Note**: We're currently moving at an extremely fast pace with rapid development and breaking changes. While we appreciate the interest, it's recommended to wait until the project stabilizes before contributing to avoid conflicts and wasted effort.
|
||||
|
||||
## Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
||||
|
||||
Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
||||
---
|
||||
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:
|
||||
|
||||
- Fork the repo and clone locally
|
||||
- Follow the setup instructions in CONTRIBUTING.md
|
||||
- Create a feature branch and submit a PR
|
||||
|
||||
## Sponsors
|
||||
|
||||
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FOpenCut-app%2FOpenCut&project-name=opencut&repository-name=opencut)
|
||||
|
||||
## License
|
||||
|
||||
[MIT LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
@ -7,18 +7,6 @@ const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
productionBrowserSourceMaps: true,
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "plus.unsplash.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
@ -2,9 +2,9 @@
|
||||
"name": "opencut",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1.2.18",
|
||||
"packageManager": "bun@1.2.17",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
@ -68,4 +68,4 @@
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "@opencut/auth/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@ -9,7 +10,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { memo, Suspense } from "react";
|
||||
import { Suspense, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@ -17,22 +18,121 @@ 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 { useLogin } from "@/hooks/auth/useLogin";
|
||||
|
||||
const LoginPage = () => {
|
||||
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",
|
||||
callbackURL: "/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();
|
||||
const {
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
error,
|
||||
isAnyLoading,
|
||||
isEmailLoading,
|
||||
isGoogleLoading,
|
||||
handleLogin,
|
||||
handleGoogleLogin,
|
||||
} = useLogin();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center relative">
|
||||
@ -58,85 +158,19 @@ const LoginPage = () => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(LoginPage);
|
||||
|
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signUp, signIn } from "@opencut/auth/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@ -9,32 +10,151 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { memo, Suspense } from "react";
|
||||
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 { Loader2, ArrowLeft } from "lucide-react";
|
||||
import { GoogleIcon } from "@/components/icons";
|
||||
import { useSignUp } from "@/hooks/auth/useSignUp";
|
||||
|
||||
const SignUpPage = () => {
|
||||
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();
|
||||
const {
|
||||
name,
|
||||
setName,
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
error,
|
||||
isAnyLoading,
|
||||
isEmailLoading,
|
||||
isGoogleLoading,
|
||||
handleSignUp,
|
||||
handleGoogleSignUp,
|
||||
} = useSignUp();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center relative">
|
||||
@ -45,6 +165,7 @@ const SignUpPage = () => {
|
||||
>
|
||||
<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">
|
||||
@ -62,101 +183,19 @@ const SignUpPage = () => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SignUpPage);
|
||||
|
@ -47,7 +47,7 @@ async function getContributors(): Promise<Contributor[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
const contributors = (await response.json()) as Contributor[];
|
||||
const contributors = await response.json();
|
||||
|
||||
const filteredContributors = contributors.filter(
|
||||
(contributor: Contributor) => contributor.type === "User"
|
||||
@ -78,15 +78,10 @@ export default async function ContributorsPage() {
|
||||
<div className="relative container mx-auto px-4 py-16">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-20">
|
||||
<Link
|
||||
href={"https://github.com/OpenCut-app/OpenCut"}
|
||||
target="_blank"
|
||||
>
|
||||
<Badge variant="secondary" className="gap-2 mb-6">
|
||||
<GithubIcon className="h-3 w-3" />
|
||||
Open Source
|
||||
</Badge>
|
||||
</Link>
|
||||
<Badge variant="secondary" className="gap-2 mb-6">
|
||||
<GithubIcon className="h-3 w-3" />
|
||||
Open Source
|
||||
</Badge>
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
||||
Contributors
|
||||
</h1>
|
||||
@ -145,6 +140,9 @@ export default async function ContributorsPage() {
|
||||
{contributor.login.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="absolute -top-2 -right-2 bg-foreground text-background rounded-full w-8 h-8 flex items-center justify-center text-sm font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
|
||||
{contributor.login}
|
||||
|
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,16 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import "./editor.css";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "../../../components/ui/resizable";
|
||||
import { MediaPanel } from "../../../components/editor/media-panel";
|
||||
import { PropertiesPanel } from "../../../components/editor/properties-panel";
|
||||
import { Timeline } from "../../../components/editor/timeline";
|
||||
import { PreviewPanel } from "../../../components/editor/preview-panel";
|
||||
} from "../../components/ui/resizable";
|
||||
import { MediaPanel } from "../../components/editor/media-panel";
|
||||
// import { PropertiesPanel } from "../../components/editor/properties-panel";
|
||||
import { Timeline } from "../../components/editor/timeline";
|
||||
import { PreviewPanel } from "../../components/editor/preview-panel";
|
||||
import { EditorHeader } from "@/components/editor-header";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
@ -21,47 +21,32 @@ export default function Editor() {
|
||||
const {
|
||||
toolsPanel,
|
||||
previewPanel,
|
||||
propertiesPanel,
|
||||
mainContent,
|
||||
timeline,
|
||||
setToolsPanel,
|
||||
setPreviewPanel,
|
||||
setPropertiesPanel,
|
||||
setMainContent,
|
||||
setTimeline,
|
||||
propertiesPanel,
|
||||
setPropertiesPanel,
|
||||
} = usePanelStore();
|
||||
|
||||
const { activeProject, loadProject, createNewProject } = useProjectStore();
|
||||
const params = useParams();
|
||||
const projectId = params.project_id as string;
|
||||
const { activeProject, createNewProject } = useProjectStore();
|
||||
|
||||
usePlaybackControls();
|
||||
|
||||
useEffect(() => {
|
||||
const initializeProject = async () => {
|
||||
if (projectId && (!activeProject || activeProject.id !== projectId)) {
|
||||
try {
|
||||
await loadProject(projectId);
|
||||
} catch (error) {
|
||||
console.error("Failed to load project:", error);
|
||||
// If project doesn't exist, create a new one
|
||||
await createNewProject("Untitled Project");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeProject();
|
||||
}, [projectId, activeProject, loadProject, createNewProject]);
|
||||
if (!activeProject) {
|
||||
createNewProject("Untitled Project");
|
||||
}
|
||||
}, [activeProject, createNewProject]);
|
||||
|
||||
return (
|
||||
<EditorProvider>
|
||||
<div className="h-screen w-screen flex flex-col bg-background overflow-hidden">
|
||||
<EditorHeader />
|
||||
<div className="flex-1 min-h-0 min-w-0">
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<ResizablePanelGroup direction="vertical" className="h-full w-full">
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
@ -70,10 +55,7 @@ export default function Editor() {
|
||||
className="min-h-0"
|
||||
>
|
||||
{/* Main content area */}
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem] px-2"
|
||||
>
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
||||
{/* Tools Panel */}
|
||||
<ResizablePanel
|
||||
defaultSize={toolsPanel}
|
||||
@ -99,7 +81,8 @@ export default function Editor() {
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
{/* Properties Panel - Hidden for now but ready */}
|
||||
{/* <ResizablePanel
|
||||
defaultSize={propertiesPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
@ -107,7 +90,7 @@ export default function Editor() {
|
||||
className="min-w-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanel> */}
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
@ -119,7 +102,7 @@ export default function Editor() {
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0 px-2 pb-2"
|
||||
className="min-h-0"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
@ -39,13 +39,13 @@
|
||||
--sidebar-ring: 0 0% 3.9%;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 4%;
|
||||
--foreground: 0 0% 89%;
|
||||
--card: 0 0% 14.9%;
|
||||
--background: 0 0% 8%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 14.9%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 180 95% 40%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
@ -71,8 +71,6 @@
|
||||
--sidebar-accent-foreground: 0 0% 98%;
|
||||
--sidebar-border: 0 0% 14.9%;
|
||||
--sidebar-ring: 0 0% 83.1%;
|
||||
--panel-background: 0 0% 11%;
|
||||
--panel-accent: 0 0% 15%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,7 +80,5 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Prevent back/forward swipe */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { Inter } from "next/font/google";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import Script from "next/script";
|
||||
@ -5,9 +6,12 @@ import "./globals.css";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { TooltipProvider } from "../components/ui/tooltip";
|
||||
import { DevelopmentDebug } from "../components/development-debug";
|
||||
import { StorageProvider } from "../components/storage-provider";
|
||||
import { baseMetaData } from "./metadata";
|
||||
import { defaultFont } from "../lib/font-config";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
});
|
||||
|
||||
export const metadata = baseMetaData;
|
||||
|
||||
@ -18,23 +22,22 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${defaultFont.className} font-sans antialiased`}>
|
||||
<ThemeProvider attribute="class" forcedTheme="dark">
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<ThemeProvider attribute="class" forcedTheme="dark" enableSystem>
|
||||
<TooltipProvider>
|
||||
<StorageProvider>{children}</StorageProvider>
|
||||
{children}
|
||||
<Analytics />
|
||||
<Toaster />
|
||||
<DevelopmentDebug />
|
||||
<Script
|
||||
src="https://cdn.databuddy.cc/databuddy.js"
|
||||
src="https://app.databuddy.cc/databuddy.js"
|
||||
strategy="afterInteractive"
|
||||
async
|
||||
data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
|
||||
data-track-attributes={false}
|
||||
data-track-attributes={true}
|
||||
data-track-errors={true}
|
||||
data-track-outgoing-links={false}
|
||||
data-track-web-vitals={false}
|
||||
data-track-sessions={false}
|
||||
data-track-outgoing-links={true}
|
||||
data-track-web-vitals={true}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
|
@ -2,6 +2,7 @@ import { Hero } from "@/components/landing/hero";
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { getWaitlistCount } from "@/lib/waitlist";
|
||||
import Image from "next/image";
|
||||
|
||||
// Force dynamic rendering so waitlist count updates in real-time
|
||||
export const dynamic = "force-dynamic";
|
||||
@ -11,6 +12,13 @@ export default async function Home() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Image
|
||||
className="fixed top-0 left-0 -z-50 size-full object-cover"
|
||||
src="/landing-page-bg.png"
|
||||
height={1903.5}
|
||||
width={1269}
|
||||
alt="landing-page.bg"
|
||||
/>
|
||||
<Header />
|
||||
<Hero signupCount={signupCount} />
|
||||
<Footer />
|
||||
|
@ -1,552 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
ChevronLeft,
|
||||
Plus,
|
||||
Calendar,
|
||||
MoreHorizontal,
|
||||
Video,
|
||||
Loader2,
|
||||
X,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { TProject } from "@/types/project";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DeleteProjectDialog } from "@/components/delete-project-dialog";
|
||||
import { RenameProjectDialog } from "@/components/rename-project-dialog";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const {
|
||||
createNewProject,
|
||||
savedProjects,
|
||||
isLoading,
|
||||
isInitialized,
|
||||
deleteProject,
|
||||
} = useProjectStore();
|
||||
const router = useRouter();
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
const projectId = await createNewProject("New Project");
|
||||
console.log("projectId", projectId);
|
||||
router.push(`/editor/${projectId}`);
|
||||
};
|
||||
|
||||
const handleSelectProject = (projectId: string, checked: boolean) => {
|
||||
const newSelected = new Set(selectedProjects);
|
||||
if (checked) {
|
||||
newSelected.add(projectId);
|
||||
} else {
|
||||
newSelected.delete(projectId);
|
||||
}
|
||||
setSelectedProjects(newSelected);
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedProjects(new Set(savedProjects.map((p) => p.id)));
|
||||
} else {
|
||||
setSelectedProjects(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelSelection = () => {
|
||||
setIsSelectionMode(false);
|
||||
setSelectedProjects(new Set());
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
await Promise.all(
|
||||
Array.from(selectedProjects).map((projectId) => deleteProject(projectId))
|
||||
);
|
||||
setSelectedProjects(new Set());
|
||||
setIsSelectionMode(false);
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
const allSelected =
|
||||
savedProjects.length > 0 && selectedProjects.size === savedProjects.length;
|
||||
const someSelected =
|
||||
selectedProjects.size > 0 && selectedProjects.size < savedProjects.length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="pt-6 px-6 flex items-center justify-between w-full h-16">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-1 hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<ChevronLeft className="!size-5 shrink-0" />
|
||||
<span className="text-sm font-medium">Back</span>
|
||||
</Link>
|
||||
<div className="block md:hidden">
|
||||
{isSelectionMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelSelection}
|
||||
>
|
||||
<X className="!size-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
{selectedProjects.size > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="!size-4" />
|
||||
Delete ({selectedProjects.size})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<CreateButton onClick={handleCreateProject} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<main className="max-w-6xl mx-auto px-6 pt-6 pb-6">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
|
||||
Your Projects
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{savedProjects.length}{" "}
|
||||
{savedProjects.length === 1 ? "project" : "projects"}
|
||||
{isSelectionMode && selectedProjects.size > 0 && (
|
||||
<span className="ml-2 text-primary">
|
||||
• {selectedProjects.size} selected
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
{isSelectionMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancelSelection}>
|
||||
<X className="!size-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
{selectedProjects.size > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsBulkDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="!size-4" />
|
||||
Delete Selected ({selectedProjects.size})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsSelectionMode(true)}
|
||||
disabled={savedProjects.length === 0}
|
||||
>
|
||||
Select Projects
|
||||
</Button>
|
||||
<CreateButton onClick={handleCreateProject} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelectionMode && savedProjects.length > 0 && (
|
||||
<div className="mb-6 p-4 bg-muted/30 rounded-lg border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
const checkboxElement = el.querySelector(
|
||||
"input"
|
||||
) as HTMLInputElement;
|
||||
if (checkboxElement) {
|
||||
checkboxElement.indeterminate = someSelected;
|
||||
}
|
||||
}
|
||||
}}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{allSelected ? "Deselect All" : "Select All"}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({selectedProjects.size} of {savedProjects.length} selected)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading || !isInitialized ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
|
||||
</div>
|
||||
) : savedProjects.length === 0 ? (
|
||||
<NoProjects onCreateProject={handleCreateProject} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{savedProjects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
isSelectionMode={isSelectionMode}
|
||||
isSelected={selectedProjects.has(project.id)}
|
||||
onSelect={handleSelectProject}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<DeleteProjectDialog
|
||||
isOpen={isBulkDeleteDialogOpen}
|
||||
onOpenChange={setIsBulkDeleteDialogOpen}
|
||||
onConfirm={handleBulkDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: TProject;
|
||||
isSelectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onSelect?: (projectId: string, checked: boolean) => void;
|
||||
}
|
||||
|
||||
function ProjectCard({
|
||||
project,
|
||||
isSelectionMode = false,
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
}: ProjectCardProps) {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const { deleteProject, renameProject, duplicateProject } = useProjectStore();
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
await deleteProject(project.id);
|
||||
setIsDropdownOpen(false);
|
||||
};
|
||||
|
||||
const handleRenameProject = async (newName: string) => {
|
||||
await renameProject(project.id, newName);
|
||||
setIsRenameDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleDuplicateProject = async () => {
|
||||
setIsDropdownOpen(false);
|
||||
await duplicateProject(project.id);
|
||||
};
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
if (isSelectionMode) {
|
||||
e.preventDefault();
|
||||
onSelect?.(project.id, !isSelected);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSelectionMode ? (
|
||||
<div onClick={handleCardClick} className="block group cursor-pointer">
|
||||
<Card
|
||||
className={`overflow-hidden bg-background border-none p-0 transition-all ${
|
||||
isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`relative aspect-square bg-muted transition-opacity ${
|
||||
isDropdownOpen
|
||||
? "opacity-65"
|
||||
: "opacity-100 group-hover:opacity-65"
|
||||
}`}
|
||||
>
|
||||
{/* Selection checkbox */}
|
||||
{isSelectionMode && (
|
||||
<div className="absolute top-3 left-3 z-10">
|
||||
<div className="w-5 h-5 rounded bg-background/80 backdrop-blur-sm border flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) =>
|
||||
onSelect?.(project.id, checked as boolean)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail preview or placeholder */}
|
||||
<div className="absolute inset-0">
|
||||
{project.thumbnail ? (
|
||||
<Image
|
||||
src={project.thumbnail}
|
||||
alt="Project thumbnail"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted/50 flex items-center justify-center">
|
||||
<Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="px-0 pt-5 flex flex-col gap-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
|
||||
{project.name}
|
||||
</h3>
|
||||
{!isSelectionMode && (
|
||||
<DropdownMenu
|
||||
open={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className={`size-6 p-0 transition-all shrink-0 ml-2 ${
|
||||
isDropdownOpen
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropdownOpen(false);
|
||||
setIsRenameDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDuplicateProject();
|
||||
}}
|
||||
>
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropdownOpen(false);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar className="!size-4" />
|
||||
<span>Created {formatDate(project.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Link href={`/editor/${project.id}`} className="block group">
|
||||
<Card
|
||||
className={`overflow-hidden bg-background border-none p-0 transition-all ${
|
||||
isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`relative aspect-square bg-muted transition-opacity ${
|
||||
isDropdownOpen
|
||||
? "opacity-65"
|
||||
: "opacity-100 group-hover:opacity-65"
|
||||
}`}
|
||||
>
|
||||
{/* Thumbnail preview or placeholder */}
|
||||
<div className="absolute inset-0">
|
||||
{project.thumbnail ? (
|
||||
<Image
|
||||
src={project.thumbnail}
|
||||
alt="Project thumbnail"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-muted/50 flex items-center justify-center">
|
||||
<Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="px-0 pt-5 flex flex-col gap-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
|
||||
{project.name}
|
||||
</h3>
|
||||
<DropdownMenu
|
||||
open={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
className={`size-6 p-0 transition-all shrink-0 ml-2 ${
|
||||
isDropdownOpen
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropdownOpen(false);
|
||||
setIsRenameDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDuplicateProject();
|
||||
}}
|
||||
>
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropdownOpen(false);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar className="!size-4" />
|
||||
<span>Created {formatDate(project.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)}
|
||||
<DeleteProjectDialog
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
onConfirm={handleDeleteProject}
|
||||
/>
|
||||
<RenameProjectDialog
|
||||
isOpen={isRenameDialogOpen}
|
||||
onOpenChange={setIsRenameDialogOpen}
|
||||
onConfirm={handleRenameProject}
|
||||
projectName={project.name}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateButton({ onClick }: { onClick?: () => void }) {
|
||||
return (
|
||||
<Button className="flex" onClick={onClick}>
|
||||
<Plus className="!size-4" />
|
||||
<span className="text-sm font-medium">New project</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function NoProjects({ onCreateProject }: { onCreateProject: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
|
||||
<Video className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">No projects yet</h3>
|
||||
<p className="text-muted-foreground mb-6 max-w-md">
|
||||
Start creating your first video project. Import media, edit, and export
|
||||
professional videos.
|
||||
</p>
|
||||
<Button size="lg" className="gap-2" onClick={onCreateProject}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Your First Project
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,191 +0,0 @@
|
||||
import { Header } from "@/components/header";
|
||||
|
||||
export default function WhyNotCapcut() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background px-5">
|
||||
<Header />
|
||||
|
||||
<main className="relative mt-12">
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative container mx-auto px-4 py-16">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-20">
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
||||
Fuck CapCut
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
|
||||
Roasting time, so get ready motherfucker.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto space-y-12">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
Seriously, what the fuck else do you want?
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
You probably use CapCut and think your video editing is
|
||||
special. You think your fucking TikTok with 47 transitions and
|
||||
12 different fonts is going to get you some viral fame. You
|
||||
think loading up every goddamn effect in their library makes
|
||||
your content better. Wrong, motherfucker. Let me describe what
|
||||
CapCut actually gives you:
|
||||
</p>
|
||||
<ul className="text-lg space-y-2 mb-6 list-disc list-inside">
|
||||
<li>A paywall every time you breathe</li>
|
||||
<li>Terms of service that steal your shit</li>
|
||||
<li>
|
||||
More "Get Pro" dialogs than a Windows 95 error message
|
||||
</li>
|
||||
<li>
|
||||
Features that disappear behind paywalls while you're fucking
|
||||
using them
|
||||
</li>
|
||||
<li>Bugs disguised as "premium features"</li>
|
||||
</ul>
|
||||
<p className="text-lg mb-6">
|
||||
<strong>Well guess what, motherfucker:</strong>
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
You. Are. Getting. Scammed. Look at this shit. It's a fucking
|
||||
video editor. Why the fuck do you need to pay $20/month just
|
||||
to remove a goddamn watermark? You spent hours editing your
|
||||
video and they slap their logo on it like they fucking made
|
||||
it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
The "Get Pro" dialog is everywhere
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
This motherfucking dialog pops up more than ads on a pirated
|
||||
movie site. Want to add a transition? Get Pro. Want to export
|
||||
without their watermark? Get Pro. Want to use more than 2
|
||||
fonts? Get fucking Pro, peasant.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
Did you seriously think you could edit a video without seeing
|
||||
this dialog 47 times? You click one button and BAM - there it
|
||||
is again, asking for your credit card like a desperate ex
|
||||
asking for money.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
Everything costs money now
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
You dumbass. You thought CapCut was free, but no. Free means
|
||||
they let you open the app. Everything else costs money. Basic
|
||||
shake effect? That'll be $20/month. A decent transition that isn't
|
||||
"fade"? Pay up, motherfucker.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
Here's my favorite piece of bullshit: You import an MP3 file -
|
||||
you know, AUDIO - and try to export. "Sorry, can't export
|
||||
because you're using our premium extract audio feature!"
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
<strong>
|
||||
My MP3 was already fucking audio, you absolute morons.
|
||||
</strong>
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
But wait, there's more! If you drag that same MP3 to their
|
||||
media panel first, then to the timeline, it magically works.
|
||||
This isn't a bug, it's a fucking scam disguised as software
|
||||
engineering.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
Their Terms of Service are insane
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
Look at this shit. You upload your content and they basically
|
||||
say "thanks for the free content, we own it now, but if Disney
|
||||
sues anyone, that's your problem."
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
<strong>CapCut's Terms of Service:</strong> We get full rights
|
||||
to use, modify, distribute, and monetize everything you upload
|
||||
- permanently and without paying you shit. But you're still
|
||||
responsible if anything goes wrong.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
Translation: "We'll make money off your viral video, you
|
||||
handle the lawsuits." Brilliant legal strategy, you fucks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
The editor is actually good
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
Here's the thing that makes me want to punch my monitor: the
|
||||
actual video editor is fucking good. It's intuitive, powerful,
|
||||
and anyone can figure it out. When it's not begging for money
|
||||
every 30 seconds, it actually works well.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
Which makes everything else so much worse. They built
|
||||
something people want to use, then turned it into a digital
|
||||
slot machine. Every click might trigger a payment request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
This is a video editor. Look at it. You've never seen one
|
||||
before.
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
Like the person who's never used software that doesn't
|
||||
constantly beg for money, you have no fucking idea what a
|
||||
video editor should be. All you've ever seen are predatory
|
||||
apps disguised as creative tools.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
A real video editor lets you edit videos. It doesn't steal
|
||||
your content. It doesn't pop up payment dialogs every 5
|
||||
seconds. It doesn't charge you separately for basic features
|
||||
that should be free.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
Yes, this is fucking satire, you fuck
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
I'm not actually saying all video editors should be basic as
|
||||
shit. What I'm saying is that all the problems we have with
|
||||
video editing apps are{" "}
|
||||
<strong>ones they create themselves</strong>. Video editors
|
||||
aren't broken by default - they edit videos, export them, and
|
||||
let you use basic features without constantly begging for
|
||||
money. CapCut breaks them. They turn them into payment
|
||||
processors with video editing as a side feature.
|
||||
</p>
|
||||
<p className="text-lg">
|
||||
<em>"Good software gets out of your way."</em>
|
||||
<br />- Some smart motherfucker who definitely wasn't working
|
||||
at CapCut
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,184 +0,0 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Button } from "./ui/button";
|
||||
import { BackgroundIcon } from "./icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Image from "next/image";
|
||||
import { colors } from "@/data/colors";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { PipetteIcon } from "lucide-react";
|
||||
|
||||
type BackgroundTab = "color" | "blur";
|
||||
|
||||
export function BackgroundSettings() {
|
||||
const { activeProject, updateBackgroundType } = useProjectStore();
|
||||
|
||||
// ✅ Good: derive activeTab from activeProject during rendering
|
||||
const activeTab = activeProject?.backgroundType || "color";
|
||||
|
||||
const handleColorSelect = (color: string) => {
|
||||
updateBackgroundType("color", { backgroundColor: color });
|
||||
};
|
||||
|
||||
const handleBlurSelect = (blurIntensity: number) => {
|
||||
updateBackgroundType("blur", { blurIntensity });
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
label: "Color",
|
||||
value: "color",
|
||||
},
|
||||
{
|
||||
label: "Blur",
|
||||
value: "blur",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="!size-4 border border-muted-foreground"
|
||||
>
|
||||
<BackgroundIcon className="!size-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0">
|
||||
<div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3">
|
||||
<h2 className="text-sm">Background</h2>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{tabs.map((tab) => (
|
||||
<span
|
||||
key={tab.value}
|
||||
onClick={() => {
|
||||
// Switch to the background type when clicking tabs
|
||||
if (tab.value === "color") {
|
||||
updateBackgroundType("color", {
|
||||
backgroundColor:
|
||||
activeProject?.backgroundColor || "#000000",
|
||||
});
|
||||
} else {
|
||||
updateBackgroundType("blur", {
|
||||
blurIntensity: activeProject?.blurIntensity || 8,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"text-muted-foreground cursor-pointer",
|
||||
activeTab === tab.value && "text-foreground"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{activeTab === "color" ? (
|
||||
<ColorView
|
||||
selectedColor={activeProject?.backgroundColor || "#000000"}
|
||||
onColorSelect={handleColorSelect}
|
||||
/>
|
||||
) : (
|
||||
<BlurView
|
||||
selectedBlur={activeProject?.blurIntensity || 8}
|
||||
onBlurSelect={handleBlurSelect}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorView({
|
||||
selectedColor,
|
||||
onColorSelect,
|
||||
}: {
|
||||
selectedColor: string;
|
||||
onColorSelect: (color: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-gradient-to-b from-popover to-transparent pointer-events-none"></div>
|
||||
<div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto">
|
||||
<div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
|
||||
<PipetteIcon className="size-4" />
|
||||
</div>
|
||||
{colors.map((color) => (
|
||||
<ColorItem
|
||||
key={color}
|
||||
color={color}
|
||||
isSelected={color === selectedColor}
|
||||
onClick={() => onColorSelect(color)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorItem({
|
||||
color,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
color: string;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary",
|
||||
isSelected && "border-2 border-primary"
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BlurView({
|
||||
selectedBlur,
|
||||
onBlurSelect,
|
||||
}: {
|
||||
selectedBlur: number;
|
||||
onBlurSelect: (blurIntensity: number) => void;
|
||||
}) {
|
||||
const blurLevels = [
|
||||
{ label: "Light", value: 4 },
|
||||
{ label: "Medium", value: 8 },
|
||||
{ label: "Heavy", value: 18 },
|
||||
];
|
||||
const blurImage =
|
||||
"https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 w-full p-3 pt-0">
|
||||
{blurLevels.map((blur) => (
|
||||
<div
|
||||
key={blur.value}
|
||||
className={cn(
|
||||
"w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden",
|
||||
selectedBlur === blur.value && "border-2 border-primary"
|
||||
)}
|
||||
onClick={() => onBlurSelect(blur.value)}
|
||||
>
|
||||
<Image
|
||||
src={blurImage}
|
||||
alt={`Blur preview ${blur.label}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
style={{ filter: `blur(${blur.value}px)` }}
|
||||
/>
|
||||
<div className="absolute bottom-1 left-1 right-1 text-center">
|
||||
<span className="text-xs text-white bg-black/50 px-1 rounded">
|
||||
{blur.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function DeleteProjectDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this project? This action cannot be
|
||||
undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@ -1,18 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import {
|
||||
useTimelineStore,
|
||||
type TimelineClip,
|
||||
type TimelineTrack,
|
||||
} from "@/stores/timeline-store";
|
||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import type { TimelineElement } from "@/types/timeline";
|
||||
|
||||
// Only show in development
|
||||
const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
|
||||
|
||||
interface ActiveElement {
|
||||
element: TimelineElement;
|
||||
interface ActiveClip {
|
||||
clip: TimelineClip;
|
||||
track: TimelineTrack;
|
||||
mediaItem: MediaItem | null;
|
||||
}
|
||||
@ -26,32 +28,31 @@ export function DevelopmentDebug() {
|
||||
// Don't render anything in production
|
||||
if (!SHOW_DEBUG_INFO) return null;
|
||||
|
||||
// Get active elements at current time
|
||||
const getActiveElements = (): ActiveElement[] => {
|
||||
const activeElements: ActiveElement[] = [];
|
||||
// Get active clips at current time
|
||||
const getActiveClips = (): ActiveClip[] => {
|
||||
const activeClips: ActiveClip[] = [];
|
||||
|
||||
tracks.forEach((track) => {
|
||||
track.elements.forEach((element) => {
|
||||
const elementStart = element.startTime;
|
||||
const elementEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
track.clips.forEach((clip) => {
|
||||
const clipStart = clip.startTime;
|
||||
const clipEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime >= elementStart && currentTime < elementEnd) {
|
||||
if (currentTime >= clipStart && currentTime < clipEnd) {
|
||||
const mediaItem =
|
||||
element.type === "media"
|
||||
? mediaItems.find((item) => item.id === element.mediaId) || null
|
||||
: null; // Text elements don't have media items
|
||||
clip.mediaId === "test"
|
||||
? null // Test clips don't have a real media item
|
||||
: mediaItems.find((item) => item.id === clip.mediaId) || null;
|
||||
|
||||
activeElements.push({ element, track, mediaItem });
|
||||
activeClips.push({ clip, track, mediaItem });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return activeElements;
|
||||
return activeClips;
|
||||
};
|
||||
|
||||
const activeElements = getActiveElements();
|
||||
const activeClips = getActiveClips();
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50">
|
||||
@ -70,30 +71,28 @@ export function DevelopmentDebug() {
|
||||
{showDebug && (
|
||||
<div className="backdrop-blur-md bg-background/90 border border-border/50 rounded-lg p-3 max-w-sm">
|
||||
<div className="text-xs font-medium mb-2 text-foreground">
|
||||
Active Elements ({activeElements.length})
|
||||
Active Clips ({activeClips.length})
|
||||
</div>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{activeElements.map((elementData, index) => (
|
||||
{activeClips.map((clipData, index) => (
|
||||
<div
|
||||
key={elementData.element.id}
|
||||
key={clipData.clip.id}
|
||||
className="flex items-center gap-2 px-2 py-1 bg-muted/60 rounded text-xs"
|
||||
>
|
||||
<span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4 flex-shrink-0">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate">{elementData.element.name}</div>
|
||||
<div className="truncate">{clipData.clip.name}</div>
|
||||
<div className="text-muted-foreground text-[10px]">
|
||||
{elementData.element.type === "media"
|
||||
? elementData.mediaItem?.type || "media"
|
||||
: "text"}
|
||||
{clipData.mediaItem?.type || "test"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{activeElements.length === 0 && (
|
||||
{activeClips.length === 0 && (
|
||||
<div className="text-muted-foreground text-xs py-2 text-center">
|
||||
No active elements
|
||||
No active clips
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -5,50 +5,44 @@ import { Button } from "./ui/button";
|
||||
import { ChevronLeft, Download } from "lucide-react";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { HeaderBase } from "./header-base";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { ProjectNameEditor } from "./editor/project-name-editor";
|
||||
|
||||
export function EditorHeader() {
|
||||
const { getTotalDuration } = useTimelineStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
|
||||
const handleExport = () => {
|
||||
// TODO: Implement export functionality
|
||||
console.log("Export project");
|
||||
};
|
||||
|
||||
// Format duration from seconds to MM:SS format
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const leftContent = (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/projects"
|
||||
href="/"
|
||||
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="text-sm">{activeProject?.name}</span>
|
||||
</Link>
|
||||
<ProjectNameEditor />
|
||||
</div>
|
||||
);
|
||||
|
||||
const centerContent = (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span>
|
||||
{formatTimeCode(
|
||||
getTotalDuration(),
|
||||
"HH:MM:SS:FF",
|
||||
activeProject?.fps || 30
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{formatDuration(getTotalDuration())}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const rightContent = (
|
||||
<nav className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Button size="sm" onClick={handleExport}>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-sm">Export</span>
|
||||
</Button>
|
||||
@ -60,7 +54,7 @@ export function EditorHeader() {
|
||||
leftContent={leftContent}
|
||||
centerContent={centerContent}
|
||||
rightContent={rightContent}
|
||||
className="bg-background h-[3.2rem] px-4"
|
||||
className="bg-background border-b"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -1,311 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import { useDragDrop } from "@/hooks/use-drag-drop";
|
||||
import { processMediaFiles } from "@/lib/media-processing";
|
||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||
import { Image, Music, Plus, Upload, Video } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DragOverlay } from "@/components/ui/drag-overlay";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
|
||||
export function MediaView() {
|
||||
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [mediaFilter, setMediaFilter] = useState("all");
|
||||
|
||||
const processFiles = async (files: FileList | File[]) => {
|
||||
if (!files || files.length === 0) return;
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setProgress(0);
|
||||
try {
|
||||
// Process files (extract metadata, generate thumbnails, etc.)
|
||||
const processedItems = await processMediaFiles(files, (p) =>
|
||||
setProgress(p)
|
||||
);
|
||||
// Add each processed media item to the store
|
||||
for (const item of processedItems) {
|
||||
await addMediaItem(activeProject.id, item);
|
||||
}
|
||||
} catch (error) {
|
||||
// Show error toast if processing fails
|
||||
console.error("Error processing files:", error);
|
||||
toast.error("Failed to process files");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const { isDragOver, dragProps } = useDragDrop({
|
||||
// When files are dropped, process them
|
||||
onDrop: processFiles,
|
||||
});
|
||||
|
||||
const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// When files are selected via file picker, process them
|
||||
if (e.target.files) processFiles(e.target.files);
|
||||
e.target.value = ""; // Reset input
|
||||
};
|
||||
|
||||
const handleRemove = async (e: React.MouseEvent, id: string) => {
|
||||
// Remove a media item from the store
|
||||
e.stopPropagation();
|
||||
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
// Media store now handles cascade deletion automatically
|
||||
await removeMediaItem(activeProject.id, id);
|
||||
};
|
||||
|
||||
const formatDuration = (duration: number) => {
|
||||
// Format seconds as mm:ss
|
||||
const min = Math.floor(duration / 60);
|
||||
const sec = Math.floor(duration % 60);
|
||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = mediaItems.filter((item) => {
|
||||
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
searchQuery &&
|
||||
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
setFilteredMediaItems(filtered);
|
||||
}, [mediaItems, mediaFilter, searchQuery]);
|
||||
|
||||
const renderPreview = (item: MediaItem) => {
|
||||
// Render a preview for each media type (image, video, audio, unknown)
|
||||
if (item.type === "image") {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "video") {
|
||||
if (item.thumbnailUrl) {
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<img
|
||||
src={item.thumbnailUrl}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover rounded"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
|
||||
<Video className="h-6 w-6 text-white drop-shadow-md" />
|
||||
</div>
|
||||
{item.duration && (
|
||||
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
|
||||
{formatDuration(item.duration)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
|
||||
<Video className="h-6 w-6 mb-1" />
|
||||
<span className="text-xs">Video</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "audio") {
|
||||
return (
|
||||
<div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
|
||||
<Music className="h-6 w-6 mb-1" />
|
||||
<span className="text-xs">Audio</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
|
||||
<Image className="h-6 w-6" />
|
||||
<span className="text-xs mt-1">Unknown</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hidden file input for uploading media */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*,audio/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
|
||||
{...dragProps}
|
||||
>
|
||||
{/* Show overlay when dragging files over the panel */}
|
||||
<DragOverlay isVisible={isDragOver} />
|
||||
|
||||
<div className="p-3 pb-2">
|
||||
{/* Button to add/upload media */}
|
||||
<div className="flex gap-2">
|
||||
{/* Search and filter controls */}
|
||||
<Select value={mediaFilter} onValueChange={setMediaFilter}>
|
||||
<SelectTrigger className="w-[80px] h-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="">
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="video">Video</SelectItem>
|
||||
<SelectItem value="audio">Audio</SelectItem>
|
||||
<SelectItem value="image">Image</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search media..."
|
||||
className="min-w-[60px] flex-1 h-full text-xs"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Add media button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFileSelect}
|
||||
disabled={isProcessing}
|
||||
className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Upload className="h-4 w-4 animate-spin" />
|
||||
<span className="hidden md:inline ml-2">{progress}%</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline ml-2" aria-label="Add file">
|
||||
Add
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 pt-0">
|
||||
{/* Show message if no media, otherwise show media grid */}
|
||||
{filteredMediaItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center h-full">
|
||||
<div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
|
||||
<Image className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No media in project
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Drag files here or use the button above
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, 160px)",
|
||||
}}
|
||||
>
|
||||
{/* Render each media item as a draggable button */}
|
||||
{filteredMediaItems.map((item) => (
|
||||
<ContextMenu key={item.id}>
|
||||
<ContextMenuTrigger>
|
||||
<DraggableMediaItem
|
||||
name={item.name}
|
||||
preview={renderPreview(item)}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
}}
|
||||
showPlusOnDrag={false}
|
||||
rounded={false}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Export clips</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={(e) => handleRemove(e, item.id)}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useDragDrop } from "@/hooks/use-drag-drop";
|
||||
import { processMediaFiles } from "@/lib/media-processing";
|
||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { Image, Music, Plus, Trash2, Upload, Video } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AspectRatio } from "../ui/aspect-ratio";
|
||||
import { Button } from "../ui/button";
|
||||
import { DragOverlay } from "../ui/drag-overlay";
|
||||
|
||||
// MediaPanel lets users add, view, and drag media (images, videos, audio) into the project.
|
||||
// You can upload files or drag them from your computer. Dragging from here to the timeline adds them to your video project.
|
||||
|
||||
export function MediaPanel() {
|
||||
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [mediaFilter, setMediaFilter] = useState("all");
|
||||
|
||||
const processFiles = async (files: FileList | File[]) => {
|
||||
if (!files || files.length === 0) return;
|
||||
setIsProcessing(true);
|
||||
setProgress(0);
|
||||
try {
|
||||
// Process files (extract metadata, generate thumbnails, etc.)
|
||||
const processedItems = await processMediaFiles(files, (p) =>
|
||||
setProgress(p)
|
||||
);
|
||||
// Add each processed media item to the store
|
||||
processedItems.forEach((item) => addMediaItem(item));
|
||||
} catch (error) {
|
||||
// Show error toast if processing fails
|
||||
console.error("Error processing files:", error);
|
||||
toast.error("Failed to process files");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const { isDragOver, dragProps } = useDragDrop({
|
||||
// When files are dropped, process them
|
||||
onDrop: processFiles,
|
||||
});
|
||||
|
||||
const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// When files are selected via file picker, process them
|
||||
if (e.target.files) processFiles(e.target.files);
|
||||
e.target.value = ""; // Reset input
|
||||
};
|
||||
|
||||
const handleRemove = (e: React.MouseEvent, id: string) => {
|
||||
// Remove a media item from the store
|
||||
e.stopPropagation();
|
||||
|
||||
|
||||
// Remove tracks automatically when delete media
|
||||
const { tracks, removeTrack } = useTimelineStore.getState();
|
||||
tracks.forEach((track) => {
|
||||
const clipsToRemove = track.clips.filter((clip) => clip.mediaId === id);
|
||||
clipsToRemove.forEach((clip) => {
|
||||
useTimelineStore.getState().removeClipFromTrack(track.id, clip.id);
|
||||
});
|
||||
// Only remove track if it becomes empty and has no other clips
|
||||
const updatedTrack = useTimelineStore.getState().tracks.find(t => t.id === track.id);
|
||||
if (updatedTrack && updatedTrack.clips.length === 0) {
|
||||
removeTrack(track.id);
|
||||
}
|
||||
});
|
||||
removeMediaItem(id);
|
||||
};
|
||||
|
||||
const formatDuration = (duration: number) => {
|
||||
// Format seconds as mm:ss
|
||||
const min = Math.floor(duration / 60);
|
||||
const sec = Math.floor(duration % 60);
|
||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const startDrag = (e: React.DragEvent, item: MediaItem) => {
|
||||
// When dragging a media item, set drag data for timeline to read
|
||||
e.dataTransfer.setData(
|
||||
"application/x-media-item",
|
||||
JSON.stringify({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
})
|
||||
);
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
};
|
||||
|
||||
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = mediaItems.filter((item) => {
|
||||
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
searchQuery &&
|
||||
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
setFilteredMediaItems(filtered);
|
||||
}, [mediaItems, mediaFilter, searchQuery]);
|
||||
|
||||
const renderPreview = (item: MediaItem) => {
|
||||
// Render a preview for each media type (image, video, audio, unknown)
|
||||
// Each preview is draggable to the timeline
|
||||
const baseDragProps = {
|
||||
draggable: true,
|
||||
onDragStart: (e: React.DragEvent) => startDrag(e, item),
|
||||
};
|
||||
|
||||
if (item.type === "image") {
|
||||
return (
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover rounded cursor-grab active:cursor-grabbing"
|
||||
loading="lazy"
|
||||
{...baseDragProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "video") {
|
||||
if (item.thumbnailUrl) {
|
||||
return (
|
||||
<div
|
||||
className="relative w-full h-full cursor-grab active:cursor-grabbing"
|
||||
{...baseDragProps}
|
||||
>
|
||||
<img
|
||||
src={item.thumbnailUrl}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover rounded"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
|
||||
<Video className="h-6 w-6 text-white drop-shadow-md" />
|
||||
</div>
|
||||
{item.duration && (
|
||||
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
|
||||
{formatDuration(item.duration)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded cursor-grab active:cursor-grabbing"
|
||||
{...baseDragProps}
|
||||
>
|
||||
<Video className="h-6 w-6 mb-1" />
|
||||
<span className="text-xs">Video</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "audio") {
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20 cursor-grab active:cursor-grabbing"
|
||||
{...baseDragProps}
|
||||
>
|
||||
<Music className="h-6 w-6 mb-1" />
|
||||
<span className="text-xs">Audio</span>
|
||||
{item.duration && (
|
||||
<span className="text-xs opacity-70">
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded cursor-grab active:cursor-grabbing"
|
||||
{...baseDragProps}
|
||||
>
|
||||
<Image className="h-6 w-6" />
|
||||
<span className="text-xs mt-1">Unknown</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hidden file input for uploading media */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*,audio/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`h-full flex flex-col transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
|
||||
{...dragProps}
|
||||
>
|
||||
{/* Show overlay when dragging files over the panel */}
|
||||
<DragOverlay isVisible={isDragOver} />
|
||||
|
||||
<div className="p-2 border-b">
|
||||
{/* Button to add/upload media */}
|
||||
<div className="flex gap-2">
|
||||
{/* Search and filter controls */}
|
||||
<select
|
||||
value={mediaFilter}
|
||||
onChange={(e) => setMediaFilter(e.target.value)}
|
||||
className="px-2 py-1 text-xs border rounded bg-background"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="audio">Audio</option>
|
||||
<option value="image">Image</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search media..."
|
||||
className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Add media button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFileSelect}
|
||||
disabled={isProcessing}
|
||||
className="flex-none min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Upload className="h-4 w-4 animate-spin" />
|
||||
<span className="hidden md:inline ml-2">{progress}%</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline ml-2" aria-label="Add file">
|
||||
Add
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{/* Show message if no media, otherwise show media grid */}
|
||||
{filteredMediaItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center h-full">
|
||||
<div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
|
||||
<Image className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No media in project
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Drag files here or use the button above
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* Render each media item as a draggable button */}
|
||||
{filteredMediaItems.map((item) => (
|
||||
<div key={item.id} className="relative group">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-col gap-2 p-2 h-auto w-full relative"
|
||||
>
|
||||
<AspectRatio ratio={item.aspectRatio}>
|
||||
{renderPreview(item)}
|
||||
</AspectRatio>
|
||||
<span
|
||||
className="text-xs truncate px-1 max-w-full"
|
||||
aria-label={item.name}
|
||||
title={item.name}
|
||||
>
|
||||
{item.name.length > 8
|
||||
? `${item.name.slice(0, 4)}...${item.name.slice(-3)}`
|
||||
: item.name}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{/* Show remove button on hover */}
|
||||
<div className="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => handleRemove(e, item.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { TabBar } from "./tabbar";
|
||||
import { MediaView } from "./views/media";
|
||||
import { useMediaPanelStore, Tab } from "./store";
|
||||
import { TextView } from "./views/text";
|
||||
|
||||
export function MediaPanel() {
|
||||
const { activeTab } = useMediaPanelStore();
|
||||
|
||||
const viewMap: Record<Tab, React.ReactNode> = {
|
||||
media: <MediaView />,
|
||||
audio: (
|
||||
<div className="p-4 text-muted-foreground">Audio view coming soon...</div>
|
||||
),
|
||||
text: <TextView />,
|
||||
stickers: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Stickers view coming soon...
|
||||
</div>
|
||||
),
|
||||
effects: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Effects view coming soon...
|
||||
</div>
|
||||
),
|
||||
transitions: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Transitions view coming soon...
|
||||
</div>
|
||||
),
|
||||
captions: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Captions view coming soon...
|
||||
</div>
|
||||
),
|
||||
filters: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Filters view coming soon...
|
||||
</div>
|
||||
),
|
||||
adjustment: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
Adjustment view coming soon...
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden">
|
||||
<TabBar />
|
||||
<div className="flex-1">{viewMap[activeTab]}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
import {
|
||||
CaptionsIcon,
|
||||
ArrowLeftRightIcon,
|
||||
SparklesIcon,
|
||||
StickerIcon,
|
||||
MusicIcon,
|
||||
VideoIcon,
|
||||
BlendIcon,
|
||||
SlidersHorizontalIcon,
|
||||
LucideIcon,
|
||||
TypeIcon,
|
||||
} from "lucide-react";
|
||||
import { create } from "zustand";
|
||||
|
||||
export type Tab =
|
||||
| "media"
|
||||
| "audio"
|
||||
| "text"
|
||||
| "stickers"
|
||||
| "effects"
|
||||
| "transitions"
|
||||
| "captions"
|
||||
| "filters"
|
||||
| "adjustment";
|
||||
|
||||
export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
|
||||
media: {
|
||||
icon: VideoIcon,
|
||||
label: "Media",
|
||||
},
|
||||
audio: {
|
||||
icon: MusicIcon,
|
||||
label: "Audio",
|
||||
},
|
||||
text: {
|
||||
icon: TypeIcon,
|
||||
label: "Text",
|
||||
},
|
||||
stickers: {
|
||||
icon: StickerIcon,
|
||||
label: "Stickers",
|
||||
},
|
||||
effects: {
|
||||
icon: SparklesIcon,
|
||||
label: "Effects",
|
||||
},
|
||||
transitions: {
|
||||
icon: ArrowLeftRightIcon,
|
||||
label: "Transitions",
|
||||
},
|
||||
captions: {
|
||||
icon: CaptionsIcon,
|
||||
label: "Captions",
|
||||
},
|
||||
filters: {
|
||||
icon: BlendIcon,
|
||||
label: "Filters",
|
||||
},
|
||||
adjustment: {
|
||||
icon: SlidersHorizontalIcon,
|
||||
label: "Adjustment",
|
||||
},
|
||||
};
|
||||
|
||||
interface MediaPanelStore {
|
||||
activeTab: Tab;
|
||||
setActiveTab: (tab: Tab) => void;
|
||||
}
|
||||
|
||||
export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
|
||||
activeTab: "media",
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
}));
|
@ -1,124 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tab, tabs, useMediaPanelStore } from "./store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronRight, ChevronLeft } from "lucide-react";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
|
||||
export function TabBar() {
|
||||
const { activeTab, setActiveTab } = useMediaPanelStore();
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isAtEnd, setIsAtEnd] = useState(false);
|
||||
const [isAtStart, setIsAtStart] = useState(true);
|
||||
|
||||
const scrollToEnd = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left: scrollContainerRef.current.scrollWidth,
|
||||
});
|
||||
setIsAtEnd(true);
|
||||
setIsAtStart(false);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToStart = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left: 0,
|
||||
});
|
||||
setIsAtStart(true);
|
||||
setIsAtEnd(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkScrollPosition = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } =
|
||||
scrollContainerRef.current;
|
||||
const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1;
|
||||
const isAtStartNow = scrollLeft <= 1;
|
||||
setIsAtEnd(isAtEndNow);
|
||||
setIsAtStart(isAtStartNow);
|
||||
}
|
||||
};
|
||||
|
||||
// We're using useEffect because we need to sync with external DOM scroll events
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
checkScrollPosition();
|
||||
container.addEventListener("scroll", checkScrollPosition);
|
||||
|
||||
const resizeObserver = new ResizeObserver(checkScrollPosition);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", checkScrollPosition);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<ScrollButton
|
||||
direction="left"
|
||||
onClick={scrollToStart}
|
||||
isVisible={!isAtStart}
|
||||
/>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative"
|
||||
>
|
||||
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
|
||||
const tab = tabs[tabKey];
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 items-center cursor-pointer",
|
||||
activeTab === tabKey ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
onClick={() => setActiveTab(tabKey)}
|
||||
key={tabKey}
|
||||
>
|
||||
<tab.icon className="!size-[1.1rem]" />
|
||||
<span className="text-[0.65rem]">{tab.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ScrollButton
|
||||
direction="right"
|
||||
onClick={scrollToEnd}
|
||||
isVisible={!isAtEnd}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollButton({
|
||||
direction,
|
||||
onClick,
|
||||
isVisible,
|
||||
}: {
|
||||
direction: "left" | "right";
|
||||
onClick: () => void;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
if (!isVisible) return null;
|
||||
|
||||
const Icon = direction === "left" ? ChevronLeft : ChevronRight;
|
||||
|
||||
return (
|
||||
<div className="bg-panel-accent w-12 h-full flex items-center justify-center">
|
||||
<Button
|
||||
size="icon"
|
||||
className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className="!size-4 text-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
|
||||
export function TextView() {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<DraggableMediaItem
|
||||
name="Default text"
|
||||
preview={
|
||||
<div className="flex items-center justify-center w-full h-full bg-accent rounded">
|
||||
<span className="text-xs select-none">Default text</span>
|
||||
</div>
|
||||
}
|
||||
dragData={{
|
||||
id: "default-text",
|
||||
type: "text",
|
||||
name: "Default text",
|
||||
content: "Default text",
|
||||
}}
|
||||
aspectRatio={1}
|
||||
showLabel={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,31 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import {
|
||||
useTimelineStore,
|
||||
type TimelineClip,
|
||||
type TimelineTrack,
|
||||
} from "@/stores/timeline-store";
|
||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
|
||||
import { VideoPlayer } from "@/components/ui/video-player";
|
||||
import { AudioPlayer } from "@/components/ui/audio-player";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Play, Pause, Expand } from "lucide-react";
|
||||
import { Play, Pause, Volume2, VolumeX, Plus } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { FONT_CLASS_MAP } from "@/lib/font-config";
|
||||
import { BackgroundSettings } from "../background-settings";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
|
||||
interface ActiveElement {
|
||||
element: TimelineElement;
|
||||
interface ActiveClip {
|
||||
clip: TimelineClip;
|
||||
track: TimelineTrack;
|
||||
mediaItem: MediaItem | null;
|
||||
}
|
||||
@ -33,15 +21,14 @@ interface ActiveElement {
|
||||
export function PreviewPanel() {
|
||||
const { tracks } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
const { canvasSize } = useEditorStore();
|
||||
const { currentTime, muted, toggleMute, volume } = usePlaybackStore();
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [previewDimensions, setPreviewDimensions] = useState({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
const { activeProject } = useProjectStore();
|
||||
|
||||
// Calculate optimal preview size that fits in container while maintaining aspect ratio
|
||||
useEffect(() => {
|
||||
@ -103,110 +90,73 @@ export function PreviewPanel() {
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [canvasSize.width, canvasSize.height]);
|
||||
|
||||
// Get active elements at current time
|
||||
const getActiveElements = (): ActiveElement[] => {
|
||||
const activeElements: ActiveElement[] = [];
|
||||
// Get active clips at current time
|
||||
const getActiveClips = (): ActiveClip[] => {
|
||||
const activeClips: ActiveClip[] = [];
|
||||
|
||||
tracks.forEach((track) => {
|
||||
track.elements.forEach((element) => {
|
||||
const elementStart = element.startTime;
|
||||
const elementEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
track.clips.forEach((clip) => {
|
||||
const clipStart = clip.startTime;
|
||||
const clipEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime >= elementStart && currentTime < elementEnd) {
|
||||
let mediaItem = null;
|
||||
if (currentTime >= clipStart && currentTime < clipEnd) {
|
||||
const mediaItem =
|
||||
clip.mediaId === "test"
|
||||
? null // Test clips don't have a real media item
|
||||
: mediaItems.find((item) => item.id === clip.mediaId) || null;
|
||||
|
||||
// Only get media item for media elements
|
||||
if (element.type === "media") {
|
||||
mediaItem =
|
||||
element.mediaId === "test"
|
||||
? null // Test elements don't have a real media item
|
||||
: mediaItems.find((item) => item.id === element.mediaId) ||
|
||||
null;
|
||||
}
|
||||
|
||||
activeElements.push({ element, track, mediaItem });
|
||||
activeClips.push({ clip, track, mediaItem });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return activeElements;
|
||||
return activeClips;
|
||||
};
|
||||
|
||||
const activeElements = getActiveElements();
|
||||
const activeClips = getActiveClips();
|
||||
|
||||
// Check if there are any elements in the timeline at all
|
||||
const hasAnyElements = tracks.some((track) => track.elements.length > 0);
|
||||
// Render a clip
|
||||
const renderClip = (clipData: ActiveClip, index: number) => {
|
||||
const { clip, mediaItem } = clipData;
|
||||
|
||||
// Get media elements for blur background (video/image only)
|
||||
const getBlurBackgroundElements = (): ActiveElement[] => {
|
||||
return activeElements.filter(
|
||||
({ element, mediaItem }) =>
|
||||
element.type === "media" &&
|
||||
mediaItem &&
|
||||
(mediaItem.type === "video" || mediaItem.type === "image") &&
|
||||
element.mediaId !== "test" // Exclude test elements
|
||||
);
|
||||
};
|
||||
|
||||
const blurBackgroundElements = getBlurBackgroundElements();
|
||||
|
||||
// Render blur background layer
|
||||
const renderBlurBackground = () => {
|
||||
if (
|
||||
!activeProject?.backgroundType ||
|
||||
activeProject.backgroundType !== "blur" ||
|
||||
blurBackgroundElements.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the first media element for background (could be enhanced to use primary/focused element)
|
||||
const backgroundElement = blurBackgroundElements[0];
|
||||
const { element, mediaItem } = backgroundElement;
|
||||
|
||||
if (!mediaItem) return null;
|
||||
|
||||
const blurIntensity = activeProject.blurIntensity || 8;
|
||||
|
||||
if (mediaItem.type === "video") {
|
||||
// Test clips
|
||||
if (!mediaItem || clip.mediaId === "test") {
|
||||
return (
|
||||
<div
|
||||
key={`blur-${element.id}`}
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{
|
||||
filter: `blur(${blurIntensity}px)`,
|
||||
transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts
|
||||
transformOrigin: "center",
|
||||
}}
|
||||
key={clip.id}
|
||||
className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">🎬</div>
|
||||
<p className="text-xs text-white">{clip.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Video clips
|
||||
if (mediaItem.type === "video") {
|
||||
return (
|
||||
<div key={clip.id} className="absolute inset-0">
|
||||
<VideoPlayer
|
||||
src={mediaItem.url!}
|
||||
src={mediaItem.url}
|
||||
poster={mediaItem.thumbnailUrl}
|
||||
clipStartTime={element.startTime}
|
||||
trimStart={element.trimStart}
|
||||
trimEnd={element.trimEnd}
|
||||
clipDuration={element.duration}
|
||||
className="w-full h-full object-cover"
|
||||
clipStartTime={clip.startTime}
|
||||
trimStart={clip.trimStart}
|
||||
trimEnd={clip.trimEnd}
|
||||
clipDuration={clip.duration}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Image clips
|
||||
if (mediaItem.type === "image") {
|
||||
return (
|
||||
<div
|
||||
key={`blur-${element.id}`}
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{
|
||||
filter: `blur(${blurIntensity}px)`,
|
||||
transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts
|
||||
transformOrigin: "center",
|
||||
}}
|
||||
>
|
||||
<div key={clip.id} className="absolute inset-0">
|
||||
<img
|
||||
src={mediaItem.url!}
|
||||
src={mediaItem.url}
|
||||
alt={mediaItem.name}
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
@ -215,283 +165,119 @@ export function PreviewPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Render an element
|
||||
const renderElement = (elementData: ActiveElement, index: number) => {
|
||||
const { element, mediaItem } = elementData;
|
||||
|
||||
// Text elements
|
||||
if (element.type === "text") {
|
||||
const fontClassName =
|
||||
FONT_CLASS_MAP[element.fontFamily as keyof typeof FONT_CLASS_MAP] || "";
|
||||
|
||||
const scaleRatio = previewDimensions.width / canvasSize.width;
|
||||
|
||||
// Audio clips (visual representation)
|
||||
if (mediaItem.type === "audio") {
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
left: `${50 + (element.x / canvasSize.width) * 100}%`,
|
||||
top: `${50 + (element.y / canvasSize.height) * 100}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`,
|
||||
opacity: element.opacity,
|
||||
zIndex: 100 + index, // Text elements on top
|
||||
}}
|
||||
key={clip.id}
|
||||
className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
className={fontClassName}
|
||||
style={{
|
||||
fontSize: `${element.fontSize}px`,
|
||||
color: element.color,
|
||||
backgroundColor: element.backgroundColor,
|
||||
textAlign: element.textAlign,
|
||||
fontWeight: element.fontWeight,
|
||||
fontStyle: element.fontStyle,
|
||||
textDecoration: element.textDecoration,
|
||||
padding: "4px 8px",
|
||||
borderRadius: "2px",
|
||||
whiteSpace: "nowrap",
|
||||
// Fallback for system fonts that don't have classes
|
||||
...(fontClassName === "" && { fontFamily: element.fontFamily }),
|
||||
}}
|
||||
>
|
||||
{element.content}
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">🎵</div>
|
||||
<p className="text-xs text-white">{mediaItem.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Media elements
|
||||
if (element.type === "media") {
|
||||
// Test elements
|
||||
if (!mediaItem || element.mediaId === "test") {
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">🎬</div>
|
||||
<p className="text-xs text-white">{element.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Video elements
|
||||
if (mediaItem.type === "video") {
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<VideoPlayer
|
||||
src={mediaItem.url!}
|
||||
poster={mediaItem.thumbnailUrl}
|
||||
clipStartTime={element.startTime}
|
||||
trimStart={element.trimStart}
|
||||
trimEnd={element.trimEnd}
|
||||
clipDuration={element.duration}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Image elements
|
||||
if (mediaItem.type === "image") {
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<img
|
||||
src={mediaItem.url!}
|
||||
alt={mediaItem.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Audio elements (no visual representation)
|
||||
if (mediaItem.type === "audio") {
|
||||
return (
|
||||
<div key={element.id} className="absolute inset-0">
|
||||
<AudioPlayer
|
||||
src={mediaItem.url!}
|
||||
clipStartTime={element.startTime}
|
||||
trimStart={element.trimStart}
|
||||
trimEnd={element.trimEnd}
|
||||
clipDuration={element.duration}
|
||||
trackMuted={elementData.track.muted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Canvas presets
|
||||
const canvasPresets = [
|
||||
{ name: "16:9 HD", width: 1920, height: 1080 },
|
||||
{ name: "16:9 4K", width: 3840, height: 2160 },
|
||||
{ name: "9:16 Mobile", width: 1080, height: 1920 },
|
||||
{ name: "1:1 Square", width: 1080, height: 1080 },
|
||||
{ name: "4:3 Standard", width: 1440, height: 1080 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm">
|
||||
<div className="h-full w-full flex flex-col min-h-0 min-w-0">
|
||||
{/* Controls */}
|
||||
<div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0">
|
||||
<span className="text-muted-foreground">Canvas:</span>
|
||||
<select
|
||||
value={`${canvasSize.width}x${canvasSize.height}`}
|
||||
onChange={(e) => {
|
||||
const preset = canvasPresets.find(
|
||||
(p) => `${p.width}x${p.height}` === e.target.value
|
||||
);
|
||||
if (preset)
|
||||
setCanvasSize({ width: preset.width, height: preset.height });
|
||||
}}
|
||||
className="bg-background border rounded px-2 py-1 text-xs"
|
||||
>
|
||||
{canvasPresets.map((preset) => (
|
||||
<option
|
||||
key={preset.name}
|
||||
value={`${preset.width}x${preset.height}`}
|
||||
>
|
||||
{preset.name} ({preset.width}×{preset.height})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleMute}
|
||||
className="ml-auto"
|
||||
>
|
||||
{muted || volume === 0 ? (
|
||||
<VolumeX className="h-3 w-3 mr-1" />
|
||||
) : (
|
||||
<Volume2 className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{muted || volume === 0 ? "Unmute" : "Mute"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Preview Area */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0"
|
||||
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4"
|
||||
>
|
||||
<div className="flex-1"></div>
|
||||
{hasAnyElements ? (
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="relative overflow-hidden rounded-sm border"
|
||||
style={{
|
||||
width: previewDimensions.width,
|
||||
height: previewDimensions.height,
|
||||
backgroundColor:
|
||||
activeProject?.backgroundType === "blur"
|
||||
? "transparent"
|
||||
: activeProject?.backgroundColor || "#000000",
|
||||
}}
|
||||
>
|
||||
{renderBlurBackground()}
|
||||
{activeElements.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
||||
No elements at current time
|
||||
</div>
|
||||
) : (
|
||||
activeElements.map((elementData, index) =>
|
||||
renderElement(elementData, index)
|
||||
)
|
||||
)}
|
||||
{/* Show message when blur is selected but no media available */}
|
||||
{activeProject?.backgroundType === "blur" &&
|
||||
blurBackgroundElements.length === 0 &&
|
||||
activeElements.length > 0 && (
|
||||
<div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded">
|
||||
Add a video or image to use blur background
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="relative overflow-hidden rounded-sm bg-black border"
|
||||
style={{
|
||||
width: previewDimensions.width,
|
||||
height: previewDimensions.height,
|
||||
}}
|
||||
>
|
||||
{activeClips.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
||||
{tracks.length === 0
|
||||
? "No media added to timeline"
|
||||
: "No clips at current time"}
|
||||
</div>
|
||||
) : (
|
||||
activeClips.map((clipData, index) => renderClip(clipData, index))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1"></div>
|
||||
|
||||
<PreviewToolbar hasAnyElements={hasAnyElements} />
|
||||
<PreviewToolbar />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
|
||||
const { isPlaying, toggle, currentTime } = usePlaybackStore();
|
||||
const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore();
|
||||
const { getTotalDuration } = useTimelineStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const {
|
||||
currentPreset,
|
||||
isOriginal,
|
||||
getOriginalAspectRatio,
|
||||
getDisplayName,
|
||||
canvasPresets,
|
||||
} = useAspectRatio();
|
||||
|
||||
const handlePresetSelect = (preset: { width: number; height: number }) => {
|
||||
setCanvasSize({ width: preset.width, height: preset.height });
|
||||
};
|
||||
|
||||
const handleOriginalSelect = () => {
|
||||
const aspectRatio = getOriginalAspectRatio();
|
||||
setCanvasSizeToOriginal(aspectRatio);
|
||||
};
|
||||
function PreviewToolbar() {
|
||||
const { isPlaying, toggle } = usePlaybackStore();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-toolbar
|
||||
className="flex items-end justify-between gap-2 p-1 pt-2 w-full"
|
||||
className="flex items-center justify-center gap-2 px-4 pt-2 bg-background-500 w-full"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[0.75rem] text-muted-foreground flex items-center gap-1",
|
||||
!hasAnyElements && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<span className="text-primary tabular-nums">
|
||||
{formatTimeCode(
|
||||
currentTime,
|
||||
"HH:MM:SS:FF",
|
||||
activeProject?.fps || 30
|
||||
)}
|
||||
</span>
|
||||
<span className="opacity-50">/</span>
|
||||
<span className="tabular-nums">
|
||||
{formatTimeCode(
|
||||
getTotalDuration(),
|
||||
"HH:MM:SS:FF",
|
||||
activeProject?.fps || 30
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={toggle}
|
||||
disabled={!hasAnyElements}
|
||||
className="h-auto p-0"
|
||||
>
|
||||
<Button variant="text" size="icon" onClick={toggle}>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-3 w-3" />
|
||||
) : (
|
||||
<Play className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<BackgroundSettings />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="!bg-panel-accent text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light"
|
||||
disabled={!hasAnyElements}
|
||||
>
|
||||
{getDisplayName()}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={handleOriginalSelect}
|
||||
className={cn("text-xs", isOriginal && "font-semibold")}
|
||||
>
|
||||
Original
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{canvasPresets.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset.name}
|
||||
onClick={() => handlePresetSelect(preset)}
|
||||
className={cn(
|
||||
"text-xs",
|
||||
currentPreset?.name === preset.name && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{preset.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="!size-4 text-muted-foreground"
|
||||
>
|
||||
<Expand className="!size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
110
apps/web/src/components/editor/project-name-editor.tsx
Normal file
110
apps/web/src/components/editor/project-name-editor.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Input } from "../ui/input";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { Edit2, Check, X } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface ProjectNameEditorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProjectNameEditor({ className }: ProjectNameEditorProps) {
|
||||
const { activeProject, updateProjectName } = useProjectStore();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProject) {
|
||||
setEditValue(activeProject.name);
|
||||
}
|
||||
}, [activeProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleStartEdit = () => {
|
||||
if (activeProject) {
|
||||
setEditValue(activeProject.name);
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (editValue.trim()) {
|
||||
updateProjectName(editValue.trim());
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (activeProject) {
|
||||
setEditValue(activeProject.name);
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeProject) {
|
||||
return <span className="text-sm text-muted-foreground">Loading...</span>;
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="h-7 text-sm px-3 py-1 min-w-[200px]"
|
||||
size={1}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
onClick={handleSave}
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={!editValue.trim()}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
onClick={handleCancel}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 group">
|
||||
<span className="text-sm font-medium">{activeProject.name}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
onClick={handleStartEdit}
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
218
apps/web/src/components/editor/properties-panel.tsx
Normal file
218
apps/web/src/components/editor/properties-panel.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { Slider } from "../ui/slider";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
import { Separator } from "../ui/separator";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
|
||||
import { useState } from "react";
|
||||
import { SpeedControl } from "./speed-control";
|
||||
import type { BackgroundType } from "@/types/editor";
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { tracks } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur");
|
||||
const [backgroundColor, setBackgroundColor] = useState("#000000");
|
||||
|
||||
// Get the first video clip for preview (simplified)
|
||||
const firstVideoClip = tracks
|
||||
.flatMap((track) => track.clips)
|
||||
.find((clip) => {
|
||||
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
|
||||
return mediaItem?.type === "video";
|
||||
});
|
||||
|
||||
const firstVideoItem = firstVideoClip
|
||||
? mediaItems.find((item) => item.id === firstVideoClip.mediaId)
|
||||
: null;
|
||||
|
||||
const firstImageClip = tracks
|
||||
.flatMap((track) => track.clips)
|
||||
.find((clip) => {
|
||||
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
|
||||
return mediaItem?.type === "image";
|
||||
});
|
||||
|
||||
const firstImageItem = firstImageClip
|
||||
? mediaItems.find((item) => item.id === firstImageClip.mediaId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-6 p-5">
|
||||
{/* Image Treatment - only show if an image is selected */}
|
||||
{firstImageItem && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Image Treatment</h3>
|
||||
<div className="space-y-4">
|
||||
{/* Preview */}
|
||||
<div className="space-y-2">
|
||||
<Label>Preview</Label>
|
||||
<div className="w-full aspect-video max-w-48">
|
||||
<ImageTimelineTreatment
|
||||
src={firstImageItem.url}
|
||||
alt={firstImageItem.name}
|
||||
targetAspectRatio={16 / 9}
|
||||
className="rounded-sm border"
|
||||
backgroundType={backgroundType}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background Type */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bg-type">Background Type</Label>
|
||||
<Select
|
||||
value={backgroundType}
|
||||
onValueChange={(value: BackgroundType) =>
|
||||
setBackgroundType(value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select background type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="blur">Blur</SelectItem>
|
||||
<SelectItem value="mirror">Mirror</SelectItem>
|
||||
<SelectItem value="color">Solid Color</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Background Color - only show for color type */}
|
||||
{backgroundType === "color" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bg-color">Background Color</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="bg-color"
|
||||
type="color"
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
className="w-16 h-10 p-1"
|
||||
/>
|
||||
<Input
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
placeholder="#000000"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Video Controls - only show if a video is selected */}
|
||||
{firstVideoItem && (
|
||||
<>
|
||||
<SpeedControl />
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Transform */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Transform</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="x">X Position</Label>
|
||||
<Input id="x" type="number" defaultValue="0" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="y">Y Position</Label>
|
||||
<Input id="y" type="number" defaultValue="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rotation">Rotation</Label>
|
||||
<Slider
|
||||
id="rotation"
|
||||
max={360}
|
||||
step={1}
|
||||
defaultValue={[0]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Effects */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Effects</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="opacity">Opacity</Label>
|
||||
<Slider
|
||||
id="opacity"
|
||||
max={100}
|
||||
step={1}
|
||||
defaultValue={[100]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="blur">Blur</Label>
|
||||
<Slider
|
||||
id="blur"
|
||||
max={20}
|
||||
step={0.5}
|
||||
defaultValue={[0]}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Timing */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Timing</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="duration">Duration (seconds)</Label>
|
||||
<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>
|
||||
);
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
import { MediaElement } from "@/types/timeline";
|
||||
|
||||
export function AudioProperties({ element }: { element: MediaElement }) {
|
||||
return <div className="space-y-4 p-5">Audio properties</div>;
|
||||
}
|
@ -1,109 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
|
||||
import { Label } from "../../ui/label";
|
||||
import { ScrollArea } from "../../ui/scroll-area";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { AudioProperties } from "./audio-properties";
|
||||
import { MediaProperties } from "./media-properties";
|
||||
import { TextProperties } from "./text-properties";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../ui/select";
|
||||
import { FPS_PRESETS } from "@/constants/timeline-constants";
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { activeProject, updateProjectFps } = useProjectStore();
|
||||
const { getDisplayName, canvasSize } = useAspectRatio();
|
||||
const { selectedElements, tracks } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
|
||||
const handleFpsChange = (value: string) => {
|
||||
const fps = parseFloat(value);
|
||||
if (!isNaN(fps) && fps > 0) {
|
||||
updateProjectFps(fps);
|
||||
}
|
||||
};
|
||||
|
||||
const emptyView = (
|
||||
<div className="space-y-4 p-5">
|
||||
{/* Media Properties */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<PropertyItem label="Name:" value={activeProject?.name || ""} />
|
||||
<PropertyItem label="Aspect ratio:" value={getDisplayName()} />
|
||||
<PropertyItem
|
||||
label="Resolution:"
|
||||
value={`${canvasSize.width} × ${canvasSize.height}`}
|
||||
/>
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-xs text-muted-foreground">Frame rate:</Label>
|
||||
<Select
|
||||
value={(activeProject?.fps || 30).toString()}
|
||||
onValueChange={handleFpsChange}
|
||||
>
|
||||
<SelectTrigger className="w-32 h-6 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FPS_PRESETS.map(({ value, label }) => (
|
||||
<SelectItem key={value} value={value} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full bg-panel rounded-sm">
|
||||
{selectedElements.length > 0
|
||||
? selectedElements.map(({ trackId, elementId }) => {
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
|
||||
if (element?.type === "text") {
|
||||
return (
|
||||
<div key={elementId}>
|
||||
<TextProperties element={element} trackId={trackId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (element?.type === "media") {
|
||||
const mediaItem = mediaItems.find(
|
||||
(item) => item.id === element.mediaId
|
||||
);
|
||||
|
||||
if (mediaItem?.type === "audio") {
|
||||
return <AudioProperties element={element} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={elementId}>
|
||||
<MediaProperties element={element} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
: emptyView}
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||
<span className="text-xs text-right">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
import { MediaElement } from "@/types/timeline";
|
||||
|
||||
export function MediaProperties({ element }: { element: MediaElement }) {
|
||||
return <div className="space-y-4 p-5">Media properties</div>;
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PropertyItemProps {
|
||||
direction?: "row" | "column";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PropertyItem({
|
||||
direction = "row",
|
||||
children,
|
||||
className,
|
||||
}: PropertyItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2",
|
||||
direction === "row"
|
||||
? "items-center justify-between gap-6"
|
||||
: "flex-col gap-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PropertyItemLabel({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <label className={cn("text-xs", className)}>{children}</label>;
|
||||
}
|
||||
|
||||
export function PropertyItemValue({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("flex-1", className)}>{children}</div>;
|
||||
}
|
@ -1,76 +0,0 @@
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FontPicker } from "@/components/ui/font-picker";
|
||||
import { FontFamily } from "@/constants/font-constants";
|
||||
import { TextElement } from "@/types/timeline";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
PropertyItem,
|
||||
PropertyItemLabel,
|
||||
PropertyItemValue,
|
||||
} from "./property-item";
|
||||
|
||||
export function TextProperties({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: TextElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const { updateTextElement } = useTimelineStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-5">
|
||||
<Textarea
|
||||
placeholder="Name"
|
||||
defaultValue={element.content}
|
||||
className="min-h-[4.5rem] resize-none bg-background/50"
|
||||
onChange={(e) =>
|
||||
updateTextElement(trackId, element.id, { content: e.target.value })
|
||||
}
|
||||
/>
|
||||
<PropertyItem direction="row">
|
||||
<PropertyItemLabel>Font</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<FontPicker
|
||||
defaultValue={element.fontFamily}
|
||||
onValueChange={(value: FontFamily) =>
|
||||
updateTextElement(trackId, element.id, { fontFamily: value })
|
||||
}
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Font size</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
defaultValue={[element.fontSize]}
|
||||
min={8}
|
||||
max={300}
|
||||
step={1}
|
||||
onValueChange={([value]) =>
|
||||
updateTextElement(trackId, element.id, { fontSize: value })
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={element.fontSize}
|
||||
onChange={(e) =>
|
||||
updateTextElement(trackId, element.id, {
|
||||
fontSize: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-12 !text-xs h-7 rounded-sm text-center
|
||||
[appearance:textfield]
|
||||
[&::-webkit-outer-spin-button]:appearance-none
|
||||
[&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface SelectionBoxProps {
|
||||
startPos: { x: number; y: number } | null;
|
||||
currentPos: { x: number; y: number } | null;
|
||||
containerRef: React.RefObject<HTMLElement>;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function SelectionBox({
|
||||
startPos,
|
||||
currentPos,
|
||||
containerRef,
|
||||
isActive,
|
||||
}: SelectionBoxProps) {
|
||||
const selectionBoxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !startPos || !currentPos || !containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// Calculate relative positions within the container
|
||||
const startX = startPos.x - containerRect.left;
|
||||
const startY = startPos.y - containerRect.top;
|
||||
const currentX = currentPos.x - containerRect.left;
|
||||
const currentY = currentPos.y - containerRect.top;
|
||||
|
||||
// Calculate the selection rectangle bounds
|
||||
const left = Math.min(startX, currentX);
|
||||
const top = Math.min(startY, currentY);
|
||||
const width = Math.abs(currentX - startX);
|
||||
const height = Math.abs(currentY - startY);
|
||||
|
||||
// Update the selection box position and size
|
||||
if (selectionBoxRef.current) {
|
||||
selectionBoxRef.current.style.left = `${left}px`;
|
||||
selectionBoxRef.current.style.top = `${top}px`;
|
||||
selectionBoxRef.current.style.width = `${width}px`;
|
||||
selectionBoxRef.current.style.height = `${height}px`;
|
||||
}
|
||||
}, [startPos, currentPos, isActive, containerRef]);
|
||||
|
||||
if (!isActive || !startPos || !currentPos) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={selectionBoxRef}
|
||||
className="absolute pointer-events-none z-50"
|
||||
style={{
|
||||
backgroundColor: "hsl(var(--foreground) / 0.1)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
380
apps/web/src/components/editor/timeline-clip.tsx
Normal file
380
apps/web/src/components/editor/timeline-clip.tsx
Normal file
@ -0,0 +1,380 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
MoreVertical,
|
||||
Scissors,
|
||||
Trash2,
|
||||
SplitSquareHorizontal,
|
||||
Music,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
} from "lucide-react";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import AudioWaveform from "./audio-waveform";
|
||||
import { toast } from "sonner";
|
||||
import { TimelineClipProps, ResizeState } from "@/types/timeline";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { isDragging } from "motion/react";
|
||||
|
||||
export function TimelineClip({
|
||||
clip,
|
||||
track,
|
||||
zoomLevel,
|
||||
isSelected,
|
||||
onContextMenu,
|
||||
onClipMouseDown,
|
||||
onClipClick,
|
||||
}: TimelineClipProps) {
|
||||
const { mediaItems } = useMediaStore();
|
||||
const {
|
||||
updateClipTrim,
|
||||
addClipToTrack,
|
||||
removeClipFromTrack,
|
||||
dragState,
|
||||
splitClip,
|
||||
splitAndKeepLeft,
|
||||
splitAndKeepRight,
|
||||
separateAudio,
|
||||
} = useTimelineStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
|
||||
const [resizing, setResizing] = useState<ResizeState | null>(null);
|
||||
const [clipMenuOpen, setClipMenuOpen] = useState(false);
|
||||
|
||||
const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
|
||||
const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
|
||||
|
||||
// Use real-time position during drag, otherwise use stored position
|
||||
const isBeingDragged = dragState.clipId === clip.id;
|
||||
const clipStartTime =
|
||||
isBeingDragged && dragState.isDragging
|
||||
? dragState.currentTime
|
||||
: clip.startTime;
|
||||
const clipLeft = clipStartTime * 50 * zoomLevel;
|
||||
|
||||
const getTrackColor = (type: string) => {
|
||||
switch (type) {
|
||||
case "video":
|
||||
return "bg-blue-500/20 border-blue-500/30";
|
||||
case "audio":
|
||||
return "bg-green-500/20 border-green-500/30";
|
||||
case "effects":
|
||||
return "bg-purple-500/20 border-purple-500/30";
|
||||
default:
|
||||
return "bg-gray-500/20 border-gray-500/30";
|
||||
}
|
||||
};
|
||||
|
||||
// Resize handles for trimming clips
|
||||
const handleResizeStart = (
|
||||
e: React.MouseEvent,
|
||||
clipId: string,
|
||||
side: "left" | "right"
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
setResizing({
|
||||
clipId,
|
||||
side,
|
||||
startX: e.clientX,
|
||||
initialTrimStart: clip.trimStart,
|
||||
initialTrimEnd: clip.trimEnd,
|
||||
});
|
||||
};
|
||||
|
||||
const updateTrimFromMouseMove = (e: { clientX: number }) => {
|
||||
if (!resizing) return;
|
||||
|
||||
const deltaX = e.clientX - resizing.startX;
|
||||
const deltaTime = deltaX / (50 * zoomLevel);
|
||||
|
||||
if (resizing.side === "left") {
|
||||
const newTrimStart = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
clip.duration - clip.trimEnd - 0.1,
|
||||
resizing.initialTrimStart + deltaTime
|
||||
)
|
||||
);
|
||||
updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd);
|
||||
} else {
|
||||
const newTrimEnd = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
clip.duration - clip.trimStart - 0.1,
|
||||
resizing.initialTrimEnd - deltaTime
|
||||
)
|
||||
);
|
||||
updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeMove = (e: React.MouseEvent) => {
|
||||
updateTrimFromMouseMove(e);
|
||||
};
|
||||
|
||||
const handleResizeEnd = () => {
|
||||
setResizing(null);
|
||||
};
|
||||
|
||||
const handleDeleteClip = () => {
|
||||
removeClipFromTrack(track.id, clip.id);
|
||||
setClipMenuOpen(false);
|
||||
toast.success("Clip deleted");
|
||||
};
|
||||
|
||||
const handleSplitClip = () => {
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within clip to split");
|
||||
return;
|
||||
}
|
||||
|
||||
const secondClipId = splitClip(track.id, clip.id, currentTime);
|
||||
if (secondClipId) {
|
||||
toast.success("Clip split successfully");
|
||||
} else {
|
||||
toast.error("Failed to split clip");
|
||||
}
|
||||
setClipMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleSplitAndKeepLeft = () => {
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within clip");
|
||||
return;
|
||||
}
|
||||
|
||||
splitAndKeepLeft(track.id, clip.id, currentTime);
|
||||
toast.success("Split and kept left portion");
|
||||
setClipMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleSplitAndKeepRight = () => {
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within clip");
|
||||
return;
|
||||
}
|
||||
|
||||
splitAndKeepRight(track.id, clip.id, currentTime);
|
||||
toast.success("Split and kept right portion");
|
||||
setClipMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleSeparateAudio = () => {
|
||||
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
|
||||
|
||||
if (!mediaItem || mediaItem.type !== "video") {
|
||||
toast.error("Audio separation only available for video clips");
|
||||
return;
|
||||
}
|
||||
|
||||
const audioClipId = separateAudio(track.id, clip.id);
|
||||
if (audioClipId) {
|
||||
toast.success("Audio separated to audio track");
|
||||
} else {
|
||||
toast.error("Failed to separate audio");
|
||||
}
|
||||
setClipMenuOpen(false);
|
||||
};
|
||||
|
||||
const canSplitAtPlayhead = () => {
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
return currentTime > effectiveStart && currentTime < effectiveEnd;
|
||||
};
|
||||
|
||||
const canSeparateAudio = () => {
|
||||
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
|
||||
return mediaItem?.type === "video" && track.type === "video";
|
||||
};
|
||||
|
||||
const renderClipContent = () => {
|
||||
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
|
||||
|
||||
if (!mediaItem) {
|
||||
return (
|
||||
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaItem.type === "image") {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
src={mediaItem.url}
|
||||
alt={mediaItem.name}
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center gap-2">
|
||||
<div className="w-8 h-8 flex-shrink-0">
|
||||
<img
|
||||
src={mediaItem.thumbnailUrl}
|
||||
alt={mediaItem.name}
|
||||
className="w-full h-full object-cover rounded-sm"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-foreground/80 truncate flex-1">
|
||||
{clip.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaItem.type === "audio") {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<AudioWaveform
|
||||
audioUrl={mediaItem.url}
|
||||
height={24}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
|
||||
);
|
||||
};
|
||||
|
||||
const handleClipMouseDown = (e: React.MouseEvent) => {
|
||||
if (onClipMouseDown) {
|
||||
onClipMouseDown(e, clip);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute top-0 h-full select-none transition-all duration-75 ${
|
||||
isBeingDragged ? "z-50" : "z-10"
|
||||
} ${isSelected ? "ring-2 ring-primary" : ""}`}
|
||||
style={{
|
||||
left: `${clipLeft}px`,
|
||||
width: `${clipWidth}px`,
|
||||
}}
|
||||
onMouseMove={resizing ? handleResizeMove : undefined}
|
||||
onMouseUp={resizing ? handleResizeEnd : undefined}
|
||||
onMouseLeave={resizing ? handleResizeEnd : undefined}
|
||||
>
|
||||
<div
|
||||
className={`relative h-full rounded border cursor-pointer overflow-hidden ${getTrackColor(
|
||||
track.type
|
||||
)} ${isSelected ? "ring-2 ring-primary ring-offset-1" : ""}`}
|
||||
onClick={(e) => onClipClick && onClipClick(e, clip)}
|
||||
onMouseDown={handleClipMouseDown}
|
||||
onContextMenu={(e) => onContextMenu && onContextMenu(e, clip.id)}
|
||||
>
|
||||
<div className="absolute inset-1 flex items-center p-1">
|
||||
{renderClipContent()}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart(e, clip.id, "left")}
|
||||
/>
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={(e) => handleResizeStart(e, clip.id, "right")}
|
||||
/>
|
||||
|
||||
<div className="absolute top-1 right-1">
|
||||
<DropdownMenu open={clipMenuOpen} onOpenChange={setClipMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80 hover:bg-background"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setClipMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{/* Split operations - only available when playhead is within clip */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger disabled={!canSplitAtPlayhead()}>
|
||||
<Scissors className="mr-2 h-4 w-4" />
|
||||
Split
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onClick={handleSplitClip}>
|
||||
<SplitSquareHorizontal className="mr-2 h-4 w-4" />
|
||||
Split at Playhead
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleSplitAndKeepLeft}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Split and Keep Left
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleSplitAndKeepRight}>
|
||||
<ChevronRight className="mr-2 h-4 w-4" />
|
||||
Split and Keep Right
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Audio separation - only available for video clips */}
|
||||
{canSeparateAudio() && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSeparateAudio}>
|
||||
<Music className="mr-2 h-4 w-4" />
|
||||
Separate Audio
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleDeleteClip}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Clip
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,405 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
MoreVertical,
|
||||
Scissors,
|
||||
Trash2,
|
||||
SplitSquareHorizontal,
|
||||
Music,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Type,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import AudioWaveform from "./audio-waveform";
|
||||
import { toast } from "sonner";
|
||||
import { TimelineElementProps, TrackType } from "@/types/timeline";
|
||||
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
|
||||
import {
|
||||
getTrackElementClasses,
|
||||
TIMELINE_CONSTANTS,
|
||||
} from "@/constants/timeline-constants";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "../ui/context-menu";
|
||||
|
||||
export function TimelineElement({
|
||||
element,
|
||||
track,
|
||||
zoomLevel,
|
||||
isSelected,
|
||||
onElementMouseDown,
|
||||
onElementClick,
|
||||
}: TimelineElementProps) {
|
||||
const { mediaItems } = useMediaStore();
|
||||
const {
|
||||
updateElementTrim,
|
||||
updateElementDuration,
|
||||
removeElementFromTrack,
|
||||
dragState,
|
||||
splitElement,
|
||||
splitAndKeepLeft,
|
||||
splitAndKeepRight,
|
||||
separateAudio,
|
||||
addElementToTrack,
|
||||
replaceElementMedia,
|
||||
} = useTimelineStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
|
||||
const [elementMenuOpen, setElementMenuOpen] = useState(false);
|
||||
|
||||
const {
|
||||
resizing,
|
||||
isResizing,
|
||||
handleResizeStart,
|
||||
handleResizeMove,
|
||||
handleResizeEnd,
|
||||
} = useTimelineElementResize({
|
||||
element,
|
||||
track,
|
||||
zoomLevel,
|
||||
onUpdateTrim: updateElementTrim,
|
||||
onUpdateDuration: updateElementDuration,
|
||||
});
|
||||
|
||||
const effectiveDuration =
|
||||
element.duration - element.trimStart - element.trimEnd;
|
||||
const elementWidth = Math.max(
|
||||
TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
|
||||
effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
|
||||
);
|
||||
|
||||
// Use real-time position during drag, otherwise use stored position
|
||||
const isBeingDragged = dragState.elementId === element.id;
|
||||
const elementStartTime =
|
||||
isBeingDragged && dragState.isDragging
|
||||
? dragState.currentTime
|
||||
: element.startTime;
|
||||
const elementLeft = elementStartTime * 50 * zoomLevel;
|
||||
|
||||
const handleDeleteElement = () => {
|
||||
removeElementFromTrack(track.id, element.id);
|
||||
setElementMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleSplitElement = () => {
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within element to split");
|
||||
return;
|
||||
}
|
||||
|
||||
const secondElementId = splitElement(track.id, element.id, currentTime);
|
||||
if (!secondElementId) {
|
||||
toast.error("Failed to split element");
|
||||
}
|
||||
setElementMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleSplitAndKeepLeft = () => {
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within element");
|
||||
return;
|
||||
}
|
||||
|
||||
splitAndKeepLeft(track.id, element.id, currentTime);
|
||||
setElementMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleSplitAndKeepRight = () => {
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within element");
|
||||
return;
|
||||
}
|
||||
|
||||
splitAndKeepRight(track.id, element.id, currentTime);
|
||||
setElementMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleSeparateAudio = () => {
|
||||
if (element.type !== "media") {
|
||||
toast.error("Audio separation only available for media elements");
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
|
||||
if (!mediaItem || mediaItem.type !== "video") {
|
||||
toast.error("Audio separation only available for video elements");
|
||||
return;
|
||||
}
|
||||
|
||||
const audioElementId = separateAudio(track.id, element.id);
|
||||
if (!audioElementId) {
|
||||
toast.error("Failed to separate audio");
|
||||
}
|
||||
setElementMenuOpen(false);
|
||||
};
|
||||
|
||||
const canSplitAtPlayhead = () => {
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
return currentTime > effectiveStart && currentTime < effectiveEnd;
|
||||
};
|
||||
|
||||
const canSeparateAudio = () => {
|
||||
if (element.type !== "media") return false;
|
||||
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
|
||||
return mediaItem?.type === "video" && track.type === "media";
|
||||
};
|
||||
|
||||
const handleElementSplitContext = () => {
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
|
||||
const secondElementId = splitElement(track.id, element.id, currentTime);
|
||||
if (!secondElementId) {
|
||||
toast.error("Failed to split element");
|
||||
}
|
||||
} else {
|
||||
toast.error("Playhead must be within element to split");
|
||||
}
|
||||
};
|
||||
|
||||
const handleElementDuplicateContext = () => {
|
||||
const { id, ...elementWithoutId } = element;
|
||||
addElementToTrack(track.id, {
|
||||
...elementWithoutId,
|
||||
name: element.name + " (copy)",
|
||||
startTime:
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd) +
|
||||
0.1,
|
||||
});
|
||||
};
|
||||
|
||||
const handleElementDeleteContext = () => {
|
||||
removeElementFromTrack(track.id, element.id);
|
||||
};
|
||||
|
||||
const handleReplaceClip = () => {
|
||||
if (element.type !== "media") {
|
||||
toast.error("Replace is only available for media clips");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a file input to select replacement media
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "video/*,audio/*,image/*";
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const success = await replaceElementMedia(track.id, element.id, file);
|
||||
if (success) {
|
||||
toast.success("Clip replaced successfully");
|
||||
} else {
|
||||
toast.error("Failed to replace clip");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to replace clip");
|
||||
console.log(
|
||||
JSON.stringify({ error: "Failed to replace clip", details: error })
|
||||
);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const renderElementContent = () => {
|
||||
if (element.type === "text") {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-start pl-2">
|
||||
<span className="text-xs text-foreground/80 truncate">
|
||||
{element.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render media element ->
|
||||
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
|
||||
if (!mediaItem) {
|
||||
return (
|
||||
<span className="text-xs text-foreground/80 truncate">
|
||||
{element.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaItem.type === "image") {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="bg-[#004D52] py-3 w-full h-full">
|
||||
<img
|
||||
src={mediaItem.url}
|
||||
alt={mediaItem.name}
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center gap-2">
|
||||
<div className="w-8 h-8 flex-shrink-0">
|
||||
<img
|
||||
src={mediaItem.thumbnailUrl}
|
||||
alt={mediaItem.name}
|
||||
className="w-full h-full object-cover rounded-sm"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-foreground/80 truncate flex-1">
|
||||
{element.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render audio element ->
|
||||
if (mediaItem.type === "audio") {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<AudioWaveform
|
||||
audioUrl={mediaItem.url || ""}
|
||||
height={24}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-xs text-foreground/80 truncate">
|
||||
{element.name}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const handleElementMouseDown = (e: React.MouseEvent) => {
|
||||
if (onElementMouseDown) {
|
||||
onElementMouseDown(e, element);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={`absolute top-0 h-full select-none timeline-element ${
|
||||
isBeingDragged ? "z-50" : "z-10"
|
||||
}`}
|
||||
style={{
|
||||
left: `${elementLeft}px`,
|
||||
width: `${elementWidth}px`,
|
||||
}}
|
||||
data-element-id={element.id}
|
||||
data-track-id={track.id}
|
||||
onMouseMove={resizing ? handleResizeMove : undefined}
|
||||
onMouseUp={resizing ? handleResizeEnd : undefined}
|
||||
onMouseLeave={resizing ? handleResizeEnd : undefined}
|
||||
>
|
||||
<div
|
||||
className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
|
||||
track.type
|
||||
)} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
|
||||
isBeingDragged ? "z-50" : "z-10"
|
||||
}`}
|
||||
onClick={(e) => onElementClick && onElementClick(e, element)}
|
||||
onMouseDown={handleElementMouseDown}
|
||||
onContextMenu={(e) =>
|
||||
onElementMouseDown && onElementMouseDown(e, element)
|
||||
}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center h-full">
|
||||
{renderElementContent()}
|
||||
</div>
|
||||
|
||||
{isSelected && (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
|
||||
onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
|
||||
/>
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
|
||||
onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={handleElementSplitContext}>
|
||||
<Scissors className="h-4 w-4 mr-2" />
|
||||
Split at playhead
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleElementDuplicateContext}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Duplicate {element.type === "text" ? "text" : "clip"}
|
||||
</ContextMenuItem>
|
||||
{element.type === "media" && (
|
||||
<ContextMenuItem onClick={handleReplaceClip}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Replace clip
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={handleElementDeleteContext}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete {element.type === "text" ? "text" : "clip"}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
@ -1,110 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import {
|
||||
TIMELINE_CONSTANTS,
|
||||
getTotalTracksHeight,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
|
||||
|
||||
interface TimelinePlayheadProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
zoomLevel: number;
|
||||
tracks: TimelineTrack[];
|
||||
seek: (time: number) => void;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
trackLabelsRef?: React.RefObject<HTMLDivElement>;
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
playheadRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function TimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
trackLabelsRef,
|
||||
timelineRef,
|
||||
playheadRef: externalPlayheadRef,
|
||||
}: TimelinePlayheadProps) {
|
||||
const internalPlayheadRef = useRef<HTMLDivElement>(null);
|
||||
const playheadRef = externalPlayheadRef || internalPlayheadRef;
|
||||
const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
});
|
||||
|
||||
// Use timeline container height minus a few pixels for breathing room
|
||||
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
|
||||
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
|
||||
|
||||
// Get dynamic track labels width, fallback to 0 if no tracks or no ref
|
||||
const trackLabelsWidth =
|
||||
tracks.length > 0 && trackLabelsRef?.current
|
||||
? trackLabelsRef.current.offsetWidth
|
||||
: 0;
|
||||
const leftPosition =
|
||||
trackLabelsWidth +
|
||||
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute pointer-events-auto z-[100]"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
height: `${totalHeight}px`,
|
||||
width: "2px", // Slightly wider for better click target
|
||||
}}
|
||||
onMouseDown={handlePlayheadMouseDown}
|
||||
>
|
||||
{/* The red line spanning full height */}
|
||||
<div className="absolute left-0 w-0.5 bg-foreground cursor-col-resize h-full" />
|
||||
|
||||
{/* Red dot indicator at the top (in ruler area) */}
|
||||
<div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-foreground rounded-full border-2 border-foreground shadow-sm" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Also export a hook for getting ruler handlers
|
||||
export function useTimelinePlayheadRuler({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
|
||||
const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
});
|
||||
|
||||
return { handleRulerMouseDown, isDraggingRuler };
|
||||
}
|
||||
|
||||
export { TimelinePlayhead as default };
|
@ -102,7 +102,7 @@ export function TimelineToolbar({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const trackId = addTrack("media");
|
||||
const trackId = addTrack("video");
|
||||
addClipToTrack(trackId, {
|
||||
mediaId: "test",
|
||||
name: "Test Clip",
|
||||
|
@ -1,962 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { toast } from "sonner";
|
||||
import { TimelineElement } from "./timeline-element";
|
||||
import {
|
||||
TimelineTrack,
|
||||
sortTracksByOrder,
|
||||
ensureMainTrack,
|
||||
getMainTrack,
|
||||
canElementGoOnTrack,
|
||||
} from "@/types/timeline";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import type {
|
||||
TimelineElement as TimelineElementType,
|
||||
DragData,
|
||||
} from "@/types/timeline";
|
||||
import {
|
||||
snapTimeToFrame,
|
||||
TIMELINE_CONSTANTS,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
|
||||
export function TimelineTrackContent({
|
||||
track,
|
||||
zoomLevel,
|
||||
}: {
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
}) {
|
||||
const { mediaItems } = useMediaStore();
|
||||
const {
|
||||
tracks,
|
||||
moveElementToTrack,
|
||||
updateElementStartTime,
|
||||
addElementToTrack,
|
||||
selectedElements,
|
||||
selectElement,
|
||||
dragState,
|
||||
startDrag: startDragAction,
|
||||
updateDragTime,
|
||||
endDrag: endDragAction,
|
||||
clearSelectedElements,
|
||||
insertTrackAt,
|
||||
} = useTimelineStore();
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const [isDropping, setIsDropping] = useState(false);
|
||||
const [dropPosition, setDropPosition] = useState<number | null>(null);
|
||||
const [wouldOverlap, setWouldOverlap] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const [mouseDownLocation, setMouseDownLocation] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
// Set up mouse event listeners for drag
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!timelineRef.current) return;
|
||||
|
||||
// On first mouse move during drag, ensure the element is selected
|
||||
if (dragState.elementId && dragState.trackId) {
|
||||
const isSelected = selectedElements.some(
|
||||
(c) =>
|
||||
c.trackId === dragState.trackId &&
|
||||
c.elementId === dragState.elementId
|
||||
);
|
||||
|
||||
if (!isSelected) {
|
||||
// Select this element (replacing other selections) since we're dragging it
|
||||
selectElement(dragState.trackId, dragState.elementId, false);
|
||||
}
|
||||
}
|
||||
|
||||
const timelineRect = timelineRef.current.getBoundingClientRect();
|
||||
const mouseX = e.clientX - timelineRect.left;
|
||||
const mouseTime = Math.max(
|
||||
0,
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
|
||||
);
|
||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||
// Use frame snapping if project has FPS, otherwise use decimal snapping
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
const snappedTime = snapTimeToFrame(adjustedTime, projectFps);
|
||||
|
||||
updateDragTime(snappedTime);
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
if (!dragState.elementId || !dragState.trackId) return;
|
||||
|
||||
// If this track initiated the drag, we should handle the mouse up regardless of where it occurs
|
||||
const isTrackThatStartedDrag = dragState.trackId === track.id;
|
||||
|
||||
const timelineRect = timelineRef.current?.getBoundingClientRect();
|
||||
if (!timelineRect) {
|
||||
if (isTrackThatStartedDrag) {
|
||||
updateElementStartTime(
|
||||
track.id,
|
||||
dragState.elementId,
|
||||
dragState.currentTime
|
||||
);
|
||||
endDragAction();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isMouseOverThisTrack =
|
||||
e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom;
|
||||
|
||||
if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return;
|
||||
|
||||
const finalTime = dragState.currentTime;
|
||||
|
||||
if (isMouseOverThisTrack) {
|
||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||
const movingElement = sourceTrack?.elements.find(
|
||||
(c) => c.id === dragState.elementId
|
||||
);
|
||||
|
||||
if (movingElement) {
|
||||
const movingElementDuration =
|
||||
movingElement.duration -
|
||||
movingElement.trimStart -
|
||||
movingElement.trimEnd;
|
||||
const movingElementEnd = finalTime + movingElementDuration;
|
||||
|
||||
const targetTrack = tracks.find((t) => t.id === track.id);
|
||||
const hasOverlap = targetTrack?.elements.some((existingElement) => {
|
||||
if (
|
||||
dragState.trackId === track.id &&
|
||||
existingElement.id === dragState.elementId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
return finalTime < existingEnd && movingElementEnd > existingStart;
|
||||
});
|
||||
|
||||
if (!hasOverlap) {
|
||||
if (dragState.trackId === track.id) {
|
||||
updateElementStartTime(track.id, dragState.elementId, finalTime);
|
||||
} else {
|
||||
moveElementToTrack(
|
||||
dragState.trackId,
|
||||
track.id,
|
||||
dragState.elementId
|
||||
);
|
||||
requestAnimationFrame(() => {
|
||||
updateElementStartTime(
|
||||
track.id,
|
||||
dragState.elementId!,
|
||||
finalTime
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isTrackThatStartedDrag) {
|
||||
// Mouse is not over this track, but this track started the drag
|
||||
// This means user released over ruler/outside - update position within same track
|
||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||
const movingElement = sourceTrack?.elements.find(
|
||||
(c) => c.id === dragState.elementId
|
||||
);
|
||||
|
||||
if (movingElement) {
|
||||
const movingElementDuration =
|
||||
movingElement.duration -
|
||||
movingElement.trimStart -
|
||||
movingElement.trimEnd;
|
||||
const movingElementEnd = finalTime + movingElementDuration;
|
||||
|
||||
const hasOverlap = track.elements.some((existingElement) => {
|
||||
if (existingElement.id === dragState.elementId) {
|
||||
return false;
|
||||
}
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
return finalTime < existingEnd && movingElementEnd > existingStart;
|
||||
});
|
||||
|
||||
if (!hasOverlap) {
|
||||
updateElementStartTime(track.id, dragState.elementId, finalTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isTrackThatStartedDrag) {
|
||||
endDragAction();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.clickOffsetTime,
|
||||
dragState.elementId,
|
||||
dragState.trackId,
|
||||
dragState.currentTime,
|
||||
zoomLevel,
|
||||
tracks,
|
||||
track.id,
|
||||
updateDragTime,
|
||||
updateElementStartTime,
|
||||
moveElementToTrack,
|
||||
endDragAction,
|
||||
selectedElements,
|
||||
selectElement,
|
||||
]);
|
||||
|
||||
const handleElementMouseDown = (
|
||||
e: React.MouseEvent,
|
||||
element: TimelineElementType
|
||||
) => {
|
||||
setMouseDownLocation({ x: e.clientX, y: e.clientY });
|
||||
|
||||
// Detect right-click (button 2) and handle selection without starting drag
|
||||
const isRightClick = e.button === 2;
|
||||
const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
|
||||
|
||||
if (isRightClick) {
|
||||
// Handle right-click selection
|
||||
const isSelected = selectedElements.some(
|
||||
(c) => c.trackId === track.id && c.elementId === element.id
|
||||
);
|
||||
|
||||
// If element is not selected, select it (keep other selections if multi-select)
|
||||
if (!isSelected) {
|
||||
selectElement(track.id, element.id, isMultiSelect);
|
||||
}
|
||||
// If element is already selected, keep it selected
|
||||
|
||||
// Don't start drag action for right-clicks
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle multi-selection for left-click with modifiers
|
||||
if (isMultiSelect) {
|
||||
selectElement(track.id, element.id, true);
|
||||
}
|
||||
|
||||
// Calculate the offset from the left edge of the element to where the user clicked
|
||||
const elementElement = e.currentTarget as HTMLElement;
|
||||
const elementRect = elementElement.getBoundingClientRect();
|
||||
const clickOffsetX = e.clientX - elementRect.left;
|
||||
const clickOffsetTime =
|
||||
clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
|
||||
startDragAction(
|
||||
element.id,
|
||||
track.id,
|
||||
e.clientX,
|
||||
element.startTime,
|
||||
clickOffsetTime
|
||||
);
|
||||
};
|
||||
|
||||
const handleElementClick = (
|
||||
e: React.MouseEvent,
|
||||
element: TimelineElementType
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Check if mouse moved significantly
|
||||
if (mouseDownLocation) {
|
||||
const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
|
||||
const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
|
||||
// If it moved more than a few pixels, consider it a drag and not a click.
|
||||
if (deltaX > 5 || deltaY > 5) {
|
||||
setMouseDownLocation(null); // Reset for next interaction
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip selection logic for multi-selection (handled in mousedown)
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle single selection
|
||||
const isSelected = selectedElements.some(
|
||||
(c) => c.trackId === track.id && c.elementId === element.id
|
||||
);
|
||||
|
||||
if (!isSelected) {
|
||||
// If element is not selected, select it (replacing other selections)
|
||||
selectElement(track.id, element.id, false);
|
||||
}
|
||||
// If element is already selected, keep it selected (do nothing)
|
||||
};
|
||||
|
||||
const handleTrackDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Handle both timeline elements and media items
|
||||
const hasTimelineElement = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-element"
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item"
|
||||
);
|
||||
|
||||
if (!hasTimelineElement && !hasMediaItem) return;
|
||||
|
||||
// Calculate drop position for overlap checking
|
||||
const trackContainer = e.currentTarget.querySelector(
|
||||
".track-elements-container"
|
||||
) as HTMLElement;
|
||||
let dropTime = 0;
|
||||
if (trackContainer) {
|
||||
const rect = trackContainer.getBoundingClientRect();
|
||||
const mouseX = Math.max(0, e.clientX - rect.left);
|
||||
dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
}
|
||||
|
||||
// Check for potential overlaps and show appropriate feedback
|
||||
let wouldOverlap = false;
|
||||
|
||||
if (hasMediaItem) {
|
||||
try {
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item"
|
||||
);
|
||||
if (mediaItemData) {
|
||||
const dragData: DragData = JSON.parse(mediaItemData);
|
||||
|
||||
if (dragData.type === "text") {
|
||||
// Text elements have default duration of 5 seconds
|
||||
const newElementDuration = 5;
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
const snappedTime = snapTimeToFrame(dropTime, projectFps);
|
||||
const newElementEnd = snappedTime + newElementDuration;
|
||||
|
||||
wouldOverlap = track.elements.some((existingElement) => {
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
return snappedTime < existingEnd && newElementEnd > existingStart;
|
||||
});
|
||||
} else {
|
||||
// Media elements
|
||||
const mediaItem = mediaItems.find(
|
||||
(item) => item.id === dragData.id
|
||||
);
|
||||
if (mediaItem) {
|
||||
const newElementDuration = mediaItem.duration || 5;
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
const snappedTime = snapTimeToFrame(dropTime, projectFps);
|
||||
const newElementEnd = snappedTime + newElementDuration;
|
||||
|
||||
wouldOverlap = track.elements.some((existingElement) => {
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
return (
|
||||
snappedTime < existingEnd && newElementEnd > existingStart
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue with default behavior
|
||||
}
|
||||
} else if (hasTimelineElement) {
|
||||
try {
|
||||
const timelineElementData = e.dataTransfer.getData(
|
||||
"application/x-timeline-element"
|
||||
);
|
||||
if (timelineElementData) {
|
||||
const { elementId, trackId: fromTrackId } =
|
||||
JSON.parse(timelineElementData);
|
||||
const sourceTrack = tracks.find(
|
||||
(t: TimelineTrack) => t.id === fromTrackId
|
||||
);
|
||||
const movingElement = sourceTrack?.elements.find(
|
||||
(c: any) => c.id === elementId
|
||||
);
|
||||
|
||||
if (movingElement) {
|
||||
const movingElementDuration =
|
||||
movingElement.duration -
|
||||
movingElement.trimStart -
|
||||
movingElement.trimEnd;
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
const snappedTime = snapTimeToFrame(dropTime, projectFps);
|
||||
const movingElementEnd = snappedTime + movingElementDuration;
|
||||
|
||||
wouldOverlap = track.elements.some((existingElement) => {
|
||||
if (fromTrackId === track.id && existingElement.id === elementId)
|
||||
return false;
|
||||
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
return (
|
||||
snappedTime < existingEnd && movingElementEnd > existingStart
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue with default behavior
|
||||
}
|
||||
}
|
||||
|
||||
if (wouldOverlap) {
|
||||
e.dataTransfer.dropEffect = "none";
|
||||
setWouldOverlap(true);
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
setDropPosition(snapTimeToFrame(dropTime, projectFps));
|
||||
return;
|
||||
}
|
||||
|
||||
e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
|
||||
setWouldOverlap(false);
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
setDropPosition(snapTimeToFrame(dropTime, projectFps));
|
||||
};
|
||||
|
||||
const handleTrackDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const hasTimelineElement = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-element"
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item"
|
||||
);
|
||||
|
||||
if (!hasTimelineElement && !hasMediaItem) return;
|
||||
|
||||
dragCounterRef.current++;
|
||||
setIsDropping(true);
|
||||
};
|
||||
|
||||
const handleTrackDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const hasTimelineElement = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-element"
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item"
|
||||
);
|
||||
|
||||
if (!hasTimelineElement && !hasMediaItem) return;
|
||||
|
||||
dragCounterRef.current--;
|
||||
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDropping(false);
|
||||
setWouldOverlap(false);
|
||||
setDropPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrackDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Reset all drag states
|
||||
dragCounterRef.current = 0;
|
||||
setIsDropping(false);
|
||||
setWouldOverlap(false);
|
||||
|
||||
const hasTimelineElement = e.dataTransfer.types.includes(
|
||||
"application/x-timeline-element"
|
||||
);
|
||||
const hasMediaItem = e.dataTransfer.types.includes(
|
||||
"application/x-media-item"
|
||||
);
|
||||
|
||||
if (!hasTimelineElement && !hasMediaItem) return;
|
||||
|
||||
const trackContainer = e.currentTarget.querySelector(
|
||||
".track-elements-container"
|
||||
) as HTMLElement;
|
||||
if (!trackContainer) return;
|
||||
|
||||
const rect = trackContainer.getBoundingClientRect();
|
||||
const mouseX = Math.max(0, e.clientX - rect.left);
|
||||
const mouseY = e.clientY - rect.top; // Get Y position relative to this track
|
||||
const newStartTime =
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
const snappedTime = snapTimeToFrame(newStartTime, projectFps);
|
||||
|
||||
// Calculate drop position relative to tracks
|
||||
const currentTrackIndex = tracks.findIndex((t) => t.id === track.id);
|
||||
|
||||
// Determine drop zone within the track (top 20px, middle 20px, bottom 20px)
|
||||
let dropPosition: "above" | "on" | "below";
|
||||
if (mouseY < 20) {
|
||||
dropPosition = "above";
|
||||
} else if (mouseY > 40) {
|
||||
dropPosition = "below";
|
||||
} else {
|
||||
dropPosition = "on";
|
||||
}
|
||||
|
||||
try {
|
||||
if (hasTimelineElement) {
|
||||
// Handle timeline element movement
|
||||
const timelineElementData = e.dataTransfer.getData(
|
||||
"application/x-timeline-element"
|
||||
);
|
||||
if (!timelineElementData) return;
|
||||
|
||||
const {
|
||||
elementId,
|
||||
trackId: fromTrackId,
|
||||
clickOffsetTime = 0,
|
||||
} = JSON.parse(timelineElementData);
|
||||
|
||||
// Find the element being moved
|
||||
const sourceTrack = tracks.find(
|
||||
(t: TimelineTrack) => t.id === fromTrackId
|
||||
);
|
||||
const movingElement = sourceTrack?.elements.find(
|
||||
(c: TimelineElementType) => c.id === elementId
|
||||
);
|
||||
|
||||
if (!movingElement) {
|
||||
toast.error("Element not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Adjust position based on where user clicked on the element
|
||||
const adjustedStartTime = snappedTime - clickOffsetTime;
|
||||
const finalStartTime = Math.max(
|
||||
0,
|
||||
snapTimeToFrame(adjustedStartTime, projectFps)
|
||||
);
|
||||
|
||||
// Check for overlaps with existing elements (excluding the moving element itself)
|
||||
const movingElementDuration =
|
||||
movingElement.duration -
|
||||
movingElement.trimStart -
|
||||
movingElement.trimEnd;
|
||||
const movingElementEnd = finalStartTime + movingElementDuration;
|
||||
|
||||
const hasOverlap = track.elements.some((existingElement) => {
|
||||
// Skip the element being moved if it's on the same track
|
||||
if (fromTrackId === track.id && existingElement.id === elementId)
|
||||
return false;
|
||||
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
|
||||
// Check if elements overlap
|
||||
return (
|
||||
finalStartTime < existingEnd && movingElementEnd > existingStart
|
||||
);
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
toast.error(
|
||||
"Cannot move element here - it would overlap with existing elements"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromTrackId === track.id) {
|
||||
// Moving within same track
|
||||
updateElementStartTime(track.id, elementId, finalStartTime);
|
||||
} else {
|
||||
// Moving to different track
|
||||
moveElementToTrack(fromTrackId, track.id, elementId);
|
||||
requestAnimationFrame(() => {
|
||||
updateElementStartTime(track.id, elementId, finalStartTime);
|
||||
});
|
||||
}
|
||||
} else if (hasMediaItem) {
|
||||
// Handle media item drop
|
||||
const mediaItemData = e.dataTransfer.getData(
|
||||
"application/x-media-item"
|
||||
);
|
||||
if (!mediaItemData) return;
|
||||
|
||||
const dragData: DragData = JSON.parse(mediaItemData);
|
||||
|
||||
if (dragData.type === "text") {
|
||||
let targetTrackId = track.id;
|
||||
let targetTrack = track;
|
||||
|
||||
// Handle position-aware track creation for text
|
||||
if (track.type !== "text" || dropPosition !== "on") {
|
||||
// Text tracks should go above the main track
|
||||
const mainTrack = getMainTrack(tracks);
|
||||
let insertIndex: number;
|
||||
|
||||
if (dropPosition === "above") {
|
||||
insertIndex = currentTrackIndex;
|
||||
} else if (dropPosition === "below") {
|
||||
insertIndex = currentTrackIndex + 1;
|
||||
} else {
|
||||
// dropPosition === "on" but track is not text type
|
||||
// Insert above main track if main track exists, otherwise at top
|
||||
if (mainTrack) {
|
||||
const mainTrackIndex = tracks.findIndex(
|
||||
(t) => t.id === mainTrack.id
|
||||
);
|
||||
insertIndex = mainTrackIndex;
|
||||
} else {
|
||||
insertIndex = 0; // Top of timeline
|
||||
}
|
||||
}
|
||||
|
||||
targetTrackId = insertTrackAt("text", insertIndex);
|
||||
// Get the updated tracks array after creating the new track
|
||||
const updatedTracks = useTimelineStore.getState().tracks;
|
||||
const newTargetTrack = updatedTracks.find(
|
||||
(t) => t.id === targetTrackId
|
||||
);
|
||||
if (!newTargetTrack) return;
|
||||
targetTrack = newTargetTrack;
|
||||
}
|
||||
|
||||
// Check for overlaps with existing elements in target track
|
||||
const newElementDuration = 5; // Default text duration
|
||||
const newElementEnd = snappedTime + newElementDuration;
|
||||
|
||||
const hasOverlap = targetTrack.elements.some((existingElement) => {
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
|
||||
// Check if elements overlap
|
||||
return snappedTime < existingEnd && newElementEnd > existingStart;
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
toast.error(
|
||||
"Cannot place element here - it would overlap with existing elements"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
addElementToTrack(targetTrackId, {
|
||||
type: "text",
|
||||
name: dragData.name || "Text",
|
||||
content: dragData.content || "Default Text",
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||
startTime: snappedTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
fontSize: 48,
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff",
|
||||
backgroundColor: "transparent",
|
||||
textAlign: "center",
|
||||
fontWeight: "normal",
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
} else {
|
||||
// Handle media items
|
||||
const mediaItem = mediaItems.find((item) => item.id === dragData.id);
|
||||
|
||||
if (!mediaItem) {
|
||||
toast.error("Media item not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let targetTrackId = track.id;
|
||||
|
||||
// Check if track type is compatible
|
||||
const isVideoOrImage =
|
||||
dragData.type === "video" || dragData.type === "image";
|
||||
const isAudio = dragData.type === "audio";
|
||||
const isCompatible = isVideoOrImage
|
||||
? canElementGoOnTrack("media", track.type)
|
||||
: isAudio
|
||||
? canElementGoOnTrack("media", track.type)
|
||||
: false;
|
||||
|
||||
let targetTrack = tracks.find((t) => t.id === targetTrackId);
|
||||
|
||||
// Handle position-aware track creation for media elements
|
||||
if (!isCompatible || dropPosition !== "on") {
|
||||
const needsNewTrack = !isCompatible || dropPosition !== "on";
|
||||
|
||||
if (needsNewTrack) {
|
||||
if (isVideoOrImage) {
|
||||
// For video/image, check if we need a main track or additional media track
|
||||
const mainTrack = getMainTrack(tracks);
|
||||
|
||||
if (!mainTrack) {
|
||||
// No main track exists, create it
|
||||
const updatedTracks = ensureMainTrack(tracks);
|
||||
const newMainTrack = getMainTrack(updatedTracks);
|
||||
if (newMainTrack && newMainTrack.elements.length === 0) {
|
||||
targetTrackId = newMainTrack.id;
|
||||
targetTrack = newMainTrack;
|
||||
} else {
|
||||
// Main track was created but somehow has elements, create new media track
|
||||
const mainTrackIndex = updatedTracks.findIndex(
|
||||
(t) => t.id === newMainTrack?.id
|
||||
);
|
||||
targetTrackId = insertTrackAt("media", mainTrackIndex);
|
||||
const updatedTracksAfterInsert =
|
||||
useTimelineStore.getState().tracks;
|
||||
const newTargetTrack = updatedTracksAfterInsert.find(
|
||||
(t) => t.id === targetTrackId
|
||||
);
|
||||
if (!newTargetTrack) return;
|
||||
targetTrack = newTargetTrack;
|
||||
}
|
||||
} else if (
|
||||
mainTrack.elements.length === 0 &&
|
||||
dropPosition === "on"
|
||||
) {
|
||||
// Main track exists and is empty, use it
|
||||
targetTrackId = mainTrack.id;
|
||||
targetTrack = mainTrack;
|
||||
} else {
|
||||
// Create new media track above main track
|
||||
const mainTrackIndex = tracks.findIndex(
|
||||
(t) => t.id === mainTrack.id
|
||||
);
|
||||
let insertIndex: number;
|
||||
|
||||
if (dropPosition === "above") {
|
||||
insertIndex = currentTrackIndex;
|
||||
} else if (dropPosition === "below") {
|
||||
insertIndex = currentTrackIndex + 1;
|
||||
} else {
|
||||
// Insert above main track
|
||||
insertIndex = mainTrackIndex;
|
||||
}
|
||||
|
||||
targetTrackId = insertTrackAt("media", insertIndex);
|
||||
const updatedTracks = useTimelineStore.getState().tracks;
|
||||
const newTargetTrack = updatedTracks.find(
|
||||
(t) => t.id === targetTrackId
|
||||
);
|
||||
if (!newTargetTrack) return;
|
||||
targetTrack = newTargetTrack;
|
||||
}
|
||||
} else if (isAudio) {
|
||||
// Audio tracks go at the bottom
|
||||
const mainTrack = getMainTrack(tracks);
|
||||
let insertIndex: number;
|
||||
|
||||
if (dropPosition === "above") {
|
||||
insertIndex = currentTrackIndex;
|
||||
} else if (dropPosition === "below") {
|
||||
insertIndex = currentTrackIndex + 1;
|
||||
} else {
|
||||
// Insert after main track (bottom area)
|
||||
if (mainTrack) {
|
||||
const mainTrackIndex = tracks.findIndex(
|
||||
(t) => t.id === mainTrack.id
|
||||
);
|
||||
insertIndex = mainTrackIndex + 1;
|
||||
} else {
|
||||
insertIndex = tracks.length; // Bottom of timeline
|
||||
}
|
||||
}
|
||||
|
||||
targetTrackId = insertTrackAt("audio", insertIndex);
|
||||
const updatedTracks = useTimelineStore.getState().tracks;
|
||||
const newTargetTrack = updatedTracks.find(
|
||||
(t) => t.id === targetTrackId
|
||||
);
|
||||
if (!newTargetTrack) return;
|
||||
targetTrack = newTargetTrack;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetTrack) return;
|
||||
|
||||
// Check for overlaps with existing elements in target track
|
||||
const newElementDuration = mediaItem.duration || 5;
|
||||
const newElementEnd = snappedTime + newElementDuration;
|
||||
|
||||
const hasOverlap = targetTrack.elements.some((existingElement) => {
|
||||
const existingStart = existingElement.startTime;
|
||||
const existingEnd =
|
||||
existingElement.startTime +
|
||||
(existingElement.duration -
|
||||
existingElement.trimStart -
|
||||
existingElement.trimEnd);
|
||||
|
||||
// Check if elements overlap
|
||||
return snappedTime < existingEnd && newElementEnd > existingStart;
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
toast.error(
|
||||
"Cannot place element here - it would overlap with existing elements"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
addElementToTrack(targetTrackId, {
|
||||
type: "media",
|
||||
mediaId: mediaItem.id,
|
||||
name: mediaItem.name,
|
||||
duration: mediaItem.duration || 5,
|
||||
startTime: snappedTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling drop:", error);
|
||||
toast.error("Failed to add media to track");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full hover:bg-muted/20"
|
||||
onClick={(e) => {
|
||||
// If clicking empty area (not on an element), deselect all elements
|
||||
if (!(e.target as HTMLElement).closest(".timeline-element")) {
|
||||
clearSelectedElements();
|
||||
}
|
||||
}}
|
||||
onDragOver={handleTrackDragOver}
|
||||
onDragEnter={handleTrackDragEnter}
|
||||
onDragLeave={handleTrackDragLeave}
|
||||
onDrop={handleTrackDrop}
|
||||
>
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className="h-full relative track-elements-container min-w-full"
|
||||
>
|
||||
{track.elements.length === 0 ? (
|
||||
<div
|
||||
className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
|
||||
isDropping
|
||||
? wouldOverlap
|
||||
? "border-red-500 bg-red-500/10 text-red-600"
|
||||
: "border-blue-500 bg-blue-500/10 text-blue-600"
|
||||
: "border-muted/30"
|
||||
}`}
|
||||
>
|
||||
{isDropping
|
||||
? wouldOverlap
|
||||
? "Cannot drop - would overlap"
|
||||
: "Drop element here"
|
||||
: ""}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{track.elements.map((element) => {
|
||||
const isSelected = selectedElements.some(
|
||||
(c) => c.trackId === track.id && c.elementId === element.id
|
||||
);
|
||||
|
||||
const handleElementSplit = () => {
|
||||
const { currentTime } = usePlaybackStore();
|
||||
const { splitElement } = useTimelineStore();
|
||||
const splitTime = currentTime;
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
|
||||
const secondElementId = splitElement(
|
||||
track.id,
|
||||
element.id,
|
||||
splitTime
|
||||
);
|
||||
if (!secondElementId) {
|
||||
toast.error("Failed to split element");
|
||||
}
|
||||
} else {
|
||||
toast.error("Playhead must be within element to split");
|
||||
}
|
||||
};
|
||||
|
||||
const handleElementDuplicate = () => {
|
||||
const { addElementToTrack } = useTimelineStore.getState();
|
||||
const { id, ...elementWithoutId } = element;
|
||||
addElementToTrack(track.id, {
|
||||
...elementWithoutId,
|
||||
name: element.name + " (copy)",
|
||||
startTime:
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd) +
|
||||
0.1,
|
||||
});
|
||||
};
|
||||
|
||||
const handleElementDelete = () => {
|
||||
const { removeElementFromTrack } = useTimelineStore.getState();
|
||||
removeElementFromTrack(track.id, element.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<TimelineElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
track={track}
|
||||
zoomLevel={zoomLevel}
|
||||
isSelected={isSelected}
|
||||
onElementMouseDown={handleElementMouseDown}
|
||||
onElementClick={handleElementClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,7 @@ import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RiGithubLine, RiTwitterXLine } from "react-icons/ri";
|
||||
import { getStars } from "@/lib/fetch-github-stars";
|
||||
import { getStars } from "@/lib/fetchGhStars";
|
||||
import Image from "next/image";
|
||||
|
||||
export function Footer() {
|
||||
@ -25,12 +25,12 @@ export function Footer() {
|
||||
|
||||
return (
|
||||
<motion.footer
|
||||
className="bg-background border-t"
|
||||
className="bg-background/80 backdrop-blur-sm border mt-16 m-6 rounded-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.8, duration: 0.8 }}
|
||||
>
|
||||
<div className="max-w-5xl mx-auto px-8 py-10">
|
||||
<div className="max-w-5xl mx-auto px-4 py-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-8">
|
||||
{/* Brand Section */}
|
||||
<div className="md:col-span-1 max-w-sm">
|
||||
|
@ -5,7 +5,7 @@ import { Button } from "./ui/button";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { HeaderBase } from "./header-base";
|
||||
import { useSession } from "@opencut/auth/client";
|
||||
import { getStars } from "@/lib/fetch-github-stars";
|
||||
import { getStars } from "@/lib/fetchGhStars";
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
@ -41,9 +41,9 @@ export function Header() {
|
||||
</Button>
|
||||
</Link>
|
||||
{process.env.NODE_ENV === "development" ? (
|
||||
<Link href="/projects">
|
||||
<Link href="/editor">
|
||||
<Button size="sm" className="text-sm ml-4">
|
||||
Projects
|
||||
Editor
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
@ -61,7 +61,7 @@ export function Header() {
|
||||
return (
|
||||
<div className="mx-4 md:mx-0">
|
||||
<HeaderBase
|
||||
className="bg-accent border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
|
||||
className="bg-[#1D1D1D] border border-white/10 rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
|
||||
leftContent={leftContent}
|
||||
rightContent={rightContent}
|
||||
/>
|
||||
|
@ -37,64 +37,3 @@ export function GithubIcon({ className }: { className?: string }) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackgroundIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="353"
|
||||
height="353"
|
||||
viewBox="0 0 353 353"
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<g clipPath="url(#clip0_1_3)">
|
||||
<rect
|
||||
x="-241.816"
|
||||
y="233.387"
|
||||
width="592.187"
|
||||
height="17.765"
|
||||
transform="rotate(-37 -241.816 233.387)"
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
x="-189.907"
|
||||
y="306.804"
|
||||
width="592.187"
|
||||
height="17.765"
|
||||
transform="rotate(-37 -189.907 306.804)"
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
x="-146.928"
|
||||
y="389.501"
|
||||
width="592.187"
|
||||
height="17.765"
|
||||
transform="rotate(-37 -146.928 389.501)"
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
x="-103.144"
|
||||
y="477.904"
|
||||
width="592.187"
|
||||
height="17.765"
|
||||
transform="rotate(-37 -103.144 477.904)"
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
x="-57.169"
|
||||
y="570.714"
|
||||
width="592.187"
|
||||
height="17.765"
|
||||
transform="rotate(-37 -57.169 570.714)"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_3">
|
||||
<rect width="353" height="353" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
@ -1,164 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { motion, useMotionValue, useTransform, PanInfo } from "motion/react";
|
||||
|
||||
interface HandlebarsProps {
|
||||
children: React.ReactNode;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
onRangeChange?: (left: number, right: number) => void;
|
||||
}
|
||||
|
||||
export function Handlebars({
|
||||
children,
|
||||
minWidth = 50,
|
||||
maxWidth = 400,
|
||||
onRangeChange,
|
||||
}: HandlebarsProps) {
|
||||
const [leftHandle, setLeftHandle] = useState(0);
|
||||
const [rightHandle, setRightHandle] = useState(maxWidth);
|
||||
const [contentWidth, setContentWidth] = useState(maxWidth);
|
||||
|
||||
const leftHandleX = useMotionValue(0);
|
||||
const rightHandleX = useMotionValue(maxWidth);
|
||||
|
||||
const visibleWidth = useTransform(
|
||||
[leftHandleX, rightHandleX],
|
||||
(values: number[]) => values[1] - values[0]
|
||||
);
|
||||
|
||||
const contentLeft = useTransform(leftHandleX, (left: number) => -left);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!measureRef.current) return;
|
||||
|
||||
const measureContent = () => {
|
||||
if (measureRef.current) {
|
||||
const width = measureRef.current.scrollWidth;
|
||||
const paddedWidth = width + 32;
|
||||
setContentWidth(paddedWidth);
|
||||
setRightHandle(paddedWidth);
|
||||
rightHandleX.set(paddedWidth);
|
||||
}
|
||||
};
|
||||
|
||||
measureContent();
|
||||
const timer = setTimeout(measureContent, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [children, rightHandleX]);
|
||||
|
||||
useEffect(() => {
|
||||
leftHandleX.set(leftHandle);
|
||||
}, [leftHandle, leftHandleX]);
|
||||
|
||||
useEffect(() => {
|
||||
rightHandleX.set(rightHandle);
|
||||
}, [rightHandle, rightHandleX]);
|
||||
|
||||
useEffect(() => {
|
||||
onRangeChange?.(leftHandle, rightHandle);
|
||||
}, [leftHandle, rightHandle, onRangeChange]);
|
||||
|
||||
const handleLeftDrag = (event: any, info: PanInfo) => {
|
||||
const newLeft = Math.max(
|
||||
0,
|
||||
Math.min(leftHandle + info.offset.x, rightHandle - minWidth)
|
||||
);
|
||||
setLeftHandle(newLeft);
|
||||
};
|
||||
|
||||
const handleRightDrag = (event: any, info: PanInfo) => {
|
||||
const newRight = Math.max(
|
||||
leftHandle + minWidth,
|
||||
Math.min(contentWidth, rightHandle + info.offset.x)
|
||||
);
|
||||
setRightHandle(newRight);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
|
||||
<div
|
||||
ref={measureRef}
|
||||
className="absolute -left-[9999px] top-0 invisible px-4 whitespace-nowrap font-[inherit]"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2"
|
||||
style={{ width: contentWidth }}
|
||||
>
|
||||
<div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between">
|
||||
<motion.div
|
||||
className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none"
|
||||
style={{
|
||||
position: "absolute",
|
||||
x: leftHandleX,
|
||||
left: 0,
|
||||
zIndex: 10,
|
||||
}}
|
||||
drag="x"
|
||||
dragConstraints={{ left: 0, right: rightHandle - minWidth }}
|
||||
dragElastic={0}
|
||||
dragMomentum={false}
|
||||
onDrag={handleLeftDrag}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileDrag={{ scale: 1.1 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
>
|
||||
<div className="w-2 h-8 rounded-full bg-yellow-500"></div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none"
|
||||
style={{
|
||||
position: "absolute",
|
||||
x: rightHandleX,
|
||||
left: -30,
|
||||
zIndex: 10,
|
||||
}}
|
||||
drag="x"
|
||||
dragConstraints={{
|
||||
left: leftHandle + minWidth,
|
||||
right: contentWidth,
|
||||
}}
|
||||
dragElastic={0}
|
||||
dragMomentum={false}
|
||||
onDrag={handleRightDrag}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileDrag={{ scale: 1.1 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
>
|
||||
<div className="w-2 h-8 rounded-full bg-yellow-500"></div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="relative overflow-hidden rounded-2xl"
|
||||
style={{
|
||||
width: visibleWidth,
|
||||
x: leftHandleX,
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="w-full h-full flex items-center justify-center px-4"
|
||||
style={{
|
||||
x: contentLeft,
|
||||
width: contentWidth,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -4,11 +4,11 @@ import { motion } from "motion/react";
|
||||
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 { useToast } from "@/hooks/use-toast";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Handlebars } from "./handlebars";
|
||||
|
||||
interface HeroProps {
|
||||
signupCount: number;
|
||||
@ -42,7 +42,7 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
body: JSON.stringify({ email: email.trim() }),
|
||||
});
|
||||
|
||||
const data = (await response.json()) as { error: string };
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
@ -53,9 +53,7 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
} else {
|
||||
toast({
|
||||
title: "Oops!",
|
||||
description:
|
||||
(data as { error: string }).error ||
|
||||
"Something went wrong. Please try again.",
|
||||
description: data.error || "Something went wrong. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
@ -71,14 +69,7 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4">
|
||||
<Image
|
||||
className="absolute top-0 left-0 -z-50 size-full object-cover"
|
||||
src="/landing-page-bg.png"
|
||||
height={1903.5}
|
||||
width={1269}
|
||||
alt="landing-page.bg"
|
||||
/>
|
||||
<div className="min-h-[calc(100vh-6rem)] supports-[height:100dvh]:min-h-[calc(100dvh-6rem)] flex flex-col justify-between items-center text-center px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@ -92,7 +83,14 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
|
||||
>
|
||||
<h1>The Open Source</h1>
|
||||
<Handlebars>Video Editor</Handlebars>
|
||||
<div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
|
||||
<div className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2">
|
||||
<Image src="/frame.svg" height={79} width={459} alt="frame" />
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
Video Editor
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
@ -115,21 +113,19 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
onSubmit={handleSubmit}
|
||||
className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"
|
||||
>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
className="h-11 text-base flex-1"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
className="h-11 text-base flex-1"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="px-6 h-11 text-base !bg-foreground"
|
||||
className="px-6 h-11 text-base"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span className="relative z-10">
|
||||
|
@ -1,73 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useState } from "react";
|
||||
|
||||
export function RenameProjectDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
projectName,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (name: string) => void;
|
||||
projectName: string;
|
||||
}) {
|
||||
const [name, setName] = useState(projectName);
|
||||
|
||||
// Reset the name when dialog opens - this is better UX than syncing with every prop change
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
setName(projectName);
|
||||
}
|
||||
onOpenChange(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a new name for your project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onConfirm(name);
|
||||
}
|
||||
}}
|
||||
placeholder="Enter a new name"
|
||||
className="mt-4"
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => onConfirm(name)}>Rename</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@ -1,80 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface StorageContextType {
|
||||
isInitialized: boolean;
|
||||
isLoading: boolean;
|
||||
hasSupport: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const StorageContext = createContext<StorageContextType | null>(null);
|
||||
|
||||
export function useStorage() {
|
||||
const context = useContext(StorageContext);
|
||||
if (!context) {
|
||||
throw new Error("useStorage must be used within StorageProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface StorageProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function StorageProvider({ children }: StorageProviderProps) {
|
||||
const [status, setStatus] = useState<StorageContextType>({
|
||||
isInitialized: false,
|
||||
isLoading: true,
|
||||
hasSupport: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const loadAllProjects = useProjectStore((state) => state.loadAllProjects);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeStorage = async () => {
|
||||
setStatus((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
try {
|
||||
// Check browser support
|
||||
const hasSupport = storageService.isFullySupported();
|
||||
|
||||
if (!hasSupport) {
|
||||
toast.warning(
|
||||
"Storage not fully supported. Some features may not work."
|
||||
);
|
||||
}
|
||||
|
||||
// Load saved projects (media will be loaded when a project is loaded)
|
||||
await loadAllProjects();
|
||||
|
||||
setStatus({
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
hasSupport,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize storage:", error);
|
||||
setStatus({
|
||||
isInitialized: false,
|
||||
isLoading: false,
|
||||
hasSupport: storageService.isFullySupported(),
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
initializeStorage();
|
||||
}, [loadAllProjects]);
|
||||
|
||||
return (
|
||||
<StorageContext.Provider value={status}>{children}</StorageContext.Provider>
|
||||
);
|
||||
}
|
@ -1,127 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
|
||||
interface AudioPlayerProps {
|
||||
src: string;
|
||||
className?: string;
|
||||
clipStartTime: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
clipDuration: number;
|
||||
trackMuted?: boolean;
|
||||
}
|
||||
|
||||
export function AudioPlayer({
|
||||
src,
|
||||
className = "",
|
||||
clipStartTime,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
clipDuration,
|
||||
trackMuted = false,
|
||||
}: AudioPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
|
||||
|
||||
// Calculate if we're within this clip's timeline range
|
||||
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
|
||||
const isInClipRange =
|
||||
currentTime >= clipStartTime && currentTime < clipEndTime;
|
||||
|
||||
// Sync playback events
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !isInClipRange) return;
|
||||
|
||||
const handleSeekEvent = (e: CustomEvent) => {
|
||||
// Always update audio time, even if outside clip range
|
||||
const timelineTime = e.detail.time;
|
||||
const audioTime = Math.max(
|
||||
trimStart,
|
||||
Math.min(
|
||||
clipDuration - trimEnd,
|
||||
timelineTime - clipStartTime + trimStart
|
||||
)
|
||||
);
|
||||
audio.currentTime = audioTime;
|
||||
};
|
||||
|
||||
const handleUpdateEvent = (e: CustomEvent) => {
|
||||
// Always update audio time, even if outside clip range
|
||||
const timelineTime = e.detail.time;
|
||||
const targetTime = Math.max(
|
||||
trimStart,
|
||||
Math.min(
|
||||
clipDuration - trimEnd,
|
||||
timelineTime - clipStartTime + trimStart
|
||||
)
|
||||
);
|
||||
|
||||
if (Math.abs(audio.currentTime - targetTime) > 0.5) {
|
||||
audio.currentTime = targetTime;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSpeed = (e: CustomEvent) => {
|
||||
audio.playbackRate = e.detail.speed;
|
||||
};
|
||||
|
||||
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
|
||||
window.addEventListener(
|
||||
"playback-update",
|
||||
handleUpdateEvent as EventListener
|
||||
);
|
||||
window.addEventListener("playback-speed", handleSpeed as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"playback-seek",
|
||||
handleSeekEvent as EventListener
|
||||
);
|
||||
window.removeEventListener(
|
||||
"playback-update",
|
||||
handleUpdateEvent as EventListener
|
||||
);
|
||||
window.removeEventListener(
|
||||
"playback-speed",
|
||||
handleSpeed as EventListener
|
||||
);
|
||||
};
|
||||
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
|
||||
|
||||
// Sync playback state
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
if (isPlaying && isInClipRange && !trackMuted) {
|
||||
audio.play().catch(() => {});
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
}, [isPlaying, isInClipRange, trackMuted]);
|
||||
|
||||
// Sync volume and speed
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
audio.volume = volume;
|
||||
audio.muted = muted || trackMuted;
|
||||
audio.playbackRate = speed;
|
||||
}, [volume, speed, muted, trackMuted]);
|
||||
|
||||
return (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={src}
|
||||
className={className}
|
||||
preload="auto"
|
||||
controls={false}
|
||||
style={{ display: "none" }} // Audio elements don't need visual representation
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
/>
|
||||
);
|
||||
}
|
@ -10,8 +10,6 @@ const buttonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-foreground text-background shadow hover:bg-foreground/90",
|
||||
primary:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
@ -24,7 +22,7 @@ const buttonVariants = cva(
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-sm px-3 text-xs",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-7 w-7",
|
||||
},
|
||||
|
@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground",
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
@ -3,7 +3,6 @@
|
||||
import * as React from "react";
|
||||
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
@ -19,40 +18,23 @@ const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||
|
||||
const contextMenuItemVariants = cva(
|
||||
"relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "focus:opacity-65 focus:text-accent-foreground",
|
||||
destructive: "text-destructive focus:text-destructive/80",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, inset, children, variant = "default", ...props }, ref) => (
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
contextMenuItemVariants({ variant }),
|
||||
"data-[state=open]:bg-accent data-[state=open]:opacity-65",
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
@ -80,8 +62,7 @@ const ContextMenuContent = React.forwardRef<
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -94,13 +75,12 @@ const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, inset, variant = "default", ...props }, ref) => (
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
contextMenuItemVariants({ variant }),
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
@ -111,13 +91,14 @@ ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {
|
||||
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, children, checked, variant = "default", ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
@ -134,18 +115,19 @@ ContextMenuCheckboxItem.displayName =
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & {
|
||||
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, children, variant = "default", ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
<Circle className="h-4 w-4 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@ -162,7 +144,7 @@ const ContextMenuLabel = React.forwardRef<
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
@ -177,7 +159,7 @@ const ContextMenuSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-foreground/10", className)}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@ -189,7 +171,10 @@ const ContextMenuShortcut = ({
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
@ -1,150 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ReactNode, useState, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Plus } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface DraggableMediaItemProps {
|
||||
name: string;
|
||||
preview: ReactNode;
|
||||
dragData: Record<string, any>;
|
||||
onDragStart?: (e: React.DragEvent) => void;
|
||||
aspectRatio?: number;
|
||||
className?: string;
|
||||
showPlusOnDrag?: boolean;
|
||||
showLabel?: boolean;
|
||||
rounded?: boolean;
|
||||
}
|
||||
|
||||
export function DraggableMediaItem({
|
||||
name,
|
||||
preview,
|
||||
dragData,
|
||||
onDragStart,
|
||||
aspectRatio = 16 / 9,
|
||||
className = "",
|
||||
showPlusOnDrag = true,
|
||||
showLabel = true,
|
||||
rounded = true,
|
||||
}: DraggableMediaItemProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const emptyImg = new window.Image();
|
||||
emptyImg.src =
|
||||
"data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
document.addEventListener("dragover", handleDragOver);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("dragover", handleDragOver);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
e.dataTransfer.setDragImage(emptyImg, 0, 0);
|
||||
|
||||
// Set drag data
|
||||
e.dataTransfer.setData(
|
||||
"application/x-media-item",
|
||||
JSON.stringify(dragData)
|
||||
);
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
|
||||
// Set initial position and show custom drag preview
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
setIsDragging(true);
|
||||
|
||||
onDragStart?.(e);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={dragRef} className="relative group w-28 h-28">
|
||||
<div
|
||||
className={`flex flex-col gap-1 p-0 h-auto w-full relative cursor-default ${className}`}
|
||||
>
|
||||
<AspectRatio
|
||||
ratio={aspectRatio}
|
||||
className={cn(
|
||||
"bg-accent relative overflow-hidden",
|
||||
rounded && "rounded-md",
|
||||
"[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding
|
||||
)}
|
||||
draggable={true}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{preview}
|
||||
{!isDragging && (
|
||||
<PlusButton className="opacity-0 group-hover:opacity-100" />
|
||||
)}
|
||||
</AspectRatio>
|
||||
{showLabel && (
|
||||
<span
|
||||
className="text-[0.7rem] text-muted-foreground truncate w-full text-left"
|
||||
aria-label={name}
|
||||
title={name}
|
||||
>
|
||||
{name.length > 8
|
||||
? `${name.slice(0, 16)}...${name.slice(-3)}`
|
||||
: name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom drag preview */}
|
||||
{isDragging &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed pointer-events-none z-[9999]"
|
||||
style={{
|
||||
left: dragPosition.x - 40, // Center the preview (half of 80px)
|
||||
top: dragPosition.y - 40, // Center the preview (half of 80px)
|
||||
}}
|
||||
>
|
||||
<div className="w-[80px]">
|
||||
<AspectRatio
|
||||
ratio={1}
|
||||
className="relative rounded-md overflow-hidden shadow-2xl ring ring-primary"
|
||||
>
|
||||
<div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none">
|
||||
{preview}
|
||||
</div>
|
||||
{showPlusOnDrag && <PlusButton />}
|
||||
</AspectRatio>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusButton({ className }: { className?: string }) {
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
className={cn("absolute bottom-2 right-2 size-4", className)}
|
||||
>
|
||||
<Plus className="!size-3" />
|
||||
</Button>
|
||||
);
|
||||
}
|
@ -19,33 +19,16 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const dropdownMenuItemVariants = cva(
|
||||
"relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "focus:opacity-65 focus:text-accent-foreground",
|
||||
destructive: "text-destructive focus:text-destructive/80",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, inset, children, variant = "default", ...props }, ref) => (
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
dropdownMenuItemVariants({ variant }),
|
||||
"data-[state=open]:bg-accent data-[state=open]:opacity-65",
|
||||
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
@ -82,12 +65,8 @@ const DropdownMenuContent = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
@ -97,6 +76,22 @@ const DropdownMenuContent = React.forwardRef<
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const dropdownMenuItemVariants = cva(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "focus:bg-accent focus:text-accent-foreground",
|
||||
destructive:
|
||||
"text-destructive focus:bg-destructive focus:text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
@ -118,15 +113,12 @@ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, children, checked, variant = "default", ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
dropdownMenuItemVariants({ variant }),
|
||||
"pl-8 pr-2",
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
@ -145,15 +137,12 @@ DropdownMenuCheckboxItem.displayName =
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, children, variant = "default", ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
dropdownMenuItemVariants({ variant }),
|
||||
"pl-8 pr-2",
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -192,7 +181,7 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-foreground/10", className)}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
@ -1,40 +0,0 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { FONT_OPTIONS, FontFamily } from "@/constants/font-constants";
|
||||
|
||||
interface FontPickerProps {
|
||||
defaultValue?: FontFamily;
|
||||
onValueChange?: (value: FontFamily) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FontPicker({
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
className,
|
||||
}: FontPickerProps) {
|
||||
return (
|
||||
<Select defaultValue={defaultValue} onValueChange={onValueChange}>
|
||||
<SelectTrigger className={`w-full text-xs ${className || ""}`}>
|
||||
<SelectValue placeholder="Select a font" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_OPTIONS.map((font) => (
|
||||
<SelectItem
|
||||
key={font.value}
|
||||
value={font.value}
|
||||
className="text-xs"
|
||||
style={{ fontFamily: font.value }}
|
||||
>
|
||||
{font.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
@ -11,7 +11,14 @@ interface InputProps extends React.ComponentProps<"input"> {
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{ className, type, showPassword, onShowPasswordChange, value, ...props },
|
||||
{
|
||||
className,
|
||||
type,
|
||||
showPassword,
|
||||
onShowPasswordChange,
|
||||
value,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isPassword = type === "password";
|
||||
@ -19,7 +26,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
const inputType = isPassword && showPassword ? "text" : type;
|
||||
|
||||
return (
|
||||
<div className={showPassword ? "relative w-full" : ""}>
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type={inputType}
|
||||
className={cn(
|
||||
|
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { GripVertical } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
@ -28,11 +29,17 @@ const ResizableHandle = ({
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-transparent after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
);
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
|
@ -3,7 +3,6 @@
|
||||
import * as React from "react";
|
||||
import { Select as SelectPrimitive } from "radix-ui";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
@ -13,21 +12,6 @@ const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const selectItemVariants = cva(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "focus:opacity-65 focus:text-accent-foreground",
|
||||
destructive: "text-destructive focus:text-destructive/80",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
@ -97,10 +81,6 @@ const SelectContent = React.forwardRef<
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
@ -133,13 +113,14 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||
variant?: VariantProps<typeof selectItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, children, variant = "default", ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(selectItemVariants({ variant }), className)}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
@ -158,7 +139,7 @@ const SelectSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-foreground/10", className)}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-xs",
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
@ -118,7 +118,7 @@ export function VideoPlayer({
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
poster={poster}
|
||||
className={`max-w-full max-h-full object-contain ${className}`}
|
||||
className={`w-full h-full object-cover ${className}`}
|
||||
playsInline
|
||||
preload="auto"
|
||||
controls={false}
|
||||
|
@ -1,79 +0,0 @@
|
||||
export interface FontOption {
|
||||
value: string;
|
||||
label: string;
|
||||
category: "system" | "google" | "custom";
|
||||
weights?: number[];
|
||||
hasClassName?: boolean;
|
||||
}
|
||||
|
||||
export const FONT_OPTIONS: FontOption[] = [
|
||||
// System fonts (always available)
|
||||
{ value: "Arial", label: "Arial", category: "system", hasClassName: false },
|
||||
{
|
||||
value: "Helvetica",
|
||||
label: "Helvetica",
|
||||
category: "system",
|
||||
hasClassName: false,
|
||||
},
|
||||
{
|
||||
value: "Times New Roman",
|
||||
label: "Times New Roman",
|
||||
category: "system",
|
||||
hasClassName: false,
|
||||
},
|
||||
{
|
||||
value: "Georgia",
|
||||
label: "Georgia",
|
||||
category: "system",
|
||||
hasClassName: false,
|
||||
},
|
||||
|
||||
// Google Fonts (loaded in layout.tsx)
|
||||
{
|
||||
value: "Inter",
|
||||
label: "Inter",
|
||||
category: "google",
|
||||
weights: [400, 700],
|
||||
hasClassName: true,
|
||||
},
|
||||
{
|
||||
value: "Roboto",
|
||||
label: "Roboto",
|
||||
category: "google",
|
||||
weights: [400, 700],
|
||||
hasClassName: true,
|
||||
},
|
||||
{
|
||||
value: "Open Sans",
|
||||
label: "Open Sans",
|
||||
category: "google",
|
||||
hasClassName: true,
|
||||
},
|
||||
{
|
||||
value: "Playfair Display",
|
||||
label: "Playfair Display",
|
||||
category: "google",
|
||||
hasClassName: true,
|
||||
},
|
||||
{
|
||||
value: "Comic Neue",
|
||||
label: "Comic Neue",
|
||||
category: "google",
|
||||
hasClassName: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_FONT = "Arial";
|
||||
|
||||
// Type-safe font family union
|
||||
export type FontFamily = (typeof FONT_OPTIONS)[number]["value"];
|
||||
|
||||
// Helper functions
|
||||
export const getFontByValue = (value: string): FontOption | undefined =>
|
||||
FONT_OPTIONS.find((font) => font.value === value);
|
||||
|
||||
export const getGoogleFonts = (): FontOption[] =>
|
||||
FONT_OPTIONS.filter((font) => font.category === "google");
|
||||
|
||||
export const getSystemFonts = (): FontOption[] =>
|
||||
FONT_OPTIONS.filter((font) => font.category === "system");
|
@ -1,106 +0,0 @@
|
||||
import type { TrackType } from "@/types/timeline";
|
||||
|
||||
// Track color definitions
|
||||
export const TRACK_COLORS: Record<
|
||||
TrackType,
|
||||
{ solid: string; background: string; border: string }
|
||||
> = {
|
||||
media: {
|
||||
solid: "bg-blue-500",
|
||||
background: "bg-blue-500/20",
|
||||
border: "border-white/80",
|
||||
},
|
||||
text: {
|
||||
solid: "bg-[#9C4937]",
|
||||
background: "bg-[#9C4937]",
|
||||
border: "border-white/80",
|
||||
},
|
||||
audio: {
|
||||
solid: "bg-green-500",
|
||||
background: "bg-green-500/20",
|
||||
border: "border-white/80",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Utility functions
|
||||
export function getTrackColors(type: TrackType) {
|
||||
return TRACK_COLORS[type];
|
||||
}
|
||||
|
||||
export function getTrackElementClasses(type: TrackType) {
|
||||
const colors = getTrackColors(type);
|
||||
return `${colors.background} ${colors.border}`;
|
||||
}
|
||||
|
||||
// Track height definitions
|
||||
export const TRACK_HEIGHTS: Record<TrackType, number> = {
|
||||
media: 65,
|
||||
text: 25,
|
||||
audio: 50,
|
||||
} as const;
|
||||
|
||||
// Utility function for track heights
|
||||
export function getTrackHeight(type: TrackType): number {
|
||||
return TRACK_HEIGHTS[type];
|
||||
}
|
||||
|
||||
// Calculate cumulative height up to (but not including) a track index
|
||||
export function getCumulativeHeightBefore(
|
||||
tracks: Array<{ type: TrackType }>,
|
||||
trackIndex: number
|
||||
): number {
|
||||
const GAP = 4; // 4px gap between tracks (equivalent to Tailwind's gap-1)
|
||||
return tracks
|
||||
.slice(0, trackIndex)
|
||||
.reduce((sum, track) => sum + getTrackHeight(track.type) + GAP, 0);
|
||||
}
|
||||
|
||||
// Calculate total height of all tracks
|
||||
export function getTotalTracksHeight(
|
||||
tracks: Array<{ type: TrackType }>
|
||||
): number {
|
||||
const GAP = 4; // 4px gap between tracks (equivalent to Tailwind's gap-1)
|
||||
const tracksHeight = tracks.reduce(
|
||||
(sum, track) => sum + getTrackHeight(track.type),
|
||||
0
|
||||
);
|
||||
const gapsHeight = Math.max(0, tracks.length - 1) * GAP; // n-1 gaps for n tracks
|
||||
return tracksHeight + gapsHeight;
|
||||
}
|
||||
|
||||
// Other timeline constants
|
||||
export const TIMELINE_CONSTANTS = {
|
||||
ELEMENT_MIN_WIDTH: 80,
|
||||
PIXELS_PER_SECOND: 50,
|
||||
TRACK_HEIGHT: 60, // Default fallback
|
||||
DEFAULT_TEXT_DURATION: 5,
|
||||
ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4],
|
||||
} as const;
|
||||
|
||||
// FPS presets for project settings
|
||||
export const FPS_PRESETS = [
|
||||
{ value: "24", label: "24 fps (Film)" },
|
||||
{ value: "25", label: "25 fps (PAL)" },
|
||||
{ value: "30", label: "30 fps (NTSC)" },
|
||||
{ value: "60", label: "60 fps (High)" },
|
||||
{ value: "120", label: "120 fps (Slow-mo)" },
|
||||
] as const;
|
||||
|
||||
// Frame snapping utilities
|
||||
export function timeToFrame(time: number, fps: number): number {
|
||||
return Math.round(time * fps);
|
||||
}
|
||||
|
||||
export function frameToTime(frame: number, fps: number): number {
|
||||
return frame / fps;
|
||||
}
|
||||
|
||||
export function snapTimeToFrame(time: number, fps: number): number {
|
||||
if (fps <= 0) return time; // Fallback for invalid FPS
|
||||
const frame = timeToFrame(time, fps);
|
||||
return frameToTime(frame, fps);
|
||||
}
|
||||
|
||||
export function getFrameDuration(fps: number): number {
|
||||
return 1 / fps;
|
||||
}
|
@ -1,244 +0,0 @@
|
||||
export const colors = [
|
||||
"#ffffff",
|
||||
"#000000",
|
||||
"#ffe2e2",
|
||||
"#ffc9c9",
|
||||
"#ffa2a2",
|
||||
"#ff6467",
|
||||
"#fb2c36",
|
||||
"#e7000b",
|
||||
"#c10007",
|
||||
"#9f0712",
|
||||
"#82181a",
|
||||
"#460809",
|
||||
"#fff7ed",
|
||||
"#ffedd4",
|
||||
"#ffd6a7",
|
||||
"#ffb86a",
|
||||
"#ff8904",
|
||||
"#ff6900",
|
||||
"#f54900",
|
||||
"#ca3500",
|
||||
"#9f2d00",
|
||||
"#7e2a0c",
|
||||
"#441306",
|
||||
"#fffbeb",
|
||||
"#fef3c6",
|
||||
"#fee685",
|
||||
"#ffd230",
|
||||
"#ffb900",
|
||||
"#fe9a00",
|
||||
"#e17100",
|
||||
"#bb4d00",
|
||||
"#973c00",
|
||||
"#7b3306",
|
||||
"#461901",
|
||||
"#fefce8",
|
||||
"#fef9c2",
|
||||
"#fff085",
|
||||
"#ffdf20",
|
||||
"#fdc700",
|
||||
"#f0b100",
|
||||
"#d08700",
|
||||
"#a65f00",
|
||||
"#894b00",
|
||||
"#733e0a",
|
||||
"#432004",
|
||||
"#f7fee7",
|
||||
"#ecfcca",
|
||||
"#d8f999",
|
||||
"#bbf451",
|
||||
"#9ae600",
|
||||
"#7ccf00",
|
||||
"#5ea500",
|
||||
"#497d00",
|
||||
"#3c6300",
|
||||
"#35530e",
|
||||
"#192e03",
|
||||
"#f0fdf4",
|
||||
"#dcfce7",
|
||||
"#b9f8cf",
|
||||
"#7bf1a8",
|
||||
"#05df72",
|
||||
"#00c950",
|
||||
"#00a63e",
|
||||
"#008236",
|
||||
"#016630",
|
||||
"#0d542b",
|
||||
"#032e15",
|
||||
"#ecfdf5",
|
||||
"#d0fae5",
|
||||
"#a4f4cf",
|
||||
"#5ee9b5",
|
||||
"#00d492",
|
||||
"#00bc7d",
|
||||
"#009966",
|
||||
"#007a55",
|
||||
"#006045",
|
||||
"#004f3b",
|
||||
"#002c22",
|
||||
"#f0fdfa",
|
||||
"#cbfbf1",
|
||||
"#96f7e4",
|
||||
"#46ecd5",
|
||||
"#00d5be",
|
||||
"#00bba7",
|
||||
"#009689",
|
||||
"#00786f",
|
||||
"#005f5a",
|
||||
"#0b4f4a",
|
||||
"#022f2e",
|
||||
"#ecfeff",
|
||||
"#cefafe",
|
||||
"#a2f4fd",
|
||||
"#53eafd",
|
||||
"#00d3f2",
|
||||
"#00b8db",
|
||||
"#0092b8",
|
||||
"#007595",
|
||||
"#005f78",
|
||||
"#104e64",
|
||||
"#053345",
|
||||
"#f0f9ff",
|
||||
"#dff2fe",
|
||||
"#b8e6fe",
|
||||
"#74d4ff",
|
||||
"#00bcff",
|
||||
"#00a6f4",
|
||||
"#0084d1",
|
||||
"#0069a8",
|
||||
"#00598a",
|
||||
"#024a70",
|
||||
"#052f4a",
|
||||
"#eff6ff",
|
||||
"#dbeafe",
|
||||
"#bedbff",
|
||||
"#8ec5ff",
|
||||
"#51a2ff",
|
||||
"#2b7fff",
|
||||
"#155dfc",
|
||||
"#1447e6",
|
||||
"#193cb8",
|
||||
"#1c398e",
|
||||
"#162456",
|
||||
"#eef2ff",
|
||||
"#e0e7ff",
|
||||
"#c6d2ff",
|
||||
"#a3b3ff",
|
||||
"#7c86ff",
|
||||
"#615fff",
|
||||
"#4f39f6",
|
||||
"#432dd7",
|
||||
"#372aac",
|
||||
"#312c85",
|
||||
"#1e1a4d",
|
||||
"#f5f3ff",
|
||||
"#ede9fe",
|
||||
"#ddd6ff",
|
||||
"#c4b4ff",
|
||||
"#a684ff",
|
||||
"#8e51ff",
|
||||
"#7f22fe",
|
||||
"#7008e7",
|
||||
"#5d0ec0",
|
||||
"#4d179a",
|
||||
"#2f0d68",
|
||||
"#faf5ff",
|
||||
"#f3e8ff",
|
||||
"#e9d4ff",
|
||||
"#dab2ff",
|
||||
"#c27aff",
|
||||
"#ad46ff",
|
||||
"#9810fa",
|
||||
"#8200db",
|
||||
"#6e11b0",
|
||||
"#59168b",
|
||||
"#3c0366",
|
||||
"#fdf4ff",
|
||||
"#fae8ff",
|
||||
"#f6cfff",
|
||||
"#f4a8ff",
|
||||
"#ed6aff",
|
||||
"#e12afb",
|
||||
"#c800de",
|
||||
"#a800b7",
|
||||
"#8a0194",
|
||||
"#721378",
|
||||
"#4b004f",
|
||||
"#fdf2f8",
|
||||
"#fce7f3",
|
||||
"#fccee8",
|
||||
"#fda5d5",
|
||||
"#fb64b6",
|
||||
"#f6339a",
|
||||
"#e60076",
|
||||
"#c6005c",
|
||||
"#a3004c",
|
||||
"#861043",
|
||||
"#510424",
|
||||
"#fff1f2",
|
||||
"#ffe4e6",
|
||||
"#ffccd3",
|
||||
"#ffa1ad",
|
||||
"#ff637e",
|
||||
"#ff2056",
|
||||
"#ec003f",
|
||||
"#c70036",
|
||||
"#a50036",
|
||||
"#8b0836",
|
||||
"#4d0218",
|
||||
"#f8fafc",
|
||||
"#f1f5f9",
|
||||
"#e2e8f0",
|
||||
"#cad5e2",
|
||||
"#90a1b9",
|
||||
"#62748e",
|
||||
"#45556c",
|
||||
"#314158",
|
||||
"#1d293d",
|
||||
"#0f172b",
|
||||
"#020618",
|
||||
"#f9fafb",
|
||||
"#f3f4f6",
|
||||
"#e5e7eb",
|
||||
"#d1d5dc",
|
||||
"#99a1af",
|
||||
"#6a7282",
|
||||
"#4a5565",
|
||||
"#364153",
|
||||
"#1e2939",
|
||||
"#101828",
|
||||
"#030712",
|
||||
"#fafafa",
|
||||
"#f4f4f5",
|
||||
"#e4e4e7",
|
||||
"#d4d4d8",
|
||||
"#9f9fa9",
|
||||
"#71717b",
|
||||
"#52525c",
|
||||
"#3f3f46",
|
||||
"#27272a",
|
||||
"#18181b",
|
||||
"#09090b",
|
||||
"#f5f5f5",
|
||||
"#e5e5e5",
|
||||
"#d4d4d4",
|
||||
"#a1a1a1",
|
||||
"#737373",
|
||||
"#525252",
|
||||
"#404040",
|
||||
"#262626",
|
||||
"#171717",
|
||||
"#0a0a0a",
|
||||
"#fafaf9",
|
||||
"#f5f5f4",
|
||||
"#e7e5e4",
|
||||
"#d6d3d1",
|
||||
"#a6a09b",
|
||||
"#79716b",
|
||||
"#57534d",
|
||||
"#44403b",
|
||||
"#292524",
|
||||
"#1c1917",
|
||||
"#0c0a09",
|
||||
];
|
@ -1,60 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "@opencut/auth/client";
|
||||
|
||||
export function useLogin() {
|
||||
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 = useCallback(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("/projects");
|
||||
}, [router, email, password]);
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
setError(null);
|
||||
setIsGoogleLoading(true);
|
||||
|
||||
try {
|
||||
await signIn.social({
|
||||
provider: "google",
|
||||
callbackURL: "/projects",
|
||||
});
|
||||
} catch (error) {
|
||||
setError("Failed to sign in with Google. Please try again.");
|
||||
setIsGoogleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isAnyLoading = isEmailLoading || isGoogleLoading;
|
||||
|
||||
return {
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
error,
|
||||
isEmailLoading,
|
||||
isGoogleLoading,
|
||||
isAnyLoading,
|
||||
handleLogin,
|
||||
handleGoogleLogin,
|
||||
};
|
||||
}
|
@ -1,65 +0,0 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signUp, signIn } from "@opencut/auth/client";
|
||||
|
||||
export function useSignUp() {
|
||||
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 = useCallback(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");
|
||||
}, [name, email, password, router]);
|
||||
|
||||
const handleGoogleSignUp = useCallback(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);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const isAnyLoading = isEmailLoading || isGoogleLoading;
|
||||
|
||||
return {
|
||||
name,
|
||||
setName,
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
error,
|
||||
isEmailLoading,
|
||||
isGoogleLoading,
|
||||
isAnyLoading,
|
||||
handleSignUp,
|
||||
handleGoogleSignUp,
|
||||
};
|
||||
}
|
@ -1,92 +0,0 @@
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
|
||||
export function useAspectRatio() {
|
||||
const { canvasSize, canvasMode, canvasPresets } = useEditorStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const { tracks } = useTimelineStore();
|
||||
|
||||
// Find the current preset based on canvas size
|
||||
const currentPreset = canvasPresets.find(
|
||||
(preset) =>
|
||||
preset.width === canvasSize.width && preset.height === canvasSize.height
|
||||
);
|
||||
|
||||
// Get the original aspect ratio from the first video/image in timeline
|
||||
const getOriginalAspectRatio = (): number => {
|
||||
// Find first video or image in timeline
|
||||
for (const track of tracks) {
|
||||
for (const element of track.elements) {
|
||||
if (element.type === "media") {
|
||||
const mediaItem = mediaItems.find(
|
||||
(item) => item.id === element.mediaId
|
||||
);
|
||||
if (
|
||||
mediaItem &&
|
||||
(mediaItem.type === "video" || mediaItem.type === "image")
|
||||
) {
|
||||
return getMediaAspectRatio(mediaItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 16 / 9; // Default aspect ratio
|
||||
};
|
||||
|
||||
// Get current aspect ratio
|
||||
const getCurrentAspectRatio = (): number => {
|
||||
return canvasSize.width / canvasSize.height;
|
||||
};
|
||||
|
||||
// Format aspect ratio as a readable string
|
||||
const formatAspectRatio = (aspectRatio: number): string => {
|
||||
// Check if it matches a common aspect ratio
|
||||
const ratios = [
|
||||
{ ratio: 16 / 9, label: "16:9" },
|
||||
{ ratio: 9 / 16, label: "9:16" },
|
||||
{ ratio: 1, label: "1:1" },
|
||||
{ ratio: 4 / 3, label: "4:3" },
|
||||
{ ratio: 3 / 4, label: "3:4" },
|
||||
{ ratio: 21 / 9, label: "21:9" },
|
||||
];
|
||||
|
||||
for (const { ratio, label } of ratios) {
|
||||
if (Math.abs(aspectRatio - ratio) < 0.01) {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
// If not a common ratio, format as decimal
|
||||
return aspectRatio.toFixed(2);
|
||||
};
|
||||
|
||||
// Check if current mode is "Original"
|
||||
const isOriginal = canvasMode === "original";
|
||||
|
||||
// Get display name for current aspect ratio
|
||||
const getDisplayName = (): string => {
|
||||
// If explicitly set to original mode, always show "Original"
|
||||
if (canvasMode === "original") {
|
||||
return "Original";
|
||||
}
|
||||
|
||||
if (currentPreset) {
|
||||
return currentPreset.name;
|
||||
}
|
||||
|
||||
return formatAspectRatio(getCurrentAspectRatio());
|
||||
};
|
||||
|
||||
return {
|
||||
currentPreset,
|
||||
canvasMode,
|
||||
isOriginal,
|
||||
getCurrentAspectRatio,
|
||||
getOriginalAspectRatio,
|
||||
formatAspectRatio,
|
||||
getDisplayName,
|
||||
canvasSize,
|
||||
canvasPresets,
|
||||
};
|
||||
}
|
226
apps/web/src/hooks/use-drag-clip.ts
Normal file
226
apps/web/src/hooks/use-drag-clip.ts
Normal file
@ -0,0 +1,226 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
|
||||
interface DragState {
|
||||
isDragging: boolean;
|
||||
clipId: string | null;
|
||||
trackId: string | null;
|
||||
startMouseX: number;
|
||||
startClipTime: number;
|
||||
clickOffsetTime: number;
|
||||
currentTime: number;
|
||||
}
|
||||
|
||||
export function useDragClip(zoomLevel: number) {
|
||||
const { tracks, updateClipStartTime, moveClipToTrack } = useTimelineStore();
|
||||
|
||||
const [dragState, setDragState] = useState<DragState>({
|
||||
isDragging: false,
|
||||
clipId: null,
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startClipTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
});
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const dragStateRef = useRef(dragState);
|
||||
|
||||
// Keep ref in sync with state
|
||||
dragStateRef.current = dragState;
|
||||
|
||||
const startDrag = useCallback(
|
||||
(
|
||||
e: React.MouseEvent,
|
||||
clipId: string,
|
||||
trackId: string,
|
||||
clipStartTime: number,
|
||||
clickOffsetTime: number
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
clipId,
|
||||
trackId,
|
||||
startMouseX: e.clientX,
|
||||
startClipTime: clipStartTime,
|
||||
clickOffsetTime,
|
||||
currentTime: clipStartTime,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const updateDrag = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!dragState.isDragging || !timelineRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timelineRect = timelineRef.current.getBoundingClientRect();
|
||||
const mouseX = e.clientX - timelineRect.left;
|
||||
const mouseTime = Math.max(0, mouseX / (50 * zoomLevel));
|
||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||
const snappedTime = Math.round(adjustedTime * 10) / 10;
|
||||
|
||||
setDragState((prev) => ({
|
||||
...prev,
|
||||
currentTime: snappedTime,
|
||||
}));
|
||||
},
|
||||
[dragState.isDragging, dragState.clickOffsetTime, zoomLevel]
|
||||
);
|
||||
|
||||
const endDrag = useCallback(
|
||||
(targetTrackId?: string) => {
|
||||
if (!dragState.isDragging || !dragState.clipId || !dragState.trackId)
|
||||
return;
|
||||
|
||||
const finalTrackId = targetTrackId || dragState.trackId;
|
||||
const finalTime = dragState.currentTime;
|
||||
|
||||
// Check for overlaps
|
||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||
const targetTrack = tracks.find((t) => t.id === finalTrackId);
|
||||
const movingClip = sourceTrack?.clips.find(
|
||||
(c) => c.id === dragState.clipId
|
||||
);
|
||||
|
||||
if (!movingClip || !targetTrack) {
|
||||
setDragState((prev) => ({ ...prev, isDragging: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
const movingClipDuration =
|
||||
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
|
||||
const movingClipEnd = finalTime + movingClipDuration;
|
||||
|
||||
const hasOverlap = targetTrack.clips.some((existingClip) => {
|
||||
// Skip the clip being moved if it's on the same track
|
||||
if (
|
||||
dragState.trackId === finalTrackId &&
|
||||
existingClip.id === dragState.clipId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingStart = existingClip.startTime;
|
||||
const existingEnd =
|
||||
existingClip.startTime +
|
||||
(existingClip.duration -
|
||||
existingClip.trimStart -
|
||||
existingClip.trimEnd);
|
||||
|
||||
return finalTime < existingEnd && movingClipEnd > existingStart;
|
||||
});
|
||||
|
||||
if (!hasOverlap) {
|
||||
if (dragState.trackId === finalTrackId) {
|
||||
// Moving within same track
|
||||
updateClipStartTime(finalTrackId, dragState.clipId!, finalTime);
|
||||
} else {
|
||||
// Moving to different track
|
||||
moveClipToTrack(dragState.trackId!, finalTrackId, dragState.clipId!);
|
||||
requestAnimationFrame(() => {
|
||||
updateClipStartTime(finalTrackId, dragState.clipId!, finalTime);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setDragState({
|
||||
isDragging: false,
|
||||
clipId: null,
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startClipTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
});
|
||||
},
|
||||
[dragState, tracks, updateClipStartTime, moveClipToTrack]
|
||||
);
|
||||
|
||||
const cancelDrag = useCallback(() => {
|
||||
setDragState({
|
||||
isDragging: false,
|
||||
clipId: null,
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startClipTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Global mouse events
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => updateDrag(e);
|
||||
const handleMouseUp = () => endDrag();
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") cancelDrag();
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [dragState.isDragging, updateDrag, endDrag, cancelDrag]);
|
||||
|
||||
const getDraggedClipPosition = useCallback(
|
||||
(clipId: string) => {
|
||||
// Use ref to get current state, not stale closure
|
||||
const currentDragState = dragStateRef.current;
|
||||
const isMatch =
|
||||
currentDragState.isDragging && currentDragState.clipId === clipId;
|
||||
|
||||
if (isMatch) {
|
||||
return currentDragState.currentTime;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[] // No dependencies needed since we use ref
|
||||
);
|
||||
|
||||
const isValidDropTarget = useCallback(
|
||||
(trackId: string) => {
|
||||
if (!dragState.isDragging) return false;
|
||||
|
||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||
const targetTrack = tracks.find((t) => t.id === trackId);
|
||||
|
||||
if (!sourceTrack || !targetTrack) return false;
|
||||
|
||||
// For now, allow drops on same track type
|
||||
return sourceTrack.type === targetTrack.type;
|
||||
},
|
||||
[dragState.isDragging, dragState.trackId, tracks]
|
||||
);
|
||||
|
||||
return {
|
||||
// State
|
||||
isDragging: dragState.isDragging,
|
||||
draggedClipId: dragState.clipId,
|
||||
currentDragTime: dragState.currentTime,
|
||||
clickOffsetTime: dragState.clickOffsetTime,
|
||||
|
||||
// Methods
|
||||
startDrag,
|
||||
endDrag,
|
||||
cancelDrag,
|
||||
getDraggedClipPosition,
|
||||
isValidDropTarget,
|
||||
|
||||
// Refs
|
||||
timelineRef,
|
||||
};
|
||||
}
|
@ -7,105 +7,106 @@ export const usePlaybackControls = () => {
|
||||
const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore();
|
||||
|
||||
const {
|
||||
selectedElements,
|
||||
selectedClips,
|
||||
tracks,
|
||||
splitElement,
|
||||
splitClip,
|
||||
splitAndKeepLeft,
|
||||
splitAndKeepRight,
|
||||
separateAudio,
|
||||
} = useTimelineStore();
|
||||
|
||||
const handleSplitSelectedElement = useCallback(() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element to split");
|
||||
const handleSplitSelectedClip = useCallback(() => {
|
||||
if (selectedClips.length !== 1) {
|
||||
toast.error("Select exactly one clip to split");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const { trackId, clipId } = selectedClips[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
const clip = track?.clips.find((c) => c.id === clipId);
|
||||
|
||||
if (!element) return;
|
||||
if (!clip) return;
|
||||
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within selected element");
|
||||
toast.error("Playhead must be within selected clip");
|
||||
return;
|
||||
}
|
||||
|
||||
splitElement(trackId, elementId, currentTime);
|
||||
}, [selectedElements, tracks, currentTime, splitElement]);
|
||||
splitClip(trackId, clipId, currentTime);
|
||||
toast.success("Clip split at playhead");
|
||||
}, [selectedClips, tracks, currentTime, splitClip]);
|
||||
|
||||
const handleSplitAndKeepLeftCallback = useCallback(() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element");
|
||||
if (selectedClips.length !== 1) {
|
||||
toast.error("Select exactly one clip");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const { trackId, clipId } = selectedClips[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
const clip = track?.clips.find((c) => c.id === clipId);
|
||||
|
||||
if (!element) return;
|
||||
if (!clip) return;
|
||||
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within selected element");
|
||||
toast.error("Playhead must be within selected clip");
|
||||
return;
|
||||
}
|
||||
|
||||
splitAndKeepLeft(trackId, elementId, currentTime);
|
||||
}, [selectedElements, tracks, currentTime, splitAndKeepLeft]);
|
||||
splitAndKeepLeft(trackId, clipId, currentTime);
|
||||
toast.success("Split and kept left portion");
|
||||
}, [selectedClips, tracks, currentTime, splitAndKeepLeft]);
|
||||
|
||||
const handleSplitAndKeepRightCallback = useCallback(() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element");
|
||||
if (selectedClips.length !== 1) {
|
||||
toast.error("Select exactly one clip");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const { trackId, clipId } = selectedClips[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
const element = track?.elements.find((e) => e.id === elementId);
|
||||
const clip = track?.clips.find((c) => c.id === clipId);
|
||||
|
||||
if (!element) return;
|
||||
if (!clip) return;
|
||||
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveStart = clip.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
|
||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
||||
toast.error("Playhead must be within selected element");
|
||||
toast.error("Playhead must be within selected clip");
|
||||
return;
|
||||
}
|
||||
|
||||
splitAndKeepRight(trackId, elementId, currentTime);
|
||||
}, [selectedElements, tracks, currentTime, splitAndKeepRight]);
|
||||
splitAndKeepRight(trackId, clipId, currentTime);
|
||||
toast.success("Split and kept right portion");
|
||||
}, [selectedClips, tracks, currentTime, splitAndKeepRight]);
|
||||
|
||||
const handleSeparateAudioCallback = useCallback(() => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one media element to separate audio");
|
||||
if (selectedClips.length !== 1) {
|
||||
toast.error("Select exactly one video clip to separate audio");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const { trackId, clipId } = selectedClips[0];
|
||||
const track = tracks.find((t) => t.id === trackId);
|
||||
|
||||
if (!track || track.type !== "media") {
|
||||
toast.error("Select a media element to separate audio");
|
||||
if (!track || track.type !== "video") {
|
||||
toast.error("Select a video clip to separate audio");
|
||||
return;
|
||||
}
|
||||
|
||||
separateAudio(trackId, elementId);
|
||||
}, [selectedElements, tracks, separateAudio]);
|
||||
separateAudio(trackId, clipId);
|
||||
toast.success("Audio separated to audio track");
|
||||
}, [selectedClips, tracks, separateAudio]);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
@ -129,7 +130,7 @@ export const usePlaybackControls = () => {
|
||||
case "s":
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleSplitSelectedElement();
|
||||
handleSplitSelectedClip();
|
||||
}
|
||||
break;
|
||||
|
||||
@ -159,7 +160,7 @@ export const usePlaybackControls = () => {
|
||||
isPlaying,
|
||||
play,
|
||||
pause,
|
||||
handleSplitSelectedElement,
|
||||
handleSplitSelectedClip,
|
||||
handleSplitAndKeepLeftCallback,
|
||||
handleSplitAndKeepRightCallback,
|
||||
handleSeparateAudioCallback,
|
||||
|
@ -1,199 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface UseSelectionBoxProps {
|
||||
containerRef: React.RefObject<HTMLElement>;
|
||||
playheadRef?: React.RefObject<HTMLElement>;
|
||||
onSelectionComplete: (
|
||||
elements: { trackId: string; elementId: string }[]
|
||||
) => void;
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface SelectionBoxState {
|
||||
startPos: { x: number; y: number };
|
||||
currentPos: { x: number; y: number };
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function useSelectionBox({
|
||||
containerRef,
|
||||
playheadRef,
|
||||
onSelectionComplete,
|
||||
isEnabled = true,
|
||||
}: UseSelectionBoxProps) {
|
||||
const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
|
||||
null
|
||||
);
|
||||
const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
|
||||
|
||||
// Mouse down handler to start selection
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
// Only start selection on empty space clicks
|
||||
if ((e.target as HTMLElement).closest(".timeline-element")) {
|
||||
return;
|
||||
}
|
||||
if (playheadRef?.current?.contains(e.target as Node)) {
|
||||
return;
|
||||
}
|
||||
if ((e.target as HTMLElement).closest("[data-track-labels]")) {
|
||||
return;
|
||||
}
|
||||
// Don't start selection when clicking in the ruler area - this interferes with playhead dragging
|
||||
if ((e.target as HTMLElement).closest("[data-ruler-area]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectionBox({
|
||||
startPos: { x: e.clientX, y: e.clientY },
|
||||
currentPos: { x: e.clientX, y: e.clientY },
|
||||
isActive: false, // Will become active when mouse moves
|
||||
});
|
||||
},
|
||||
[isEnabled, playheadRef]
|
||||
);
|
||||
|
||||
// Function to select elements within the selection box
|
||||
const selectElementsInBox = useCallback(
|
||||
(startPos: { x: number; y: number }, endPos: { x: number; y: number }) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// Calculate selection rectangle in container coordinates
|
||||
const startX = startPos.x - containerRect.left;
|
||||
const startY = startPos.y - containerRect.top;
|
||||
const endX = endPos.x - containerRect.left;
|
||||
const endY = endPos.y - containerRect.top;
|
||||
|
||||
const selectionRect = {
|
||||
left: Math.min(startX, endX),
|
||||
top: Math.min(startY, endY),
|
||||
right: Math.max(startX, endX),
|
||||
bottom: Math.max(startY, endY),
|
||||
};
|
||||
|
||||
// Find all timeline elements within the selection rectangle
|
||||
const timelineElements = container.querySelectorAll(".timeline-element");
|
||||
|
||||
const selectedElements: { trackId: string; elementId: string }[] = [];
|
||||
|
||||
timelineElements.forEach((element) => {
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
// Use absolute coordinates for more accurate intersection detection
|
||||
const elementAbsolute = {
|
||||
left: elementRect.left,
|
||||
top: elementRect.top,
|
||||
right: elementRect.right,
|
||||
bottom: elementRect.bottom,
|
||||
};
|
||||
|
||||
const selectionAbsolute = {
|
||||
left: startPos.x,
|
||||
top: startPos.y,
|
||||
right: endPos.x,
|
||||
bottom: endPos.y,
|
||||
};
|
||||
|
||||
// Normalize selection rectangle (handle dragging in any direction)
|
||||
const normalizedSelection = {
|
||||
left: Math.min(selectionAbsolute.left, selectionAbsolute.right),
|
||||
top: Math.min(selectionAbsolute.top, selectionAbsolute.bottom),
|
||||
right: Math.max(selectionAbsolute.left, selectionAbsolute.right),
|
||||
bottom: Math.max(selectionAbsolute.top, selectionAbsolute.bottom),
|
||||
};
|
||||
|
||||
const elementId = element.getAttribute("data-element-id");
|
||||
const trackId = element.getAttribute("data-track-id");
|
||||
|
||||
// Check if element intersects with selection rectangle (any overlap)
|
||||
// Using absolute coordinates for more precise detection
|
||||
const intersects = !(
|
||||
elementAbsolute.right < normalizedSelection.left ||
|
||||
elementAbsolute.left > normalizedSelection.right ||
|
||||
elementAbsolute.bottom < normalizedSelection.top ||
|
||||
elementAbsolute.top > normalizedSelection.bottom
|
||||
);
|
||||
|
||||
if (intersects && elementId && trackId) {
|
||||
selectedElements.push({ trackId, elementId });
|
||||
}
|
||||
});
|
||||
|
||||
// Always call the callback - with elements or empty array to clear selection
|
||||
console.log(
|
||||
JSON.stringify({ selectElementsInBox: selectedElements.length })
|
||||
);
|
||||
onSelectionComplete(selectedElements);
|
||||
},
|
||||
[containerRef, onSelectionComplete]
|
||||
);
|
||||
|
||||
// Effect to track selection box movement
|
||||
useEffect(() => {
|
||||
if (!selectionBox) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = Math.abs(e.clientX - selectionBox.startPos.x);
|
||||
const deltaY = Math.abs(e.clientY - selectionBox.startPos.y);
|
||||
|
||||
// Start selection if mouse moved more than 5px
|
||||
const shouldActivate = deltaX > 5 || deltaY > 5;
|
||||
|
||||
const newSelectionBox = {
|
||||
...selectionBox,
|
||||
currentPos: { x: e.clientX, y: e.clientY },
|
||||
isActive: shouldActivate || selectionBox.isActive,
|
||||
};
|
||||
|
||||
setSelectionBox(newSelectionBox);
|
||||
|
||||
// Real-time visual feedback: update selection as we drag
|
||||
if (newSelectionBox.isActive) {
|
||||
selectElementsInBox(
|
||||
newSelectionBox.startPos,
|
||||
newSelectionBox.currentPos
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
console.log(
|
||||
JSON.stringify({ mouseUp: { wasActive: selectionBox?.isActive } })
|
||||
);
|
||||
|
||||
// If we had an active selection, mark that we just finished selecting
|
||||
if (selectionBox?.isActive) {
|
||||
console.log(JSON.stringify({ settingJustFinishedSelecting: true }));
|
||||
setJustFinishedSelecting(true);
|
||||
// Clear the flag after a short delay to allow click events to check it
|
||||
setTimeout(() => {
|
||||
console.log(JSON.stringify({ clearingJustFinishedSelecting: true }));
|
||||
setJustFinishedSelecting(false);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// Don't call selectElementsInBox again - real-time selection already handled it
|
||||
// Just clean up the selection box visual
|
||||
setSelectionBox(null);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [selectionBox, selectElementsInBox]);
|
||||
|
||||
return {
|
||||
selectionBox,
|
||||
handleMouseDown,
|
||||
isSelecting: selectionBox?.isActive || false,
|
||||
justFinishedSelecting,
|
||||
};
|
||||
}
|
@ -1,164 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { ResizeState, TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
|
||||
interface UseTimelineElementResizeProps {
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
onUpdateTrim: (
|
||||
trackId: string,
|
||||
elementId: string,
|
||||
trimStart: number,
|
||||
trimEnd: number
|
||||
) => void;
|
||||
onUpdateDuration: (
|
||||
trackId: string,
|
||||
elementId: string,
|
||||
duration: number
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function useTimelineElementResize({
|
||||
element,
|
||||
track,
|
||||
zoomLevel,
|
||||
onUpdateTrim,
|
||||
onUpdateDuration,
|
||||
}: UseTimelineElementResizeProps) {
|
||||
const [resizing, setResizing] = useState<ResizeState | null>(null);
|
||||
const { mediaItems } = useMediaStore();
|
||||
|
||||
// Set up document-level mouse listeners during resize (like proper drag behavior)
|
||||
useEffect(() => {
|
||||
if (!resizing) return;
|
||||
|
||||
const handleDocumentMouseMove = (e: MouseEvent) => {
|
||||
updateTrimFromMouseMove({ clientX: e.clientX });
|
||||
};
|
||||
|
||||
const handleDocumentMouseUp = () => {
|
||||
handleResizeEnd();
|
||||
};
|
||||
|
||||
// Add document-level listeners for proper drag behavior
|
||||
document.addEventListener("mousemove", handleDocumentMouseMove);
|
||||
document.addEventListener("mouseup", handleDocumentMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDocumentMouseMove);
|
||||
document.removeEventListener("mouseup", handleDocumentMouseUp);
|
||||
};
|
||||
}, [resizing]); // Re-run when resizing state changes
|
||||
|
||||
const handleResizeStart = (
|
||||
e: React.MouseEvent,
|
||||
elementId: string,
|
||||
side: "left" | "right"
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
setResizing({
|
||||
elementId,
|
||||
side,
|
||||
startX: e.clientX,
|
||||
initialTrimStart: element.trimStart,
|
||||
initialTrimEnd: element.trimEnd,
|
||||
});
|
||||
};
|
||||
|
||||
const canExtendElementDuration = () => {
|
||||
// Text elements can always be extended
|
||||
if (element.type === "text") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Media elements - check the media type
|
||||
if (element.type === "media") {
|
||||
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
|
||||
if (!mediaItem) return false;
|
||||
|
||||
// Images can be extended (static content)
|
||||
if (mediaItem.type === "image") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Videos and audio cannot be extended beyond their natural duration
|
||||
// (no additional content exists)
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const updateTrimFromMouseMove = (e: { clientX: number }) => {
|
||||
if (!resizing) return;
|
||||
|
||||
const deltaX = e.clientX - resizing.startX;
|
||||
// Reasonable sensitivity for resize operations - similar to timeline scale
|
||||
const deltaTime = deltaX / (50 * zoomLevel);
|
||||
|
||||
if (resizing.side === "left") {
|
||||
// Left resize - only trim within original duration
|
||||
const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1;
|
||||
const calculated = resizing.initialTrimStart + deltaTime;
|
||||
const newTrimStart = Math.max(0, Math.min(maxAllowed, calculated));
|
||||
|
||||
onUpdateTrim(track.id, element.id, newTrimStart, resizing.initialTrimEnd);
|
||||
} else {
|
||||
// Right resize - can extend duration for supported element types
|
||||
const calculated = resizing.initialTrimEnd - deltaTime;
|
||||
|
||||
if (calculated < 0) {
|
||||
// We're trying to extend beyond original duration
|
||||
if (canExtendElementDuration()) {
|
||||
// Extend the duration instead of reducing trimEnd further
|
||||
const extensionNeeded = Math.abs(calculated);
|
||||
const newDuration = element.duration + extensionNeeded;
|
||||
const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending
|
||||
|
||||
// Update duration first, then trim
|
||||
onUpdateDuration(track.id, element.id, newDuration);
|
||||
onUpdateTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
resizing.initialTrimStart,
|
||||
newTrimEnd
|
||||
);
|
||||
} else {
|
||||
// Can't extend - just set trimEnd to 0 (maximum possible extension)
|
||||
onUpdateTrim(track.id, element.id, resizing.initialTrimStart, 0);
|
||||
}
|
||||
} else {
|
||||
// Normal trimming within original duration
|
||||
const maxTrimEnd = element.duration - resizing.initialTrimStart - 0.1; // Leave at least 0.1s visible
|
||||
const newTrimEnd = Math.max(0, Math.min(maxTrimEnd, calculated));
|
||||
|
||||
onUpdateTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
resizing.initialTrimStart,
|
||||
newTrimEnd
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeMove = (e: React.MouseEvent) => {
|
||||
updateTrimFromMouseMove(e);
|
||||
};
|
||||
|
||||
const handleResizeEnd = () => {
|
||||
setResizing(null);
|
||||
};
|
||||
|
||||
return {
|
||||
resizing,
|
||||
isResizing: resizing !== null,
|
||||
handleResizeStart,
|
||||
// Return empty handlers since we use document listeners now
|
||||
handleResizeMove: () => {}, // Not used anymore
|
||||
handleResizeEnd: () => {}, // Not used anymore
|
||||
};
|
||||
}
|
@ -1,157 +0,0 @@
|
||||
import { snapTimeToFrame } from "@/constants/timeline-constants";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface UseTimelinePlayheadProps {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
zoomLevel: number;
|
||||
seek: (time: number) => void;
|
||||
rulerRef: React.RefObject<HTMLDivElement>;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
||||
playheadRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function useTimelinePlayhead({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
playheadRef,
|
||||
}: UseTimelinePlayheadProps) {
|
||||
// Playhead scrubbing state
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
const [scrubTime, setScrubTime] = useState<number | null>(null);
|
||||
|
||||
// Ruler drag detection state
|
||||
const [isDraggingRuler, setIsDraggingRuler] = useState(false);
|
||||
const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
|
||||
|
||||
const playheadPosition =
|
||||
isScrubbing && scrubTime !== null ? scrubTime : currentTime;
|
||||
|
||||
// --- Playhead Scrubbing Handlers ---
|
||||
const handlePlayheadMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent ruler drag from triggering
|
||||
setIsScrubbing(true);
|
||||
handleScrub(e);
|
||||
},
|
||||
[duration, zoomLevel]
|
||||
);
|
||||
|
||||
// Ruler mouse down handler
|
||||
const handleRulerMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only handle left mouse button
|
||||
if (e.button !== 0) return;
|
||||
|
||||
// Don't interfere if clicking on the playhead itself
|
||||
if (playheadRef?.current?.contains(e.target as Node)) return;
|
||||
|
||||
e.preventDefault();
|
||||
setIsDraggingRuler(true);
|
||||
setHasDraggedRuler(false);
|
||||
|
||||
// Start scrubbing immediately
|
||||
setIsScrubbing(true);
|
||||
handleScrub(e);
|
||||
},
|
||||
[duration, zoomLevel]
|
||||
);
|
||||
|
||||
const handleScrub = useCallback(
|
||||
(e: MouseEvent | React.MouseEvent) => {
|
||||
const ruler = rulerRef.current;
|
||||
if (!ruler) return;
|
||||
const rect = ruler.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
|
||||
// Use frame snapping for playhead scrubbing
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
const time = snapTimeToFrame(rawTime, projectFps);
|
||||
setScrubTime(time);
|
||||
seek(time); // update video preview in real time
|
||||
},
|
||||
[duration, zoomLevel, seek, rulerRef]
|
||||
);
|
||||
|
||||
// Mouse move/up event handlers
|
||||
useEffect(() => {
|
||||
if (!isScrubbing) return;
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
handleScrub(e);
|
||||
// Mark that we've dragged if ruler drag is active
|
||||
if (isDraggingRuler) {
|
||||
setHasDraggedRuler(true);
|
||||
}
|
||||
};
|
||||
const onMouseUp = (e: MouseEvent) => {
|
||||
setIsScrubbing(false);
|
||||
if (scrubTime !== null) seek(scrubTime); // finalize seek
|
||||
setScrubTime(null);
|
||||
|
||||
// Handle ruler click vs drag
|
||||
if (isDraggingRuler) {
|
||||
setIsDraggingRuler(false);
|
||||
// If we didn't drag, treat it as a click-to-seek
|
||||
if (!hasDraggedRuler) {
|
||||
handleScrub(e);
|
||||
}
|
||||
setHasDraggedRuler(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [
|
||||
isScrubbing,
|
||||
scrubTime,
|
||||
seek,
|
||||
handleScrub,
|
||||
isDraggingRuler,
|
||||
hasDraggedRuler,
|
||||
]);
|
||||
|
||||
// --- Playhead auto-scroll effect ---
|
||||
useEffect(() => {
|
||||
const rulerViewport = rulerScrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement;
|
||||
const tracksViewport = tracksScrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement;
|
||||
if (!rulerViewport || !tracksViewport) return;
|
||||
const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
|
||||
const viewportWidth = rulerViewport.clientWidth;
|
||||
const scrollMin = 0;
|
||||
const scrollMax = rulerViewport.scrollWidth - viewportWidth;
|
||||
// Center the playhead if it's not visible (100px buffer)
|
||||
const desiredScroll = Math.max(
|
||||
scrollMin,
|
||||
Math.min(scrollMax, playheadPx - viewportWidth / 2)
|
||||
);
|
||||
if (
|
||||
playheadPx < rulerViewport.scrollLeft + 100 ||
|
||||
playheadPx > rulerViewport.scrollLeft + viewportWidth - 100
|
||||
) {
|
||||
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
|
||||
}
|
||||
}, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]);
|
||||
|
||||
return {
|
||||
playheadPosition,
|
||||
handlePlayheadMouseDown,
|
||||
handleRulerMouseDown,
|
||||
isDraggingRuler,
|
||||
};
|
||||
}
|
@ -1,54 +0,0 @@
|
||||
import { useState, useCallback, useEffect, RefObject } from "react";
|
||||
|
||||
interface UseTimelineZoomProps {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
isInTimeline?: boolean;
|
||||
}
|
||||
|
||||
interface UseTimelineZoomReturn {
|
||||
zoomLevel: number;
|
||||
setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
|
||||
handleWheel: (e: React.WheelEvent) => void;
|
||||
}
|
||||
|
||||
export function useTimelineZoom({
|
||||
containerRef,
|
||||
isInTimeline = false,
|
||||
}: UseTimelineZoomProps): UseTimelineZoomReturn {
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
// Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.15 : 0.15;
|
||||
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
|
||||
}
|
||||
// Otherwise, allow normal scrolling
|
||||
}, []);
|
||||
|
||||
// Prevent browser zooming in/out when in timeline
|
||||
useEffect(() => {
|
||||
const preventZoom = (e: WheelEvent) => {
|
||||
if (
|
||||
isInTimeline &&
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
containerRef.current?.contains(e.target as Node)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("wheel", preventZoom, { passive: false });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("wheel", preventZoom);
|
||||
};
|
||||
}, [isInTimeline, containerRef]);
|
||||
|
||||
return {
|
||||
zoomLevel,
|
||||
setZoomLevel,
|
||||
handleWheel,
|
||||
};
|
||||
}
|
@ -10,7 +10,7 @@ export async function getStars(): Promise<string> {
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const data = (await res.json()) as { stargazers_count: number };
|
||||
const data = await res.json();
|
||||
const count = data.stargazers_count;
|
||||
|
||||
if (typeof count !== "number") {
|
@ -1,39 +0,0 @@
|
||||
import {
|
||||
Inter,
|
||||
Roboto,
|
||||
Open_Sans,
|
||||
Playfair_Display,
|
||||
Comic_Neue,
|
||||
} from "next/font/google";
|
||||
|
||||
// Configure all fonts
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
const roboto = Roboto({ subsets: ["latin"], weight: ["400", "700"] });
|
||||
const openSans = Open_Sans({ subsets: ["latin"] });
|
||||
const playfairDisplay = Playfair_Display({ subsets: ["latin"] });
|
||||
const comicNeue = Comic_Neue({ subsets: ["latin"], weight: ["400", "700"] });
|
||||
|
||||
// Export font class mapping for use in components
|
||||
export const FONT_CLASS_MAP = {
|
||||
Inter: inter.className,
|
||||
Roboto: roboto.className,
|
||||
"Open Sans": openSans.className,
|
||||
"Playfair Display": playfairDisplay.className,
|
||||
"Comic Neue": comicNeue.className,
|
||||
Arial: "",
|
||||
Helvetica: "",
|
||||
"Times New Roman": "",
|
||||
Georgia: "",
|
||||
} as const;
|
||||
|
||||
// Export individual fonts for use in layout
|
||||
export const fonts = {
|
||||
inter,
|
||||
roboto,
|
||||
openSans,
|
||||
playfairDisplay,
|
||||
comicNeue,
|
||||
};
|
||||
|
||||
// Default font for the body
|
||||
export const defaultFont = inter;
|
@ -1,101 +1,81 @@
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getFileType,
|
||||
generateVideoThumbnail,
|
||||
getMediaDuration,
|
||||
getImageDimensions,
|
||||
type MediaItem,
|
||||
} from "@/stores/media-store";
|
||||
import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils";
|
||||
|
||||
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
|
||||
|
||||
export async function processMediaFiles(
|
||||
files: FileList | File[],
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<ProcessedMediaItem[]> {
|
||||
const fileArray = Array.from(files);
|
||||
const processedItems: ProcessedMediaItem[] = [];
|
||||
|
||||
const total = fileArray.length;
|
||||
let completed = 0;
|
||||
|
||||
for (const file of fileArray) {
|
||||
const fileType = getFileType(file);
|
||||
|
||||
if (!fileType) {
|
||||
toast.error(`Unsupported file type: ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
let thumbnailUrl: string | undefined;
|
||||
let duration: number | undefined;
|
||||
let width: number | undefined;
|
||||
let height: number | undefined;
|
||||
let fps: number | undefined;
|
||||
|
||||
try {
|
||||
if (fileType === "image") {
|
||||
// Get image dimensions
|
||||
const dimensions = await getImageDimensions(file);
|
||||
width = dimensions.width;
|
||||
height = dimensions.height;
|
||||
} else if (fileType === "video") {
|
||||
try {
|
||||
// Use FFmpeg for comprehensive video info extraction
|
||||
const videoInfo = await getVideoInfo(file);
|
||||
duration = videoInfo.duration;
|
||||
width = videoInfo.width;
|
||||
height = videoInfo.height;
|
||||
fps = videoInfo.fps;
|
||||
|
||||
// Generate thumbnail using FFmpeg
|
||||
thumbnailUrl = await generateThumbnail(file, 1);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"FFmpeg processing failed, falling back to basic processing:",
|
||||
error
|
||||
);
|
||||
// Fallback to basic processing
|
||||
const videoResult = await generateVideoThumbnail(file);
|
||||
thumbnailUrl = videoResult.thumbnailUrl;
|
||||
width = videoResult.width;
|
||||
height = videoResult.height;
|
||||
duration = await getMediaDuration(file);
|
||||
// FPS will remain undefined for fallback
|
||||
}
|
||||
} else if (fileType === "audio") {
|
||||
// For audio, we don't set width/height/fps (they'll be undefined)
|
||||
duration = await getMediaDuration(file);
|
||||
}
|
||||
|
||||
processedItems.push({
|
||||
name: file.name,
|
||||
type: fileType,
|
||||
file,
|
||||
url,
|
||||
thumbnailUrl,
|
||||
duration,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
});
|
||||
|
||||
// Yield back to the event loop to keep the UI responsive
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
completed += 1;
|
||||
if (onProgress) {
|
||||
const percent = Math.round((completed / total) * 100);
|
||||
onProgress(percent);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing file:", file.name, error);
|
||||
toast.error(`Failed to process ${file.name}`);
|
||||
URL.revokeObjectURL(url); // Clean up on error
|
||||
}
|
||||
}
|
||||
|
||||
return processedItems;
|
||||
}
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getFileType,
|
||||
generateVideoThumbnail,
|
||||
getMediaDuration,
|
||||
getImageAspectRatio,
|
||||
type MediaItem,
|
||||
} from "@/stores/media-store";
|
||||
// import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; // Temporarily disabled
|
||||
|
||||
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
|
||||
|
||||
export async function processMediaFiles(
|
||||
files: FileList | File[],
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<ProcessedMediaItem[]> {
|
||||
const fileArray = Array.from(files);
|
||||
const processedItems: ProcessedMediaItem[] = [];
|
||||
|
||||
const total = fileArray.length;
|
||||
let completed = 0;
|
||||
|
||||
for (const file of fileArray) {
|
||||
const fileType = getFileType(file);
|
||||
|
||||
if (!fileType) {
|
||||
toast.error(`Unsupported file type: ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
let thumbnailUrl: string | undefined;
|
||||
let duration: number | undefined;
|
||||
let aspectRatio: number = 16 / 9; // Default fallback
|
||||
|
||||
try {
|
||||
if (fileType === "image") {
|
||||
// Get image aspect ratio
|
||||
aspectRatio = await getImageAspectRatio(file);
|
||||
} else if (fileType === "video") {
|
||||
// Use basic thumbnail generation for now
|
||||
const videoResult = await generateVideoThumbnail(file);
|
||||
thumbnailUrl = videoResult.thumbnailUrl;
|
||||
aspectRatio = videoResult.aspectRatio;
|
||||
} else if (fileType === "audio") {
|
||||
// For audio, use a square aspect ratio
|
||||
aspectRatio = 1;
|
||||
}
|
||||
|
||||
// Get duration for videos and audio (if not already set by FFmpeg)
|
||||
if ((fileType === "video" || fileType === "audio") && !duration) {
|
||||
duration = await getMediaDuration(file);
|
||||
}
|
||||
|
||||
processedItems.push({
|
||||
name: file.name,
|
||||
type: fileType,
|
||||
file,
|
||||
url,
|
||||
thumbnailUrl,
|
||||
duration,
|
||||
aspectRatio,
|
||||
});
|
||||
|
||||
// Yield back to the event loop to keep the UI responsive
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
completed += 1;
|
||||
if (onProgress) {
|
||||
const percent = Math.round((completed / total) * 100);
|
||||
onProgress(percent);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing file:", file.name, error);
|
||||
toast.error(`Failed to process ${file.name}`);
|
||||
URL.revokeObjectURL(url); // Clean up on error
|
||||
}
|
||||
}
|
||||
|
||||
return processedItems;
|
||||
}
|
||||
|
@ -1,89 +0,0 @@
|
||||
import { StorageAdapter } from "./types";
|
||||
|
||||
export class IndexedDBAdapter<T> implements StorageAdapter<T> {
|
||||
private dbName: string;
|
||||
private storeName: string;
|
||||
private version: number;
|
||||
|
||||
constructor(dbName: string, storeName: string, version: number = 1) {
|
||||
this.dbName = dbName;
|
||||
this.storeName = storeName;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
private async getDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.version);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||
db.createObjectStore(this.storeName, { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async get(key: string): Promise<T | null> {
|
||||
const db = await this.getDB();
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(key);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
});
|
||||
}
|
||||
|
||||
async set(key: string, value: T): Promise<void> {
|
||||
const db = await this.getDB();
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.put({ id: key, ...value });
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
const db = await this.getDB();
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.delete(key);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async list(): Promise<string[]> {
|
||||
const db = await this.getDB();
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.getAllKeys();
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result as string[]);
|
||||
});
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
const db = await this.getDB();
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.clear();
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
import { StorageAdapter } from "./types";
|
||||
|
||||
export class OPFSAdapter implements StorageAdapter<File> {
|
||||
private directoryName: string;
|
||||
|
||||
constructor(directoryName: string = "media") {
|
||||
this.directoryName = directoryName;
|
||||
}
|
||||
|
||||
private async getDirectory(): Promise<FileSystemDirectoryHandle> {
|
||||
const opfsRoot = await navigator.storage.getDirectory();
|
||||
return await opfsRoot.getDirectoryHandle(this.directoryName, {
|
||||
create: true,
|
||||
});
|
||||
}
|
||||
|
||||
async get(key: string): Promise<File | null> {
|
||||
try {
|
||||
const directory = await this.getDirectory();
|
||||
const fileHandle = await directory.getFileHandle(key);
|
||||
return await fileHandle.getFile();
|
||||
} catch (error) {
|
||||
if ((error as Error).name === "NotFoundError") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async set(key: string, file: File): Promise<void> {
|
||||
const directory = await this.getDirectory();
|
||||
const fileHandle = await directory.getFileHandle(key, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
|
||||
await writable.write(file);
|
||||
await writable.close();
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
try {
|
||||
const directory = await this.getDirectory();
|
||||
await directory.removeEntry(key);
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== "NotFoundError") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async list(): Promise<string[]> {
|
||||
const directory = await this.getDirectory();
|
||||
const keys: string[] = [];
|
||||
|
||||
for await (const name of directory.keys()) {
|
||||
keys.push(name);
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
const directory = await this.getDirectory();
|
||||
|
||||
for await (const name of directory.keys()) {
|
||||
await directory.removeEntry(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to check OPFS support
|
||||
static isSupported(): boolean {
|
||||
return "storage" in navigator && "getDirectory" in navigator.storage;
|
||||
}
|
||||
}
|
@ -1,279 +0,0 @@
|
||||
import { TProject } from "@/types/project";
|
||||
import { MediaItem } from "@/stores/media-store";
|
||||
import { IndexedDBAdapter } from "./indexeddb-adapter";
|
||||
import { OPFSAdapter } from "./opfs-adapter";
|
||||
import {
|
||||
MediaFileData,
|
||||
StorageConfig,
|
||||
SerializedProject,
|
||||
TimelineData,
|
||||
} from "./types";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
class StorageService {
|
||||
private projectsAdapter: IndexedDBAdapter<SerializedProject>;
|
||||
private config: StorageConfig;
|
||||
|
||||
constructor() {
|
||||
this.config = {
|
||||
projectsDb: "video-editor-projects",
|
||||
mediaDb: "video-editor-media",
|
||||
timelineDb: "video-editor-timelines",
|
||||
version: 1,
|
||||
};
|
||||
|
||||
this.projectsAdapter = new IndexedDBAdapter<SerializedProject>(
|
||||
this.config.projectsDb,
|
||||
"projects",
|
||||
this.config.version
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to get project-specific media adapters
|
||||
private getProjectMediaAdapters(projectId: string) {
|
||||
const mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>(
|
||||
`${this.config.mediaDb}-${projectId}`,
|
||||
"media-metadata",
|
||||
this.config.version
|
||||
);
|
||||
|
||||
const mediaFilesAdapter = new OPFSAdapter(`media-files-${projectId}`);
|
||||
|
||||
return { mediaMetadataAdapter, mediaFilesAdapter };
|
||||
}
|
||||
|
||||
// Helper to get project-specific timeline adapter
|
||||
private getProjectTimelineAdapter(projectId: string) {
|
||||
return new IndexedDBAdapter<TimelineData>(
|
||||
`${this.config.timelineDb}-${projectId}`,
|
||||
"timeline",
|
||||
this.config.version
|
||||
);
|
||||
}
|
||||
|
||||
// Project operations
|
||||
async saveProject(project: TProject): Promise<void> {
|
||||
// Convert TProject to serializable format
|
||||
const serializedProject: SerializedProject = {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
thumbnail: project.thumbnail,
|
||||
createdAt: project.createdAt.toISOString(),
|
||||
updatedAt: project.updatedAt.toISOString(),
|
||||
backgroundColor: project.backgroundColor,
|
||||
backgroundType: project.backgroundType,
|
||||
blurIntensity: project.blurIntensity,
|
||||
};
|
||||
|
||||
await this.projectsAdapter.set(project.id, serializedProject);
|
||||
}
|
||||
|
||||
async loadProject(id: string): Promise<TProject | null> {
|
||||
const serializedProject = await this.projectsAdapter.get(id);
|
||||
|
||||
if (!serializedProject) return null;
|
||||
|
||||
// Convert back to TProject format
|
||||
return {
|
||||
id: serializedProject.id,
|
||||
name: serializedProject.name,
|
||||
thumbnail: serializedProject.thumbnail,
|
||||
createdAt: new Date(serializedProject.createdAt),
|
||||
updatedAt: new Date(serializedProject.updatedAt),
|
||||
backgroundColor: serializedProject.backgroundColor,
|
||||
backgroundType: serializedProject.backgroundType,
|
||||
blurIntensity: serializedProject.blurIntensity,
|
||||
};
|
||||
}
|
||||
|
||||
async loadAllProjects(): Promise<TProject[]> {
|
||||
const projectIds = await this.projectsAdapter.list();
|
||||
const projects: TProject[] = [];
|
||||
|
||||
for (const id of projectIds) {
|
||||
const project = await this.loadProject(id);
|
||||
if (project) {
|
||||
projects.push(project);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by last updated (most recent first)
|
||||
return projects.sort(
|
||||
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
|
||||
);
|
||||
}
|
||||
|
||||
async deleteProject(id: string): Promise<void> {
|
||||
await this.projectsAdapter.remove(id);
|
||||
}
|
||||
|
||||
// Media operations - now project-specific
|
||||
async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise<void> {
|
||||
const { mediaMetadataAdapter, mediaFilesAdapter } =
|
||||
this.getProjectMediaAdapters(projectId);
|
||||
|
||||
// Save file to project-specific OPFS
|
||||
await mediaFilesAdapter.set(mediaItem.id, mediaItem.file);
|
||||
|
||||
// Save metadata to project-specific IndexedDB
|
||||
const metadata: MediaFileData = {
|
||||
id: mediaItem.id,
|
||||
name: mediaItem.name,
|
||||
type: mediaItem.type,
|
||||
size: mediaItem.file.size,
|
||||
lastModified: mediaItem.file.lastModified,
|
||||
width: mediaItem.width,
|
||||
height: mediaItem.height,
|
||||
duration: mediaItem.duration,
|
||||
};
|
||||
|
||||
await mediaMetadataAdapter.set(mediaItem.id, metadata);
|
||||
}
|
||||
|
||||
async loadMediaItem(
|
||||
projectId: string,
|
||||
id: string
|
||||
): Promise<MediaItem | null> {
|
||||
const { mediaMetadataAdapter, mediaFilesAdapter } =
|
||||
this.getProjectMediaAdapters(projectId);
|
||||
|
||||
const [file, metadata] = await Promise.all([
|
||||
mediaFilesAdapter.get(id),
|
||||
mediaMetadataAdapter.get(id),
|
||||
]);
|
||||
|
||||
if (!file || !metadata) return null;
|
||||
|
||||
// Create new object URL for the file
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
return {
|
||||
id: metadata.id,
|
||||
name: metadata.name,
|
||||
type: metadata.type,
|
||||
file,
|
||||
url,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
duration: metadata.duration,
|
||||
// thumbnailUrl would need to be regenerated or cached separately
|
||||
};
|
||||
}
|
||||
|
||||
async loadAllMediaItems(projectId: string): Promise<MediaItem[]> {
|
||||
const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId);
|
||||
|
||||
const mediaIds = await mediaMetadataAdapter.list();
|
||||
const mediaItems: MediaItem[] = [];
|
||||
|
||||
for (const id of mediaIds) {
|
||||
const item = await this.loadMediaItem(projectId, id);
|
||||
if (item) {
|
||||
mediaItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return mediaItems;
|
||||
}
|
||||
|
||||
async deleteMediaItem(projectId: string, id: string): Promise<void> {
|
||||
const { mediaMetadataAdapter, mediaFilesAdapter } =
|
||||
this.getProjectMediaAdapters(projectId);
|
||||
|
||||
await Promise.all([
|
||||
mediaFilesAdapter.remove(id),
|
||||
mediaMetadataAdapter.remove(id),
|
||||
]);
|
||||
}
|
||||
|
||||
async deleteProjectMedia(projectId: string): Promise<void> {
|
||||
const { mediaMetadataAdapter, mediaFilesAdapter } =
|
||||
this.getProjectMediaAdapters(projectId);
|
||||
|
||||
await Promise.all([
|
||||
mediaMetadataAdapter.clear(),
|
||||
mediaFilesAdapter.clear(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Timeline operations - now project-specific
|
||||
async saveTimeline(
|
||||
projectId: string,
|
||||
tracks: TimelineTrack[]
|
||||
): Promise<void> {
|
||||
const timelineAdapter = this.getProjectTimelineAdapter(projectId);
|
||||
const timelineData: TimelineData = {
|
||||
tracks,
|
||||
lastModified: new Date().toISOString(),
|
||||
};
|
||||
await timelineAdapter.set("timeline", timelineData);
|
||||
}
|
||||
|
||||
async loadTimeline(projectId: string): Promise<TimelineTrack[] | null> {
|
||||
const timelineAdapter = this.getProjectTimelineAdapter(projectId);
|
||||
const timelineData = await timelineAdapter.get("timeline");
|
||||
return timelineData ? timelineData.tracks : null;
|
||||
}
|
||||
|
||||
async deleteProjectTimeline(projectId: string): Promise<void> {
|
||||
const timelineAdapter = this.getProjectTimelineAdapter(projectId);
|
||||
await timelineAdapter.remove("timeline");
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
async clearAllData(): Promise<void> {
|
||||
// Clear all projects
|
||||
await this.projectsAdapter.clear();
|
||||
|
||||
// Note: Project-specific media and timelines will be cleaned up when projects are deleted
|
||||
}
|
||||
|
||||
async getStorageInfo(): Promise<{
|
||||
projects: number;
|
||||
isOPFSSupported: boolean;
|
||||
isIndexedDBSupported: boolean;
|
||||
}> {
|
||||
const projectIds = await this.projectsAdapter.list();
|
||||
|
||||
return {
|
||||
projects: projectIds.length,
|
||||
isOPFSSupported: this.isOPFSSupported(),
|
||||
isIndexedDBSupported: this.isIndexedDBSupported(),
|
||||
};
|
||||
}
|
||||
|
||||
async getProjectStorageInfo(projectId: string): Promise<{
|
||||
mediaItems: number;
|
||||
hasTimeline: boolean;
|
||||
}> {
|
||||
const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId);
|
||||
const timelineAdapter = this.getProjectTimelineAdapter(projectId);
|
||||
|
||||
const [mediaIds, timelineData] = await Promise.all([
|
||||
mediaMetadataAdapter.list(),
|
||||
timelineAdapter.get("timeline"),
|
||||
]);
|
||||
|
||||
return {
|
||||
mediaItems: mediaIds.length,
|
||||
hasTimeline: !!timelineData,
|
||||
};
|
||||
}
|
||||
|
||||
// Check browser support
|
||||
isOPFSSupported(): boolean {
|
||||
return OPFSAdapter.isSupported();
|
||||
}
|
||||
|
||||
isIndexedDBSupported(): boolean {
|
||||
return "indexedDB" in window;
|
||||
}
|
||||
|
||||
isFullySupported(): boolean {
|
||||
return this.isIndexedDBSupported() && this.isOPFSSupported();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const storageService = new StorageService();
|
||||
export { StorageService };
|
@ -1,49 +0,0 @@
|
||||
import { TProject } from "@/types/project";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
export interface StorageAdapter<T> {
|
||||
get(key: string): Promise<T | null>;
|
||||
set(key: string, value: T): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
list(): Promise<string[]>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MediaFileData {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "image" | "video" | "audio";
|
||||
size: number;
|
||||
lastModified: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
// File will be stored separately in OPFS
|
||||
}
|
||||
|
||||
export interface TimelineData {
|
||||
tracks: TimelineTrack[];
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
projectsDb: string;
|
||||
mediaDb: string;
|
||||
timelineDb: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// Helper type for serialization - converts Date objects to strings
|
||||
export type SerializedProject = Omit<TProject, "createdAt" | "updatedAt"> & {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
// Extend FileSystemDirectoryHandle with missing async iterator methods
|
||||
declare global {
|
||||
interface FileSystemDirectoryHandle {
|
||||
keys(): AsyncIterableIterator<string>;
|
||||
values(): AsyncIterableIterator<FileSystemHandle>;
|
||||
entries(): AsyncIterableIterator<[string, FileSystemHandle]>;
|
||||
}
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
// Time-related utility functions
|
||||
|
||||
// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS, HH:MM:SS:FF)
|
||||
export const formatTimeCode = (
|
||||
timeInSeconds: number,
|
||||
format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF" = "HH:MM:SS:CS",
|
||||
fps: number = 30
|
||||
): string => {
|
||||
const hours = Math.floor(timeInSeconds / 3600);
|
||||
const minutes = Math.floor((timeInSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(timeInSeconds % 60);
|
||||
const centiseconds = Math.floor((timeInSeconds % 1) * 100);
|
||||
const frames = Math.floor((timeInSeconds % 1) * fps);
|
||||
|
||||
switch (format) {
|
||||
case "MM:SS":
|
||||
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
case "HH:MM:SS":
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
case "HH:MM:SS:CS":
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`;
|
||||
case "HH:MM:SS:FF":
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`;
|
||||
}
|
||||
};
|
@ -5,34 +5,4 @@ import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a UUID v4 string
|
||||
* Uses crypto.randomUUID() if available, otherwise falls back to a custom implementation
|
||||
*/
|
||||
export function generateUUID(): string {
|
||||
// Use the native crypto.randomUUID if available
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// Secure fallback using crypto.getRandomValues
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
|
||||
// Set version 4 (UUIDv4)
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
// Set variant 10xxxxxx
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
|
||||
const hex = [...bytes].map(b => b.toString(16).padStart(2, '0'));
|
||||
|
||||
return (
|
||||
hex.slice(0, 4).join('') + '-' +
|
||||
hex.slice(4, 6).join('') + '-' +
|
||||
hex.slice(6, 8).join('') + '-' +
|
||||
hex.slice(8, 10).join('') + '-' +
|
||||
hex.slice(10, 16).join('')
|
||||
);
|
||||
}
|
@ -1,18 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { getSessionCookie } from "better-auth/cookies";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
// Handle fuckcapcut.com domain redirect
|
||||
if (request.headers.get("host") === "fuckcapcut.com") {
|
||||
return NextResponse.redirect("https://opencut.app/why-not-capcut", 301);
|
||||
}
|
||||
|
||||
const path = request.nextUrl.pathname;
|
||||
const session = getSessionCookie(request);
|
||||
|
||||
if (path === "/editor" && process.env.NODE_ENV === "production") {
|
||||
const homeUrl = new URL("/", request.url);
|
||||
homeUrl.searchParams.set("redirect", request.url);
|
||||
return NextResponse.redirect(homeUrl);
|
||||
if (path === "/editor" && !session && process.env.NODE_ENV === "production") {
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
loginUrl.searchParams.set("redirect", request.url);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
|
@ -1,76 +1,20 @@
|
||||
import { create } from "zustand";
|
||||
import { CanvasSize, CanvasPreset } from "@/types/editor";
|
||||
|
||||
type CanvasMode = "preset" | "original" | "custom";
|
||||
|
||||
interface EditorState {
|
||||
// Loading states
|
||||
isInitializing: boolean;
|
||||
isPanelsReady: boolean;
|
||||
|
||||
// Canvas/Project settings
|
||||
canvasSize: CanvasSize;
|
||||
canvasMode: CanvasMode;
|
||||
canvasPresets: CanvasPreset[];
|
||||
|
||||
// Actions
|
||||
setInitializing: (loading: boolean) => void;
|
||||
setPanelsReady: (ready: boolean) => void;
|
||||
initializeApp: () => Promise<void>;
|
||||
setCanvasSize: (size: CanvasSize) => void;
|
||||
setCanvasSizeToOriginal: (aspectRatio: number) => void;
|
||||
setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [
|
||||
{ name: "16:9", width: 1920, height: 1080 },
|
||||
{ name: "9:16", width: 1080, height: 1920 },
|
||||
{ name: "1:1", width: 1080, height: 1080 },
|
||||
{ name: "4:3", width: 1440, height: 1080 },
|
||||
];
|
||||
|
||||
// Helper function to find the best matching canvas preset for an aspect ratio
|
||||
const findBestCanvasPreset = (aspectRatio: number): CanvasSize => {
|
||||
// Calculate aspect ratio for each preset and find the closest match
|
||||
let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
|
||||
let smallestDifference = Math.abs(
|
||||
aspectRatio - bestMatch.width / bestMatch.height
|
||||
);
|
||||
|
||||
for (const preset of DEFAULT_CANVAS_PRESETS) {
|
||||
const presetAspectRatio = preset.width / preset.height;
|
||||
const difference = Math.abs(aspectRatio - presetAspectRatio);
|
||||
|
||||
if (difference < smallestDifference) {
|
||||
smallestDifference = difference;
|
||||
bestMatch = preset;
|
||||
}
|
||||
}
|
||||
|
||||
// If the difference is still significant (> 0.1), create a custom size
|
||||
// based on the media aspect ratio with a reasonable resolution
|
||||
const bestAspectRatio = bestMatch.width / bestMatch.height;
|
||||
if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
|
||||
// Create custom dimensions based on the aspect ratio
|
||||
if (aspectRatio > 1) {
|
||||
// Landscape - use 1920 width
|
||||
return { width: 1920, height: Math.round(1920 / aspectRatio) };
|
||||
} else {
|
||||
// Portrait or square - use 1080 height
|
||||
return { width: Math.round(1080 * aspectRatio), height: 1080 };
|
||||
}
|
||||
}
|
||||
|
||||
return { width: bestMatch.width, height: bestMatch.height };
|
||||
};
|
||||
|
||||
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
// Initial states
|
||||
isInitializing: true,
|
||||
isPanelsReady: false,
|
||||
canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
|
||||
canvasMode: "preset" as CanvasMode,
|
||||
canvasPresets: DEFAULT_CANVAS_PRESETS,
|
||||
|
||||
// Actions
|
||||
setInitializing: (loading) => {
|
||||
@ -88,18 +32,4 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
set({ isPanelsReady: true, isInitializing: false });
|
||||
console.log("Video editor ready");
|
||||
},
|
||||
|
||||
setCanvasSize: (size) => {
|
||||
set({ canvasSize: size, canvasMode: "preset" });
|
||||
},
|
||||
|
||||
setCanvasSizeToOriginal: (aspectRatio) => {
|
||||
const newCanvasSize = findBestCanvasPreset(aspectRatio);
|
||||
set({ canvasSize: newCanvasSize, canvasMode: "original" });
|
||||
},
|
||||
|
||||
setCanvasSizeFromAspectRatio: (aspectRatio) => {
|
||||
const newCanvasSize = findBestCanvasPreset(aspectRatio);
|
||||
set({ canvasSize: newCanvasSize, canvasMode: "custom" });
|
||||
},
|
||||
}));
|
||||
|
@ -1,267 +1,170 @@
|
||||
import { create } from "zustand";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
export type MediaType = "image" | "video" | "audio";
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: MediaType;
|
||||
file: File;
|
||||
url?: string; // Object URL for preview
|
||||
thumbnailUrl?: string; // For video thumbnails
|
||||
duration?: number; // For video/audio duration
|
||||
width?: number; // For video/image width
|
||||
height?: number; // For video/image height
|
||||
fps?: number; // For video frame rate
|
||||
// Text-specific properties
|
||||
content?: string; // Text content
|
||||
fontSize?: number; // Font size
|
||||
fontFamily?: string; // Font family
|
||||
color?: string; // Text color
|
||||
backgroundColor?: string; // Background color
|
||||
textAlign?: "left" | "center" | "right"; // Text alignment
|
||||
}
|
||||
|
||||
interface MediaStore {
|
||||
mediaItems: MediaItem[];
|
||||
isLoading: boolean;
|
||||
|
||||
// Actions - now require projectId
|
||||
addMediaItem: (
|
||||
projectId: string,
|
||||
item: Omit<MediaItem, "id">
|
||||
) => Promise<void>;
|
||||
removeMediaItem: (projectId: string, id: string) => Promise<void>;
|
||||
loadProjectMedia: (projectId: string) => Promise<void>;
|
||||
clearProjectMedia: (projectId: string) => Promise<void>;
|
||||
clearAllMedia: () => void; // Clear local state only
|
||||
}
|
||||
|
||||
// Helper function to determine file type
|
||||
export const getFileType = (file: File): MediaType | null => {
|
||||
const { type } = file;
|
||||
|
||||
if (type.startsWith("image/")) {
|
||||
return "image";
|
||||
}
|
||||
if (type.startsWith("video/")) {
|
||||
return "video";
|
||||
}
|
||||
if (type.startsWith("audio/")) {
|
||||
return "audio";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper function to get image dimensions
|
||||
export const getImageDimensions = (
|
||||
file: File
|
||||
): Promise<{ width: number; height: number }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new window.Image();
|
||||
|
||||
img.addEventListener("load", () => {
|
||||
const width = img.naturalWidth;
|
||||
const height = img.naturalHeight;
|
||||
resolve({ width, height });
|
||||
img.remove();
|
||||
});
|
||||
|
||||
img.addEventListener("error", () => {
|
||||
reject(new Error("Could not load image"));
|
||||
img.remove();
|
||||
});
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to generate video thumbnail and get dimensions
|
||||
export const generateVideoThumbnail = (
|
||||
file: File
|
||||
): Promise<{ thumbnailUrl: string; width: number; height: number }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement("video") as HTMLVideoElement;
|
||||
const canvas = document.createElement("canvas") as HTMLCanvasElement;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error("Could not get canvas context"));
|
||||
return;
|
||||
}
|
||||
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
// Seek to 1 second or 10% of duration, whichever is smaller
|
||||
video.currentTime = Math.min(1, video.duration * 0.1);
|
||||
});
|
||||
|
||||
video.addEventListener("seeked", () => {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8);
|
||||
const width = video.videoWidth;
|
||||
const height = video.videoHeight;
|
||||
|
||||
resolve({ thumbnailUrl, width, height });
|
||||
|
||||
// Cleanup
|
||||
video.remove();
|
||||
canvas.remove();
|
||||
});
|
||||
|
||||
video.addEventListener("error", () => {
|
||||
reject(new Error("Could not load video"));
|
||||
video.remove();
|
||||
canvas.remove();
|
||||
});
|
||||
|
||||
video.src = URL.createObjectURL(file);
|
||||
video.load();
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to get media duration
|
||||
export const getMediaDuration = (file: File): Promise<number> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const element = document.createElement(
|
||||
file.type.startsWith("video/") ? "video" : "audio"
|
||||
) as HTMLVideoElement;
|
||||
|
||||
element.addEventListener("loadedmetadata", () => {
|
||||
resolve(element.duration);
|
||||
element.remove();
|
||||
});
|
||||
|
||||
element.addEventListener("error", () => {
|
||||
reject(new Error("Could not load media"));
|
||||
element.remove();
|
||||
});
|
||||
|
||||
element.src = URL.createObjectURL(file);
|
||||
element.load();
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to get aspect ratio from MediaItem
|
||||
export const getMediaAspectRatio = (item: MediaItem): number => {
|
||||
if (item.width && item.height) {
|
||||
return item.width / item.height;
|
||||
}
|
||||
return 16 / 9; // Default aspect ratio
|
||||
};
|
||||
|
||||
export const useMediaStore = create<MediaStore>((set, get) => ({
|
||||
mediaItems: [],
|
||||
isLoading: false,
|
||||
|
||||
addMediaItem: async (projectId, item) => {
|
||||
const newItem: MediaItem = {
|
||||
...item,
|
||||
id: generateUUID(),
|
||||
};
|
||||
|
||||
// Add to local state immediately for UI responsiveness
|
||||
set((state) => ({
|
||||
mediaItems: [...state.mediaItems, newItem],
|
||||
}));
|
||||
|
||||
// Save to persistent storage in background
|
||||
try {
|
||||
await storageService.saveMediaItem(projectId, newItem);
|
||||
} catch (error) {
|
||||
console.error("Failed to save media item:", error);
|
||||
// Remove from local state if save failed
|
||||
set((state) => ({
|
||||
mediaItems: state.mediaItems.filter((media) => media.id !== newItem.id),
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
removeMediaItem: async (projectId, id: string) => {
|
||||
const state = get();
|
||||
const item = state.mediaItems.find((media) => media.id === id);
|
||||
|
||||
// Cleanup object URLs to prevent memory leaks
|
||||
if (item && item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from local state immediately
|
||||
set((state) => ({
|
||||
mediaItems: state.mediaItems.filter((media) => media.id !== id),
|
||||
}));
|
||||
|
||||
// Remove from persistent storage
|
||||
try {
|
||||
await storageService.deleteMediaItem(projectId, id);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete media item:", error);
|
||||
}
|
||||
},
|
||||
|
||||
loadProjectMedia: async (projectId) => {
|
||||
set({ isLoading: true });
|
||||
|
||||
try {
|
||||
const mediaItems = await storageService.loadAllMediaItems(projectId);
|
||||
set({ mediaItems });
|
||||
} catch (error) {
|
||||
console.error("Failed to load media items:", error);
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
clearProjectMedia: async (projectId) => {
|
||||
const state = get();
|
||||
|
||||
// Cleanup all object URLs
|
||||
state.mediaItems.forEach((item) => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
}
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear local state
|
||||
set({ mediaItems: [] });
|
||||
|
||||
// Clear persistent storage
|
||||
try {
|
||||
const mediaIds = state.mediaItems.map((item) => item.id);
|
||||
await Promise.all(
|
||||
mediaIds.map((id) => storageService.deleteMediaItem(projectId, id))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to clear media items from storage:", error);
|
||||
}
|
||||
},
|
||||
|
||||
clearAllMedia: () => {
|
||||
const state = get();
|
||||
|
||||
// Cleanup all object URLs
|
||||
state.mediaItems.forEach((item) => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
}
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear local state
|
||||
set({ mediaItems: [] });
|
||||
},
|
||||
}));
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "image" | "video" | "audio";
|
||||
file: File;
|
||||
url: string; // Object URL for preview
|
||||
thumbnailUrl?: string; // For video thumbnails
|
||||
duration?: number; // For video/audio duration
|
||||
aspectRatio: number; // width / height
|
||||
}
|
||||
|
||||
interface MediaStore {
|
||||
mediaItems: MediaItem[];
|
||||
|
||||
// Actions
|
||||
addMediaItem: (item: Omit<MediaItem, "id">) => void;
|
||||
removeMediaItem: (id: string) => void;
|
||||
clearAllMedia: () => void;
|
||||
}
|
||||
|
||||
// Helper function to determine file type
|
||||
export const getFileType = (file: File): "image" | "video" | "audio" | null => {
|
||||
const { type } = file;
|
||||
|
||||
if (type.startsWith("image/")) {
|
||||
return "image";
|
||||
}
|
||||
if (type.startsWith("video/")) {
|
||||
return "video";
|
||||
}
|
||||
if (type.startsWith("audio/")) {
|
||||
return "audio";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper function to get image aspect ratio
|
||||
export const getImageAspectRatio = (file: File): Promise<number> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
|
||||
img.addEventListener("load", () => {
|
||||
const aspectRatio = img.naturalWidth / img.naturalHeight;
|
||||
resolve(aspectRatio);
|
||||
img.remove();
|
||||
});
|
||||
|
||||
img.addEventListener("error", () => {
|
||||
reject(new Error("Could not load image"));
|
||||
img.remove();
|
||||
});
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to generate video thumbnail and get aspect ratio
|
||||
export const generateVideoThumbnail = (
|
||||
file: File
|
||||
): Promise<{ thumbnailUrl: string; aspectRatio: number }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement("video");
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error("Could not get canvas context"));
|
||||
return;
|
||||
}
|
||||
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
// Seek to 1 second or 10% of duration, whichever is smaller
|
||||
video.currentTime = Math.min(1, video.duration * 0.1);
|
||||
});
|
||||
|
||||
video.addEventListener("seeked", () => {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8);
|
||||
const aspectRatio = video.videoWidth / video.videoHeight;
|
||||
|
||||
resolve({ thumbnailUrl, aspectRatio });
|
||||
|
||||
// Cleanup
|
||||
video.remove();
|
||||
canvas.remove();
|
||||
});
|
||||
|
||||
video.addEventListener("error", () => {
|
||||
reject(new Error("Could not load video"));
|
||||
video.remove();
|
||||
canvas.remove();
|
||||
});
|
||||
|
||||
video.src = URL.createObjectURL(file);
|
||||
video.load();
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to get media duration
|
||||
export const getMediaDuration = (file: File): Promise<number> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const element = document.createElement(
|
||||
file.type.startsWith("video/") ? "video" : "audio"
|
||||
) as HTMLVideoElement | HTMLAudioElement;
|
||||
|
||||
element.addEventListener("loadedmetadata", () => {
|
||||
resolve(element.duration);
|
||||
element.remove();
|
||||
});
|
||||
|
||||
element.addEventListener("error", () => {
|
||||
reject(new Error("Could not load media"));
|
||||
element.remove();
|
||||
});
|
||||
|
||||
element.src = URL.createObjectURL(file);
|
||||
element.load();
|
||||
});
|
||||
};
|
||||
|
||||
export const useMediaStore = create<MediaStore>((set, get) => ({
|
||||
mediaItems: [],
|
||||
|
||||
addMediaItem: (item) => {
|
||||
const newItem: MediaItem = {
|
||||
...item,
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
set((state) => ({
|
||||
mediaItems: [...state.mediaItems, newItem],
|
||||
}));
|
||||
},
|
||||
|
||||
removeMediaItem: (id) => {
|
||||
const state = get();
|
||||
const item = state.mediaItems.find((item) => item.id === id);
|
||||
|
||||
// Cleanup object URLs to prevent memory leaks
|
||||
if (item) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
mediaItems: state.mediaItems.filter((item) => item.id !== id),
|
||||
}));
|
||||
},
|
||||
|
||||
clearAllMedia: () => {
|
||||
const state = get();
|
||||
|
||||
// Cleanup all object URLs
|
||||
state.mediaItems.forEach((item) => {
|
||||
URL.revokeObjectURL(item.url);
|
||||
if (item.thumbnailUrl) {
|
||||
URL.revokeObjectURL(item.thumbnailUrl);
|
||||
}
|
||||
});
|
||||
|
||||
set({ mediaItems: [] });
|
||||
},
|
||||
}));
|
||||
|
@ -1,14 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
const DEFAULT_PANEL_SIZES = {
|
||||
toolsPanel: 45,
|
||||
previewPanel: 75,
|
||||
propertiesPanel: 20,
|
||||
mainContent: 70,
|
||||
timeline: 30,
|
||||
} as const;
|
||||
|
||||
interface PanelState {
|
||||
// Panel sizes as percentages
|
||||
toolsPanel: number;
|
||||
@ -29,7 +21,11 @@ export const usePanelStore = create<PanelState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Default sizes - optimized for responsiveness
|
||||
...DEFAULT_PANEL_SIZES,
|
||||
toolsPanel: 25,
|
||||
previewPanel: 75,
|
||||
propertiesPanel: 20,
|
||||
mainContent: 70,
|
||||
timeline: 30,
|
||||
|
||||
// Actions
|
||||
setToolsPanel: (size) => set({ toolsPanel: size }),
|
||||
|
@ -1,320 +1,41 @@
|
||||
import { TProject } from "@/types/project";
|
||||
import { create } from "zustand";
|
||||
import { storageService } from "@/lib/storage/storage-service";
|
||||
import { toast } from "sonner";
|
||||
import { useMediaStore } from "./media-store";
|
||||
import { useTimelineStore } from "./timeline-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
interface ProjectStore {
|
||||
activeProject: TProject | null;
|
||||
savedProjects: TProject[];
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
|
||||
// Actions
|
||||
createNewProject: (name: string) => Promise<string>;
|
||||
loadProject: (id: string) => Promise<void>;
|
||||
saveCurrentProject: () => Promise<void>;
|
||||
loadAllProjects: () => Promise<void>;
|
||||
deleteProject: (id: string) => Promise<void>;
|
||||
createNewProject: (name: string) => void;
|
||||
closeProject: () => void;
|
||||
renameProject: (projectId: string, name: string) => Promise<void>;
|
||||
duplicateProject: (projectId: string) => Promise<string>;
|
||||
updateProjectBackground: (backgroundColor: string) => Promise<void>;
|
||||
updateBackgroundType: (
|
||||
type: "color" | "blur",
|
||||
options?: { backgroundColor?: string; blurIntensity?: number }
|
||||
) => Promise<void>;
|
||||
updateProjectFps: (fps: number) => Promise<void>;
|
||||
updateProjectName: (name: string) => void;
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
export const useProjectStore = create<ProjectStore>((set) => ({
|
||||
activeProject: null,
|
||||
savedProjects: [],
|
||||
isLoading: true,
|
||||
isInitialized: false,
|
||||
|
||||
createNewProject: async (name: string) => {
|
||||
createNewProject: (name: string) => {
|
||||
const newProject: TProject = {
|
||||
id: generateUUID(),
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
thumbnail: "",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
backgroundColor: "#000000",
|
||||
backgroundType: "color",
|
||||
blurIntensity: 8,
|
||||
};
|
||||
|
||||
set({ activeProject: newProject });
|
||||
|
||||
try {
|
||||
await storageService.saveProject(newProject);
|
||||
// Reload all projects to update the list
|
||||
await get().loadAllProjects();
|
||||
return newProject.id;
|
||||
} catch (error) {
|
||||
toast.error("Failed to save new project");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
loadProject: async (id: string) => {
|
||||
if (!get().isInitialized) {
|
||||
set({ isLoading: true });
|
||||
}
|
||||
|
||||
// Clear media and timeline immediately to prevent flickering when switching projects
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
|
||||
try {
|
||||
const project = await storageService.loadProject(id);
|
||||
if (project) {
|
||||
set({ activeProject: project });
|
||||
|
||||
// Load project-specific data in parallel
|
||||
await Promise.all([
|
||||
mediaStore.loadProjectMedia(id),
|
||||
timelineStore.loadProjectTimeline(id),
|
||||
]);
|
||||
} else {
|
||||
throw new Error(`Project with id ${id} not found`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load project:", error);
|
||||
throw error; // Re-throw so the editor page can handle it
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
saveCurrentProject: async () => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
try {
|
||||
// Save project metadata and timeline data in parallel
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
await Promise.all([
|
||||
storageService.saveProject(activeProject),
|
||||
timelineStore.saveProjectTimeline(activeProject.id),
|
||||
]);
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Failed to save project:", error);
|
||||
}
|
||||
},
|
||||
|
||||
loadAllProjects: async () => {
|
||||
if (!get().isInitialized) {
|
||||
set({ isLoading: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const projects = await storageService.loadAllProjects();
|
||||
set({ savedProjects: projects });
|
||||
} catch (error) {
|
||||
console.error("Failed to load projects:", error);
|
||||
} finally {
|
||||
set({ isLoading: false, isInitialized: true });
|
||||
}
|
||||
},
|
||||
|
||||
deleteProject: async (id: string) => {
|
||||
try {
|
||||
// Delete project data in parallel
|
||||
await Promise.all([
|
||||
storageService.deleteProjectMedia(id),
|
||||
storageService.deleteProjectTimeline(id),
|
||||
storageService.deleteProject(id),
|
||||
]);
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
|
||||
// If we deleted the active project, close it and clear data
|
||||
const { activeProject } = get();
|
||||
if (activeProject?.id === id) {
|
||||
set({ activeProject: null });
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete project:", error);
|
||||
}
|
||||
},
|
||||
|
||||
closeProject: () => {
|
||||
set({ activeProject: null });
|
||||
|
||||
// Clear data from stores when closing project
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
},
|
||||
|
||||
renameProject: async (id: string, name: string) => {
|
||||
const { savedProjects } = get();
|
||||
|
||||
// Find the project to rename
|
||||
const projectToRename = savedProjects.find((p) => p.id === id);
|
||||
if (!projectToRename) {
|
||||
toast.error("Project not found", {
|
||||
description: "Please try again",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedProject = {
|
||||
...projectToRename,
|
||||
name,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
// Save to storage
|
||||
await storageService.saveProject(updatedProject);
|
||||
|
||||
await get().loadAllProjects();
|
||||
|
||||
// Update activeProject if it's the same project
|
||||
const { activeProject } = get();
|
||||
if (activeProject?.id === id) {
|
||||
set({ activeProject: updatedProject });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to rename project:", error);
|
||||
toast.error("Failed to rename project", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
duplicateProject: async (projectId: string) => {
|
||||
try {
|
||||
const project = await storageService.loadProject(projectId);
|
||||
if (!project) {
|
||||
toast.error("Project not found", {
|
||||
description: "Please try again",
|
||||
});
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const { savedProjects } = get();
|
||||
|
||||
// Extract the base name (remove any existing numbering)
|
||||
const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/);
|
||||
const baseName = numberMatch ? numberMatch[2] : project.name;
|
||||
const existingNumbers: number[] = [];
|
||||
|
||||
// Check for pattern "(number) baseName" in existing projects
|
||||
savedProjects.forEach((p) => {
|
||||
const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
|
||||
if (match && match[2] === baseName) {
|
||||
existingNumbers.push(parseInt(match[1], 10));
|
||||
}
|
||||
});
|
||||
|
||||
const nextNumber =
|
||||
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
|
||||
|
||||
const newProject: TProject = {
|
||||
id: generateUUID(),
|
||||
name: `(${nextNumber}) ${baseName}`,
|
||||
thumbnail: project.thumbnail,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await storageService.saveProject(newProject);
|
||||
await get().loadAllProjects();
|
||||
return newProject.id;
|
||||
} catch (error) {
|
||||
console.error("Failed to duplicate project:", error);
|
||||
toast.error("Failed to duplicate project", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateProjectBackground: async (backgroundColor: string) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
backgroundColor,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject(updatedProject);
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Failed to update project background:", error);
|
||||
toast.error("Failed to update background", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateBackgroundType: async (
|
||||
type: "color" | "blur",
|
||||
options?: { backgroundColor?: string; blurIntensity?: number }
|
||||
) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
backgroundType: type,
|
||||
...(options?.backgroundColor && {
|
||||
backgroundColor: options.backgroundColor,
|
||||
}),
|
||||
...(options?.blurIntensity && { blurIntensity: options.blurIntensity }),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject(updatedProject);
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Failed to update background type:", error);
|
||||
toast.error("Failed to update background", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateProjectFps: async (fps: number) => {
|
||||
const { activeProject } = get();
|
||||
if (!activeProject) return;
|
||||
|
||||
const updatedProject = {
|
||||
...activeProject,
|
||||
fps,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject(updatedProject);
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Failed to update project FPS:", error);
|
||||
toast.error("Failed to update project FPS", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
updateProjectName: (name: string) => {
|
||||
set((state) => ({
|
||||
activeProject: state.activeProject
|
||||
? {
|
||||
...state.activeProject,
|
||||
name,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,12 +1 @@
|
||||
export type BackgroundType = "blur" | "mirror" | "color";
|
||||
|
||||
export interface CanvasSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface CanvasPreset {
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
@ -1,12 +1,6 @@
|
||||
export interface TProject {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnail: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
mediaItems?: string[];
|
||||
backgroundColor?: string;
|
||||
backgroundType?: "color" | "blur";
|
||||
blurIntensity?: number; // in pixels (4, 8, 18)
|
||||
fps?: number;
|
||||
}
|
||||
export interface TProject {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
@ -1,157 +1,29 @@
|
||||
import { MediaType } from "@/stores/media-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
export type TrackType = "media" | "text" | "audio";
|
||||
|
||||
// Base element properties
|
||||
interface BaseTimelineElement {
|
||||
id: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
startTime: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
}
|
||||
|
||||
// Media element that references MediaStore
|
||||
export interface MediaElement extends BaseTimelineElement {
|
||||
type: "media";
|
||||
mediaId: string;
|
||||
}
|
||||
|
||||
// Text element with embedded text data
|
||||
export interface TextElement extends BaseTimelineElement {
|
||||
type: "text";
|
||||
content: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
textAlign: "left" | "center" | "right";
|
||||
fontWeight: "normal" | "bold";
|
||||
fontStyle: "normal" | "italic";
|
||||
textDecoration: "none" | "underline" | "line-through";
|
||||
x: number; // Position relative to canvas center
|
||||
y: number; // Position relative to canvas center
|
||||
rotation: number; // in degrees
|
||||
opacity: number; // 0-1
|
||||
}
|
||||
|
||||
// Typed timeline elements
|
||||
export type TimelineElement = MediaElement | TextElement;
|
||||
|
||||
// Creation types (without id, for addElementToTrack)
|
||||
export type CreateMediaElement = Omit<MediaElement, "id">;
|
||||
export type CreateTextElement = Omit<TextElement, "id">;
|
||||
export type CreateTimelineElement = CreateMediaElement | CreateTextElement;
|
||||
|
||||
export interface TimelineElementProps {
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
isSelected: boolean;
|
||||
onElementMouseDown: (e: React.MouseEvent, element: TimelineElement) => void;
|
||||
onElementClick: (e: React.MouseEvent, element: TimelineElement) => void;
|
||||
}
|
||||
|
||||
export interface ResizeState {
|
||||
elementId: string;
|
||||
side: "left" | "right";
|
||||
startX: number;
|
||||
initialTrimStart: number;
|
||||
initialTrimEnd: number;
|
||||
}
|
||||
|
||||
// Drag data types for type-safe drag and drop
|
||||
export interface MediaItemDragData {
|
||||
id: string;
|
||||
type: MediaType;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TextItemDragData {
|
||||
id: string;
|
||||
type: "text";
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export type DragData = MediaItemDragData | TextItemDragData;
|
||||
|
||||
export interface TimelineTrack {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TrackType;
|
||||
elements: TimelineElement[];
|
||||
muted?: boolean;
|
||||
isMain?: boolean;
|
||||
}
|
||||
|
||||
export function sortTracksByOrder(tracks: TimelineTrack[]): TimelineTrack[] {
|
||||
return [...tracks].sort((a, b) => {
|
||||
// Audio tracks always go to bottom
|
||||
if (a.type === "audio" && b.type !== "audio") return 1;
|
||||
if (b.type === "audio" && a.type !== "audio") return -1;
|
||||
|
||||
// Main track goes above audio but below other tracks
|
||||
if (a.isMain && !b.isMain && b.type !== "audio") return 1;
|
||||
if (b.isMain && !a.isMain && a.type !== "audio") return -1;
|
||||
|
||||
// Within same category, maintain creation order
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function getMainTrack(tracks: TimelineTrack[]): TimelineTrack | null {
|
||||
return tracks.find((track) => track.isMain) || null;
|
||||
}
|
||||
|
||||
export function ensureMainTrack(tracks: TimelineTrack[]): TimelineTrack[] {
|
||||
const hasMainTrack = tracks.some((track) => track.isMain);
|
||||
|
||||
if (!hasMainTrack) {
|
||||
// Create main track if it doesn't exist
|
||||
const mainTrack: TimelineTrack = {
|
||||
id: generateUUID(),
|
||||
name: "Main Track",
|
||||
type: "media",
|
||||
elements: [],
|
||||
muted: false,
|
||||
isMain: true,
|
||||
};
|
||||
return [mainTrack, ...tracks];
|
||||
}
|
||||
|
||||
return tracks;
|
||||
}
|
||||
|
||||
// Timeline validation utilities
|
||||
export function canElementGoOnTrack(
|
||||
elementType: "text" | "media",
|
||||
trackType: TrackType
|
||||
): boolean {
|
||||
if (elementType === "text") {
|
||||
return trackType === "text";
|
||||
} else if (elementType === "media") {
|
||||
return trackType === "media" || trackType === "audio";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function validateElementTrackCompatibility(
|
||||
element: { type: "text" | "media" },
|
||||
track: { type: TrackType }
|
||||
): { isValid: boolean; errorMessage?: string } {
|
||||
const isValid = canElementGoOnTrack(element.type, track.type);
|
||||
|
||||
if (!isValid) {
|
||||
const errorMessage =
|
||||
element.type === "text"
|
||||
? "Text elements can only be placed on text tracks"
|
||||
: "Media elements can only be placed on media or audio tracks";
|
||||
|
||||
return { isValid: false, errorMessage };
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
import { TimelineTrack, TimelineClip } from "@/stores/timeline-store";
|
||||
|
||||
export type TrackType = "video" | "audio" | "effects";
|
||||
|
||||
export interface TimelineClipProps {
|
||||
clip: TimelineClip;
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
isSelected: boolean;
|
||||
onContextMenu: (e: React.MouseEvent, clipId: string) => void;
|
||||
onClipMouseDown: (e: React.MouseEvent, clip: TimelineClip) => void;
|
||||
onClipClick: (e: React.MouseEvent, clip: TimelineClip) => void;
|
||||
}
|
||||
|
||||
export interface ResizeState {
|
||||
clipId: string;
|
||||
side: "left" | "right";
|
||||
startX: number;
|
||||
initialTrimStart: number;
|
||||
initialTrimEnd: number;
|
||||
}
|
||||
|
||||
export interface ContextMenuState {
|
||||
type: "track" | "clip";
|
||||
trackId: string;
|
||||
clipId?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
@ -5,17 +5,11 @@ export default {
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/lib/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/constants/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
screens: {
|
||||
xs: "480px",
|
||||
},
|
||||
fontSize: {
|
||||
base: "0.95rem",
|
||||
xs: "0.80rem",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-inter)", "sans-serif"],
|
||||
@ -71,15 +65,11 @@ export default {
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
},
|
||||
panel: {
|
||||
DEFAULT: "hsl(var(--panel-background))",
|
||||
accent: "hsl(var(--panel-accent))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 8px)",
|
||||
sm: "calc(var(--radius) - 6px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
@ -105,38 +95,7 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("tailwindcss-animate"),
|
||||
function ({
|
||||
addUtilities,
|
||||
}: {
|
||||
addUtilities: (utilities: Record<string, any>) => void;
|
||||
}) {
|
||||
addUtilities({
|
||||
".scrollbar-hidden": {
|
||||
"-ms-overflow-style": "none",
|
||||
"scrollbar-width": "none",
|
||||
"&::-webkit-scrollbar": {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
".scrollbar-x-hidden": {
|
||||
"-ms-overflow-style": "none",
|
||||
"scrollbar-width": "none",
|
||||
"&::-webkit-scrollbar:horizontal": {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
".scrollbar-y-hidden": {
|
||||
"-ms-overflow-style": "none",
|
||||
"scrollbar-width": "none",
|
||||
"&::-webkit-scrollbar:vertical": {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
],
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
future: {
|
||||
hoverOnlyWhenSupported: true,
|
||||
},
|
||||
|
14
bun.lock
14
bun.lock
@ -2,7 +2,6 @@
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "opencut",
|
||||
"dependencies": {
|
||||
"next": "^15.3.4",
|
||||
"wavesurfer.js": "^7.9.8",
|
||||
@ -22,6 +21,7 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@opencut/auth": "workspace:*",
|
||||
"@opencut/db": "workspace:*",
|
||||
"@types/pg": "^8.15.4",
|
||||
"@upstash/ratelimit": "^2.0.5",
|
||||
"@upstash/redis": "^1.35.0",
|
||||
"@vercel/analytics": "^1.4.1",
|
||||
@ -39,7 +39,6 @@
|
||||
"motion": "^12.18.1",
|
||||
"next": "^15.3.4",
|
||||
"next-themes": "^0.4.4",
|
||||
"ollama": "^0.5.16",
|
||||
"pg": "^8.16.2",
|
||||
"radix-ui": "^1.4.2",
|
||||
"react": "^18.2.0",
|
||||
@ -59,7 +58,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/pg": "^8.15.4",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"cross-env": "^7.0.3",
|
||||
@ -105,8 +103,6 @@
|
||||
|
||||
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.18", "", {}, "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA=="],
|
||||
|
||||
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250701.0", "", {}, "sha512-q1bHwe5P7FGy9RkLYOY1kwoZrqUe2Q6XhCPscaxzQc0N7+2pwIZzZzY5iMTTfvmf65UNsadoVxuF+vPVXoAkkQ=="],
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="],
|
||||
@ -415,7 +411,7 @@
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
|
||||
"@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="],
|
||||
|
||||
"@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="],
|
||||
|
||||
@ -693,8 +689,6 @@
|
||||
|
||||
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
|
||||
|
||||
"ollama": ["ollama@0.5.16", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-OEbxxOIUZtdZgOaTPAULo051F5y+Z1vosxEYOoABPnQKeW7i4O8tJNlxCB+xioyoorVqgjkdj+TA1f1Hy2ug/w=="],
|
||||
|
||||
"opencut": ["opencut@workspace:apps/web"],
|
||||
|
||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||
@ -911,8 +905,6 @@
|
||||
|
||||
"wavesurfer.js": ["wavesurfer.js@7.9.8", "", {}, "sha512-Mxz6qRwkSmuWVxLzp0XQ6EzSv1FTvQgMEUJTirLN1Ox76sn0YeyQlI99WuE+B0IuxShPHXIhvEuoBSJdaQs7tA=="],
|
||||
|
||||
"whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
@ -929,8 +921,6 @@
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@types/bun/bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
|
||||
|
||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
@ -52,7 +52,7 @@ services:
|
||||
dockerfile: ./apps/web/Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3100:3000" # app is running on 3000 so we run this at 3100
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://opencut:opencutthegoat@db:5432/opencut
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencut",
|
||||
"packageManager": "bun@1.2.18",
|
||||
"packageManager": "bun@1.2.17",
|
||||
"devDependencies": {
|
||||
"turbo": "^2.5.4"
|
||||
},
|
||||
|
@ -7,7 +7,7 @@ export const auth = betterAuth({
|
||||
provider: "pg",
|
||||
usePlural: true,
|
||||
}),
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
user: {
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
|
@ -19,7 +19,11 @@ function getDb() {
|
||||
}
|
||||
|
||||
// Export a proxy that forwards all calls to the actual db instance
|
||||
export const db = getDb();
|
||||
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
|
||||
get(target, prop) {
|
||||
return getDb()[prop as keyof typeof _db];
|
||||
},
|
||||
});
|
||||
|
||||
// Re-export schema for convenience
|
||||
export * from "./schema";
|
||||
|
@ -5,7 +5,7 @@ export const users = pgTable("users", {
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified")
|
||||
.default(false)
|
||||
.$defaultFn(() => false)
|
||||
.notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at")
|
||||
|
Reference in New Issue
Block a user