2 Commits

Author SHA1 Message Date
99965c0674 Update README.md 2025-06-24 20:04:49 +02:00
87c4bd4c95 Fix rendering issue in README 2025-06-24 20:04:22 +02:00
142 changed files with 4452 additions and 11398 deletions

View File

@ -10,11 +10,6 @@ Thank you for your interest in contributing to OpenCut! This document provides g
4. Install dependencies: `bun install` 4. Install dependencies: `bun install`
5. Start the development server: `bun run dev` 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 ## Development Setup
### Prerequisites ### Prerequisites

31
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us improve
title: '[BUG] '
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@ -1,70 +0,0 @@
name: Bug report
description: Create a report to help us improve
title: '[BUG] '
labels: bug
body:
- type: input
id: Platform
attributes:
label: Platform
description: Please enter the platform on which you encountered the bug.
placeholder: e.g. Windows 11, Ubuntu 14.04
validations:
required: true
- type: input
id: Browser
attributes:
label: Browser
description: Please enter the browser on which you encountered the bug.
placeholder: e.g. Chrome 137, Firefox 137, Safari 17
validations:
required: true
- type: textarea
id: current-behavior
attributes:
label: Current Behavior
description: A concise description of what you're experiencing.
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: false
- type: dropdown
id: recurrence-probability
attributes:
label: Recurrence Probability
description: How often does this bug occur?
options:
- Always
- Usually
- Sometimes
- Seldom
default: 0
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false

View File

@ -0,0 +1,19 @@
---
name: Feature request
about: Suggest an idea for this project
title: '[FEATURE] '
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@ -1,42 +0,0 @@
name: Feature request
description: Suggest an idea for OpenCut
title: '[FEATURE] '
labels: enhancement
body:
- type: markdown
attributes:
value: Please make sure that no duplicated issues has already been delivered.
- type: textarea
id: problem
attributes:
label: Problem
placeholder: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
validations:
required: true
- type: textarea
id: solution
attributes:
label: Solution
placeholder: Describe the solution you'd like.
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternative
attributes:
label: Alternative
placeholder: Describe alternatives you've considered.
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false

View File

@ -31,13 +31,13 @@ jobs:
- name: Install Bun - name: Install Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
with: with:
bun-version: 1.2.18 bun-version: 1.2.2
- name: Cache Bun modules - name: Cache Bun modules
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-1.2.18-${{ hashFiles('apps/web/bun.lock') }} key: ${{ runner.os }}-bun-${{ hashFiles('apps/web/bun.lock') }}
- name: Install dependencies - name: Install dependencies
working-directory: apps/web working-directory: apps/web

5
.gitignore vendored
View File

@ -28,8 +28,3 @@ node_modules
.turbo .turbo
*.env *.env
# cursor
.cursor/
bun.lockb

133
README.md
View File

@ -1,11 +1,13 @@
<table width="100%"> <table>
<tr> <tr>
<td align="left" width="120"> <td width="130">
<img src="apps/web/public/logo.png" alt="OpenCut Logo" width="100" /> <img src="apps/web/public/logo.png" width="130" height="130">
</td> </td>
<td align="right"> <td align="right">
<h1>OpenCut <span style="font-size: 0.7em; font-weight: normal;">(prev AppCut)</span></h1>
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3> # OpenCut (prev AppCut)
### A free, open-source video editor for web, desktop, and mobile.
</td> </td>
</tr> </tr>
</table> </table>
@ -22,7 +24,6 @@
- Multi-track support - Multi-track support
- Real-time preview - Real-time preview
- No watermarks or subscriptions - No watermarks or subscriptions
- Analytics provided by [Databuddy](https://www.databuddy.cc?utm_source=opencut), 100% Anonymized & Non-invasive.
## Project Structure ## Project Structure
@ -45,122 +46,72 @@ Before you begin, ensure you have the following installed on your system:
### Setup ### Setup
## Getting Started 1. **Clone the repository**
```bash
1. Fork the repository git clone <repo-url>
2. Clone your fork locally cd OpenCut
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:
## Development Setup
### Prerequisites
- Node.js 18+
- Bun (latest version)
- Docker (for local database)
### Local Development
1. Start the database and Redis services:
```bash ```bash
# From project root
docker-compose up -d docker-compose up -d
``` ```
2. Navigate to the web app directory: 3. **Set up environment variables**
Navigate into the web app's directory and create a `.env` file from the example:
```bash ```bash
cd apps/web cd apps/web
cp .env.example .env
``` ```
*The default values in the `.env` file should work for local development.*
3. Copy `.env.example` to `.env.local`: 4. **Install dependencies**
Install the project dependencies using `bun` (recommended) or `npm`.
```bash ```bash
# Unix/Linux/Mac # With bun
cp .env.example .env.local bun install
# Windows Command Prompt # Or with npm
copy .env.example .env.local npm install
# Windows PowerShell
Copy-Item .env.example .env.local
``` ```
4. Configure required environment variables in `.env.local`: 5. **Run database migrations**
Apply the database schema to your local database:
**Required Variables:**
```bash ```bash
# Database (matches docker-compose.yaml) # With bun
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut" bun run db:push:local
# Generate a secure secret for Better Auth # Or with npm
BETTER_AUTH_SECRET="your-generated-secret-here" npm run db:push:local
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:** 6. **Start the development server**
```bash ```bash
# Unix/Linux/Mac # With bun
openssl rand -base64 32 bun run dev
# Windows PowerShell (simple method) # Or with npm
[System.Web.Security.Membership]::GeneratePassword(32, 0) npm run dev
# 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)
The application will be available at [http://localhost:3000](http://localhost:3000). The application will be available at [http://localhost:3000](http://localhost:3000).
=======
## Contributing ## 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. 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 - Fork the repo and clone locally
- Follow the setup instructions in CONTRIBUTING.md - Follow the setup instructions in CONTRIBUTING.md
- Create a feature branch and submit a PR - 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.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FOpenCut-app%2FOpenCut&project-name=opencut&repository-name=opencut)
## License ## License
[MIT LICENSE](LICENSE) [MIT LICENSE](LICENSE)
---
![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date)

View File

@ -1,45 +1,30 @@
FROM oven/bun:alpine AS base FROM oven/bun:latest AS base
# Install dependencies and build the application
FROM base AS builder
# Install dependencies
FROM base AS deps
WORKDIR /app WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY package.json package.json # Build the application
COPY bun.lock bun.lock FROM base AS builder
COPY turbo.json turbo.json WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY apps/web/package.json apps/web/package.json COPY . .
COPY packages/db/package.json packages/db/package.json
COPY packages/auth/package.json packages/auth/package.json
RUN bun install
COPY apps/web/ apps/web/
COPY packages/db/ packages/db/
COPY packages/auth/ packages/auth/
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
WORKDIR /app/apps/web
RUN bun run build RUN bun run build
# Production image # Production image
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN chown nextjs:nodejs apps
USER nextjs USER nextjs
@ -48,4 +33,4 @@ EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
CMD ["bun", "apps/web/server.js"] CMD ["bun", "server.js"]

View File

@ -13,7 +13,7 @@ if (!process.env.DATABASE_URL) {
} }
export default { export default {
schema: "../../packages/db/src/schema.ts", schema: "./src/lib/db/schema.ts",
dialect: "postgresql", dialect: "postgresql",
dbCredentials: { dbCredentials: {
url: process.env.DATABASE_URL, url: process.env.DATABASE_URL,

View File

@ -1,8 +1,11 @@
# Next.js plugin [build]
base = "../.."
command = "bun install && bunx turbo build --filter=opencut"
publish = "apps/web/.next"
[[plugins]] [[plugins]]
package = "@netlify/plugin-nextjs" package = "@netlify/plugin-nextjs"
# Redirects for domain migration
[[redirects]] [[redirects]]
from = "https://appcut.app/*" from = "https://appcut.app/*"
to = "https://opencut.app/:splat" to = "https://opencut.app/:splat"

View File

@ -6,19 +6,6 @@ const nextConfig: NextConfig = {
}, },
reactStrictMode: true, reactStrictMode: true,
productionBrowserSourceMaps: true, productionBrowserSourceMaps: true,
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "plus.unsplash.com",
},
{
protocol: "https",
hostname: "images.unsplash.com",
},
],
},
}; };
export default nextConfig; export default nextConfig;

View File

@ -2,9 +2,9 @@
"name": "opencut", "name": "opencut",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "bun@1.2.18", "packageManager": "bun@1.2.17",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
@ -21,6 +21,7 @@
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@opencut/auth": "workspace:*", "@opencut/auth": "workspace:*",
"@opencut/db": "workspace:*", "@opencut/db": "workspace:*",
"@types/pg": "^8.15.4",
"@upstash/ratelimit": "^2.0.5", "@upstash/ratelimit": "^2.0.5",
"@upstash/redis": "^1.35.0", "@upstash/redis": "^1.35.0",
"@vercel/analytics": "^1.4.1", "@vercel/analytics": "^1.4.1",
@ -56,7 +57,6 @@
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/pg": "^8.15.4",
"@types/bun": "latest", "@types/bun": "latest",
"@types/react": "^18.2.48", "@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="/icons/ms-icon-70x70.png"/>
<square150x150logo src="/icons/ms-icon-150x150.png"/>
<square310x310logo src="/icons/ms-icon-310x310.png"/>
<TileColor>#ffffff</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

View File

@ -1,10 +0,0 @@
<svg width="459" height="77" viewBox="0 0 459 77" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="1.15625" width="458" height="75" rx="15.5" fill="#101010"/>
<rect x="0.5" y="1.15625" width="458" height="75" rx="15.5" stroke="#FFCC00"/>
<rect x="0.5" y="1.15625" width="31" height="75" rx="15.5" fill="#2A2A2A"/>
<rect x="0.5" y="1.15625" width="31" height="75" rx="15.5" stroke="#FFCC00"/>
<rect x="13" y="20.6562" width="6" height="36" rx="3" fill="#FFCC00"/>
<rect x="427.5" y="1.15625" width="31" height="75" rx="15.5" fill="#2A2A2A"/>
<rect x="427.5" y="1.15625" width="31" height="75" rx="15.5" stroke="#FFCC00"/>
<rect x="440" y="20.6562" width="6" height="36" rx="3" fill="#FFCC00"/>
</svg>

Before

Width:  |  Height:  |  Size: 716 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 741 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 802 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 906 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 747 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 906 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

View File

@ -1,10 +0,0 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_10_2)">
<path d="M32 9.37305V22.627L22.627 32H9.37305L0 22.627V9.37305L9.37305 0H22.627L32 9.37305ZM8 8V24H24V8H8Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_10_2">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 362 B

View File

@ -1,44 +0,0 @@
{
"name": "OpenCut",
"description": "A simple but powerful video editor that gets the job done. In your browser.",
"display": "standalone",
"start_url": "/",
"icons": [
{
"src": "/icons/android-icon-36x36.png",
"sizes": "36x36",
"type": "image\/png",
"density": "0.75"
},
{
"src": "/icons/android-icon-48x48.png",
"sizes": "48x48",
"type": "image\/png",
"density": "1.0"
},
{
"src": "/icons/android-icon-72x72.png",
"sizes": "72x72",
"type": "image\/png",
"density": "1.5"
},
{
"src": "/icons/android-icon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
},
{
"src": "/icons/android-icon-144x144.png",
"sizes": "144x144",
"type": "image\/png",
"density": "3.0"
},
{
"src": "/icons/android-icon-192x192.png",
"sizes": "192x192",
"type": "image\/png",
"density": "4.0"
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { signIn } from "@opencut/auth/client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@ -9,7 +10,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { memo, Suspense } from "react"; import { Suspense, useState } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@ -17,47 +18,51 @@ import Link from "next/link";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ArrowLeft, Loader2 } from "lucide-react"; import { ArrowLeft, Loader2 } from "lucide-react";
import { GoogleIcon } from "@/components/icons"; import { GoogleIcon } from "@/components/icons";
import { useLogin } from "@/hooks/auth/useLogin";
const LoginPage = () => { function LoginForm() {
const router = useRouter(); const router = useRouter();
const { 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, email,
setEmail,
password, password,
setPassword, });
error,
isAnyLoading, if (error) {
isEmailLoading, setError(error.message || "An unexpected error occurred.");
isGoogleLoading, setIsEmailLoading(false);
handleLogin, return;
handleGoogleLogin, }
} = useLogin();
router.push("/editor");
};
const handleGoogleLogin = async () => {
setError(null);
setIsGoogleLoading(true);
try {
await signIn.social({
provider: "google",
});
router.push("/editor");
} catch (error) {
setError("Failed to sign in with Google. Please try again.");
setIsGoogleLoading(false);
}
};
const isAnyLoading = isEmailLoading || isGoogleLoading;
return ( return (
<div className="flex h-screen items-center justify-center relative">
<Button
variant="text"
onClick={() => router.back()}
className="absolute top-6 left-6"
>
<ArrowLeft className="h-5 w-5" /> Back
</Button>
<Card className="w-[400px] shadow-lg border-0">
<CardHeader className="text-center pb-4">
<CardTitle className="text-2xl font-semibold">Welcome back</CardTitle>
<CardDescription className="text-base">
Sign in to your account to continue
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Suspense
fallback={
<div className="text-center">
<Loader2 className="animate-spin" />
</div>
}
>
<div className="flex flex-col space-y-6"> <div className="flex flex-col space-y-6">
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
@ -123,6 +128,38 @@ const LoginPage = () => {
</Button> </Button>
</div> </div>
</div> </div>
);
}
export default function LoginPage() {
const router = useRouter();
return (
<div className="flex h-screen items-center justify-center relative">
<Button
variant="text"
onClick={() => router.back()}
className="absolute top-6 left-6"
>
<ArrowLeft className="h-5 w-5" /> Back
</Button>
<Card className="w-[400px] shadow-lg border-0">
<CardHeader className="text-center pb-4">
<CardTitle className="text-2xl font-semibold">Welcome back</CardTitle>
<CardDescription className="text-base">
Sign in to your account to continue
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Suspense
fallback={
<div className="text-center">
<Loader2 className="animate-spin" />
</div>
}
>
<LoginForm />
</Suspense>
<div className="mt-6 text-center text-sm"> <div className="mt-6 text-center text-sm">
Don't have an account?{" "} Don't have an account?{" "}
<Link <Link
@ -132,11 +169,8 @@ const LoginPage = () => {
Sign up Sign up
</Link> </Link>
</div> </div>
</Suspense>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
} }
export default memo(LoginPage);

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { signUp, signIn } from "@opencut/auth/client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@ -9,59 +10,62 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { memo, Suspense } from "react"; import { Suspense, useState } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import Link from "next/link"; import Link from "next/link";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 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 { GoogleIcon } from "@/components/icons";
import { useSignUp } from "@/hooks/auth/useSignUp";
const SignUpPage = () => { function SignUpForm() {
const router = useRouter(); const router = useRouter();
const { 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, name,
setName,
email, email,
setEmail,
password, password,
setPassword, });
error,
isAnyLoading, if (error) {
isEmailLoading, setError(error.message || "An unexpected error occurred.");
isGoogleLoading, setIsEmailLoading(false);
handleSignUp, return;
handleGoogleSignUp, }
} = useSignUp();
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 ( return (
<div className="flex h-screen items-center justify-center relative">
<Button
variant="text"
onClick={() => router.back()}
className="absolute top-6 left-6"
>
<ArrowLeft className="h-5 w-5" /> Back
</Button>
<Card className="w-[400px] shadow-lg border-0">
<CardHeader className="text-center pb-4">
<CardTitle className="text-2xl font-semibold">
Create your account
</CardTitle>
<CardDescription className="text-base">
Get started with your free account today
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Suspense
fallback={
<div className="text-center">
<Loader2 className="animate-spin" />
</div>
}
>
<div className="flex flex-col space-y-6"> <div className="flex flex-col space-y-6">
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
@ -69,6 +73,7 @@ const SignUpPage = () => {
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
</Alert> </Alert>
)} )}
<Button <Button
onClick={handleGoogleSignUp} onClick={handleGoogleSignUp}
variant="outline" variant="outline"
@ -82,6 +87,7 @@ const SignUpPage = () => {
)}{" "} )}{" "}
Continue with Google Continue with Google
</Button> </Button>
<div className="relative"> <div className="relative">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<Separator className="w-full" /> <Separator className="w-full" />
@ -92,6 +98,7 @@ const SignUpPage = () => {
</span> </span>
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Full Name</Label> <Label htmlFor="name">Full Name</Label>
@ -143,6 +150,41 @@ const SignUpPage = () => {
</Button> </Button>
</div> </div>
</div> </div>
);
}
export default function SignUpPage() {
const router = useRouter();
return (
<div className="flex h-screen items-center justify-center relative">
<Button
variant="text"
onClick={() => router.back()}
className="absolute top-6 left-6"
>
<ArrowLeft className="h-5 w-5" /> Back
</Button>
<Card className="w-[400px] shadow-lg border-0">
<CardHeader className="text-center pb-4">
<CardTitle className="text-2xl font-semibold">
Create your account
</CardTitle>
<CardDescription className="text-base">
Get started with your free account today
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Suspense
fallback={
<div className="text-center">
<Loader2 className="animate-spin" />
</div>
}
>
<SignUpForm />
</Suspense>
<div className="mt-6 text-center text-sm"> <div className="mt-6 text-center text-sm">
Already have an account?{" "} Already have an account?{" "}
<Link <Link
@ -152,11 +194,8 @@ const SignUpPage = () => {
Sign in Sign in
</Link> </Link>
</div> </div>
</Suspense>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
} }
export default memo(SignUpPage);

View File

@ -1,5 +0,0 @@
import { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
return new Response("OK", { status: 200 });
}

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { db, eq } from "@opencut/db"; import { db } from "@opencut/db";
import { waitlist } from "@opencut/db/schema"; import { waitlist } from "@opencut/db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { waitlistRateLimit } from "@/lib/rate-limit"; import { waitlistRateLimit } from "@/lib/rate-limit";
import { z } from "zod"; import { z } from "zod";

View File

@ -6,7 +6,6 @@ import { Button } from "@/components/ui/button";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { GithubIcon } from "@/components/icons"; import { GithubIcon } from "@/components/icons";
import { Badge } from "@/components/ui/badge";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Contributors - OpenCut", title: "Contributors - OpenCut",
@ -47,10 +46,10 @@ async function getContributors(): Promise<Contributor[]> {
return []; return [];
} }
const contributors = (await response.json()) as Contributor[]; const contributors = await response.json();
const filteredContributors = contributors.filter( const filteredContributors = contributors.filter(
(contributor: Contributor) => contributor.type === "User" (contributor: any) => contributor.type === "User"
); );
return filteredContributors; return filteredContributors;
@ -62,8 +61,8 @@ async function getContributors(): Promise<Contributor[]> {
export default async function ContributorsPage() { export default async function ContributorsPage() {
const contributors = await getContributors(); const contributors = await getContributors();
const topContributors = contributors.slice(0, 2); const topContributor = contributors[0];
const otherContributors = contributors.slice(2); const otherContributors = contributors.slice(1);
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
@ -78,15 +77,10 @@ export default async function ContributorsPage() {
<div className="relative container mx-auto px-4 py-16"> <div className="relative container mx-auto px-4 py-16">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="text-center mb-20"> <div className="text-center mb-20">
<Link <div className="inline-flex items-center gap-2 bg-muted/50 text-muted-foreground px-3 py-1 rounded-full text-sm mb-6">
href={"https://github.com/OpenCut-app/OpenCut"}
target="_blank"
>
<Badge variant="secondary" className="gap-2 mb-6">
<GithubIcon className="h-3 w-3" /> <GithubIcon className="h-3 w-3" />
Open Source Open Source
</Badge> </div>
</Link>
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6"> <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
Contributors Contributors
</h1> </h1>
@ -111,25 +105,22 @@ export default async function ContributorsPage() {
</div> </div>
</div> </div>
{topContributors.length > 0 && ( {topContributor && (
<div className="mb-20"> <div className="mb-20">
<div className="text-center mb-12"> <div className="text-center mb-12">
<h2 className="text-2xl font-semibold mb-2"> <h2 className="text-2xl font-semibold mb-2">
Top Contributors Top Contributor
</h2> </h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Leading the way in contributions Leading the way in contributions
</p> </p>
</div> </div>
<div className="flex flex-col md:flex-row gap-6 justify-center max-w-4xl mx-auto">
{topContributors.map((contributor, index) => (
<Link <Link
key={contributor.id} href={topContributor.html_url}
href={contributor.html_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="group block flex-1" className="group block"
> >
<div className="relative mx-auto max-w-md"> <div className="relative mx-auto max-w-md">
<div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" /> <div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" />
@ -138,20 +129,23 @@ export default async function ContributorsPage() {
<div className="relative mb-6"> <div className="relative mb-6">
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl"> <Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
<AvatarImage <AvatarImage
src={contributor.avatar_url} src={topContributor.avatar_url}
alt={`${contributor.login}'s avatar`} alt={`${topContributor.login}'s avatar`}
/> />
<AvatarFallback className="text-lg font-semibold"> <AvatarFallback className="text-lg font-semibold">
{contributor.login.charAt(0).toUpperCase()} {topContributor.login.charAt(0).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </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">
1
</div>
</div> </div>
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors"> <h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
{contributor.login} {topContributor.login}
</h3> </h3>
<div className="flex items-center justify-center gap-2 text-muted-foreground"> <div className="flex items-center justify-center gap-2 text-muted-foreground">
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{contributor.contributions} {topContributor.contributions}
</span> </span>
<span>contributions</span> <span>contributions</span>
</div> </div>
@ -159,8 +153,6 @@ export default async function ContributorsPage() {
</Card> </Card>
</div> </div>
</Link> </Link>
))}
</div>
</div> </div>
)} )}
@ -175,7 +167,7 @@ export default async function ContributorsPage() {
</p> </p>
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{otherContributors.map((contributor, index) => ( {otherContributors.map((contributor, index) => (
<Link <Link
key={contributor.id} key={contributor.id}
@ -187,8 +179,8 @@ export default async function ContributorsPage() {
animationDelay: `${index * 50}ms`, animationDelay: `${index * 50}ms`,
}} }}
> >
<div className="text-center p-2 rounded-xl transition-all duration-300 hover:opacity-50"> <div className="text-center p-4 rounded-xl hover:bg-muted/50 transition-all duration-300 group-hover:scale-105">
<Avatar className="h-16 w-16 mx-auto mb-3"> <Avatar className="h-16 w-16 mx-auto mb-3 ring-2 ring-transparent group-hover:ring-muted-foreground/20 transition-all duration-300">
<AvatarImage <AvatarImage
src={contributor.avatar_url} src={contributor.avatar_url}
alt={`${contributor.login}'s avatar`} alt={`${contributor.login}'s avatar`}

View File

@ -0,0 +1,4 @@
/* Prevent scroll jumping on Mac devices when using the editor */
body {
overflow: hidden;
}

View File

@ -1,16 +1,16 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { useParams } from "next/navigation"; import "./editor.css";
import { import {
ResizablePanelGroup, ResizablePanelGroup,
ResizablePanel, ResizablePanel,
ResizableHandle, ResizableHandle,
} from "../../../components/ui/resizable"; } from "../../components/ui/resizable";
import { MediaPanel } from "../../../components/editor/media-panel"; import { MediaPanel } from "../../components/editor/media-panel";
import { PropertiesPanel } from "../../../components/editor/properties-panel"; // import { PropertiesPanel } from "../../components/editor/properties-panel";
import { Timeline } from "../../../components/editor/timeline"; import { Timeline } from "../../components/editor/timeline";
import { PreviewPanel } from "../../../components/editor/preview-panel"; import { PreviewPanel } from "../../components/editor/preview-panel";
import { EditorHeader } from "@/components/editor-header"; import { EditorHeader } from "@/components/editor-header";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useProjectStore } from "@/stores/project-store"; import { useProjectStore } from "@/stores/project-store";
@ -21,47 +21,32 @@ export default function Editor() {
const { const {
toolsPanel, toolsPanel,
previewPanel, previewPanel,
propertiesPanel,
mainContent, mainContent,
timeline, timeline,
setToolsPanel, setToolsPanel,
setPreviewPanel, setPreviewPanel,
setPropertiesPanel,
setMainContent, setMainContent,
setTimeline, setTimeline,
propertiesPanel,
setPropertiesPanel,
} = usePanelStore(); } = usePanelStore();
const { activeProject, loadProject, createNewProject } = useProjectStore(); const { activeProject, createNewProject } = useProjectStore();
const params = useParams();
const projectId = params.project_id as string;
usePlaybackControls(); usePlaybackControls();
useEffect(() => { useEffect(() => {
const initializeProject = async () => { if (!activeProject) {
if (projectId && (!activeProject || activeProject.id !== projectId)) { createNewProject("Untitled Project");
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");
} }
} }, [activeProject, createNewProject]);
};
initializeProject();
}, [projectId, activeProject, loadProject, createNewProject]);
return ( return (
<EditorProvider> <EditorProvider>
<div className="h-screen w-screen flex flex-col bg-background overflow-hidden"> <div className="h-screen w-screen flex flex-col bg-background overflow-hidden">
<EditorHeader /> <EditorHeader />
<div className="flex-1 min-h-0 min-w-0"> <div className="flex-1 min-h-0 min-w-0">
<ResizablePanelGroup <ResizablePanelGroup direction="vertical" className="h-full w-full">
direction="vertical"
className="h-full w-full gap-[0.18rem]"
>
<ResizablePanel <ResizablePanel
defaultSize={mainContent} defaultSize={mainContent}
minSize={30} minSize={30}
@ -70,10 +55,7 @@ export default function Editor() {
className="min-h-0" className="min-h-0"
> >
{/* Main content area */} {/* Main content area */}
<ResizablePanelGroup <ResizablePanelGroup direction="horizontal" className="h-full w-full">
direction="horizontal"
className="h-full w-full gap-[0.19rem] px-2"
>
{/* Tools Panel */} {/* Tools Panel */}
<ResizablePanel <ResizablePanel
defaultSize={toolsPanel} defaultSize={toolsPanel}
@ -99,7 +81,8 @@ export default function Editor() {
<ResizableHandle withHandle /> <ResizableHandle withHandle />
<ResizablePanel {/* Properties Panel - Hidden for now but ready */}
{/* <ResizablePanel
defaultSize={propertiesPanel} defaultSize={propertiesPanel}
minSize={15} minSize={15}
maxSize={40} maxSize={40}
@ -107,7 +90,7 @@ export default function Editor() {
className="min-w-0" className="min-w-0"
> >
<PropertiesPanel /> <PropertiesPanel />
</ResizablePanel> </ResizablePanel> */}
</ResizablePanelGroup> </ResizablePanelGroup>
</ResizablePanel> </ResizablePanel>
@ -119,7 +102,7 @@ export default function Editor() {
minSize={15} minSize={15}
maxSize={70} maxSize={70}
onResize={setTimeline} onResize={setTimeline}
className="min-h-0 px-2 pb-2" className="min-h-0"
> >
<Timeline /> <Timeline />
</ResizablePanel> </ResizablePanel>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -39,13 +39,13 @@
--sidebar-ring: 0 0% 3.9%; --sidebar-ring: 0 0% 3.9%;
} }
.dark { .dark {
--background: 0 0% 4%; --background: 0 0% 8%;
--foreground: 0 0% 89%; --foreground: 0 0% 98%;
--card: 0 0% 14.9%; --card: 0 0% 3.9%;
--card-foreground: 0 0% 98%; --card-foreground: 0 0% 98%;
--popover: 0 0% 14.9%; --popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%; --popover-foreground: 0 0% 98%;
--primary: 180 95% 40%; --primary: 0 0% 98%;
--primary-foreground: 0 0% 9%; --primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%; --secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%; --secondary-foreground: 0 0% 98%;
@ -55,7 +55,7 @@
--accent-foreground: 0 0% 98%; --accent-foreground: 0 0% 98%;
--destructive: 0 100% 60%; --destructive: 0 100% 60%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--border: 0 0% 17%; --border: 0 0% 14.9%;
--input: 0 0% 14.9%; --input: 0 0% 14.9%;
--ring: 0 0% 83.1%; --ring: 0 0% 83.1%;
--chart-1: 220 70% 50%; --chart-1: 220 70% 50%;
@ -71,8 +71,6 @@
--sidebar-accent-foreground: 0 0% 98%; --sidebar-accent-foreground: 0 0% 98%;
--sidebar-border: 0 0% 14.9%; --sidebar-border: 0 0% 14.9%;
--sidebar-ring: 0 0% 83.1%; --sidebar-ring: 0 0% 83.1%;
--panel-background: 0 0% 11%;
--panel-accent: 0 0% 15%;
} }
} }
@ -82,7 +80,5 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
/* Prevent back/forward swipe */
overscroll-behavior-x: contain;
} }
} }

View File

@ -1,15 +1,22 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import { Analytics } from "@vercel/analytics/react"; import { Analytics } from "@vercel/analytics/react";
import Script from "next/script"; import Script from "next/script";
import "./globals.css"; import "./globals.css";
import { Toaster } from "../components/ui/sonner"; import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip"; 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";
export const metadata = baseMetaData; const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
export const metadata: Metadata = {
title: "OpenCut",
description:
"A simple but powerful video editor that gets the job done. In your browser.",
};
export default function RootLayout({ export default function RootLayout({
children, children,
@ -18,23 +25,21 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={`${defaultFont.className} font-sans antialiased`}> <body className={`${inter.variable} font-sans antialiased`}>
<ThemeProvider attribute="class" forcedTheme="dark"> <ThemeProvider attribute="class" forcedTheme="dark" enableSystem>
<TooltipProvider> <TooltipProvider>
<StorageProvider>{children}</StorageProvider> {children}
<Analytics /> <Analytics />
<Toaster /> <Toaster />
<DevelopmentDebug />
<Script <Script
src="https://cdn.databuddy.cc/databuddy.js" src="https://app.databuddy.cc/databuddy.js"
strategy="afterInteractive" strategy="afterInteractive"
async async
data-client-id="UP-Wcoy5arxFeK7oyjMMZ" data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
data-track-attributes={false} data-track-attributes={true}
data-track-errors={true} data-track-errors={true}
data-track-outgoing-links={false} data-track-outgoing-links={true}
data-track-web-vitals={false} data-track-web-vitals={true}
data-track-sessions={false}
/> />
</TooltipProvider> </TooltipProvider>
</ThemeProvider> </ThemeProvider>

View File

@ -1,66 +0,0 @@
import { Metadata } from "next";
const title = "OpenCut";
const description = "A simple but powerful video editor that gets the job done. In your browser.";
const openGraphImageUrl = "https://opencut.app/opengraph-image.jpg";
const twitterImageUrl = "/opengraph-image.jpg";
export const baseMetaData: Metadata = {
title: title,
description: description,
openGraph: {
title: title,
description: description,
url: "https://opencut.app",
siteName: "OpenCut",
locale: "en_US",
type: "website",
images: [
{
url: openGraphImageUrl,
width: 1200,
height: 630,
alt: "OpenCut",
},
],
},
twitter: {
card: "summary_large_image",
title: title,
description: description,
creator: "@opencutapp",
images: [twitterImageUrl],
},
robots: {
index: true,
follow: true,
},
icons: {
icon: [
{ url: "/favicon.ico" },
{ url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" },
{ url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" },
{ url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" },
],
apple: [
{ url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" },
{ url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" },
{ url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" },
{ url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" },
{ url: "/icons/apple-icon-114x114.png", sizes: "114x114", type: "image/png" },
{ url: "/icons/apple-icon-120x120.png", sizes: "120x120", type: "image/png" },
{ url: "/icons/apple-icon-144x144.png", sizes: "144x144", type: "image/png" },
{ url: "/icons/apple-icon-152x152.png", sizes: "152x152", type: "image/png" },
{ url: "/icons/apple-icon-180x180.png", sizes: "180x180", type: "image/png" },
],
shortcut: ["/favicon.ico"]
},
appleWebApp: {
capable: true,
title: title,
},
manifest: "/manifest.json",
other: {
"msapplication-config": "/browserconfig.xml"
}
};

View File

@ -1,6 +1,5 @@
import { Hero } from "@/components/landing/hero"; import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header"; import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
import { getWaitlistCount } from "@/lib/waitlist"; import { getWaitlistCount } from "@/lib/waitlist";
// Force dynamic rendering so waitlist count updates in real-time // Force dynamic rendering so waitlist count updates in real-time
@ -13,7 +12,6 @@ export default async function Home() {
<div> <div>
<Header /> <Header />
<Hero signupCount={signupCount} /> <Hero signupCount={signupCount} />
<Footer />
</div> </div>
); );
} }

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -0,0 +1,398 @@
"use client";
import { useRouter } from "next/navigation";
import { signUp, signIn } from "@opencut/auth/client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ArrowLeft, Loader2 } from "lucide-react";
import { GoogleIcon } from "@/components/icons";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
// Zod schemas
const loginSchema = z.object({
email: z.string().email("Please enter a valid email address"),
password: z.string().min(1, "Password is required"),
});
const signupSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Please enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
type LoginFormData = z.infer<typeof loginSchema>;
type SignupFormData = z.infer<typeof signupSchema>;
interface AuthFormProps {
mode: "login" | "signup";
}
const authConfig = {
login: {
title: "Welcome back",
description: "Sign in to your account to continue",
buttonText: "Sign in",
linkText: "Don't have an account?",
linkHref: "/signup",
linkLabel: "Sign up",
successRedirect: "/editor",
},
signup: {
title: "Create your account",
description: "Get started with your free account today",
buttonText: "Create account",
linkText: "Already have an account?",
linkHref: "/login",
linkLabel: "Sign in",
successRedirect: "/login",
},
} as const;
interface AuthFormContentProps {
error: string | null;
setError: (error: string | null) => void;
isGoogleLoading: boolean;
config: typeof authConfig.login | typeof authConfig.signup;
router: ReturnType<typeof useRouter>;
}
function LoginFormContent({
error,
setError,
isGoogleLoading,
config,
router,
}: AuthFormContentProps) {
const form = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
});
const { isSubmitting } = form.formState;
const isAnyLoading = isSubmitting || isGoogleLoading;
const onSubmit = async (data: LoginFormData) => {
setError(null);
try {
const { error } = await signIn.email({
email: data.email,
password: data.password,
});
if (error) {
setError(error.message || "An unexpected error occurred.");
return;
}
router.push(config.successRedirect);
} catch (error) {
setError("An unexpected error occurred. Please try again.");
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="m@example.com"
disabled={isAnyLoading}
className="h-11"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
disabled={isAnyLoading}
className="h-11"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isAnyLoading}
className="w-full h-11"
size="lg"
>
{isSubmitting ? (
<Loader2 className="animate-spin" />
) : (
config.buttonText
)}
</Button>
</form>
</Form>
);
}
function SignupFormContent({
error,
setError,
isGoogleLoading,
config,
router,
}: AuthFormContentProps) {
const form = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
defaultValues: { email: "", password: "", name: "" },
});
const { isSubmitting } = form.formState;
const isAnyLoading = isSubmitting || isGoogleLoading;
const onSubmit = async (data: SignupFormData) => {
setError(null);
try {
const { error } = await signUp.email({
name: data.name,
email: data.email,
password: data.password,
});
if (error) {
setError(error.message || "An unexpected error occurred.");
return;
}
router.push(config.successRedirect);
} catch (error) {
setError("An unexpected error occurred. Please try again.");
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input
placeholder="John Doe"
disabled={isAnyLoading}
className="h-11"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="m@example.com"
disabled={isAnyLoading}
className="h-11"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Create a strong password"
disabled={isAnyLoading}
className="h-11"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isAnyLoading}
className="w-full h-11"
size="lg"
>
{isSubmitting ? (
<Loader2 className="animate-spin" />
) : (
config.buttonText
)}
</Button>
</form>
</Form>
);
}
export function AuthForm({ mode }: AuthFormProps) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const config = authConfig[mode];
const handleGoogleAuth = async () => {
setError(null);
setIsGoogleLoading(true);
try {
await signIn.social({
provider: "google",
});
router.push(config.successRedirect);
} catch (error) {
setError(
`Failed to ${mode === "login" ? "sign in" : "sign up"} with Google. Please try again.`
);
setIsGoogleLoading(false);
}
};
return (
<div className="flex h-screen items-center justify-center relative">
<Button
variant="text"
onClick={() => router.back()}
className="absolute top-6 left-6"
>
<ArrowLeft className="h-5 w-5" /> Back
</Button>
<Card className="w-[400px] shadow-lg border-0">
<CardHeader className="text-center pb-4">
<CardTitle className="text-2xl font-semibold">
{config.title}
</CardTitle>
<CardDescription className="text-base">
{config.description}
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-col space-y-6">
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
onClick={handleGoogleAuth}
variant="outline"
size="lg"
disabled={isGoogleLoading}
>
{isGoogleLoading ? (
<Loader2 className="animate-spin" />
) : (
<GoogleIcon />
)}
Continue with Google
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator className="w-full" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
{mode === "login" ? (
<LoginFormContent
error={error}
setError={setError}
isGoogleLoading={isGoogleLoading}
config={config}
router={router}
/>
) : (
<SignupFormContent
error={error}
setError={setError}
isGoogleLoading={isGoogleLoading}
config={config}
router={router}
/>
)}
</div>
<div className="mt-6 text-center text-sm">
{config.linkText}{" "}
<Link
href={config.linkHref}
className="font-medium text-primary underline-offset-4 hover:underline"
>
{config.linkLabel}
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -1,108 +0,0 @@
"use client";
import { useTimelineStore } 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;
track: TimelineTrack;
mediaItem: MediaItem | null;
}
export function DevelopmentDebug() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { currentTime } = usePlaybackStore();
const [showDebug, setShowDebug] = useState(false);
// Don't render anything in production
if (!SHOW_DEBUG_INFO) return null;
// Get active elements at current time
const getActiveElements = (): ActiveElement[] => {
const activeElements: ActiveElement[] = [];
tracks.forEach((track) => {
track.elements.forEach((element) => {
const elementStart = element.startTime;
const elementEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime >= elementStart && currentTime < elementEnd) {
const mediaItem =
element.type === "media"
? mediaItems.find((item) => item.id === element.mediaId) || null
: null; // Text elements don't have media items
activeElements.push({ element, track, mediaItem });
}
});
});
return activeElements;
};
const activeElements = getActiveElements();
return (
<div className="fixed bottom-4 right-4 z-50">
<div className="flex flex-col items-end gap-2">
{/* Toggle Button */}
<Button
variant="outline"
size="sm"
onClick={() => setShowDebug(!showDebug)}
className="text-xs backdrop-blur-md bg-background/80 border-border/50"
>
Debug {showDebug ? "ON" : "OFF"}
</Button>
{/* Debug Info Panel */}
{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})
</div>
<div className="space-y-1 max-h-40 overflow-y-auto">
{activeElements.map((elementData, index) => (
<div
key={elementData.element.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="text-muted-foreground text-[10px]">
{elementData.element.type === "media"
? elementData.mediaItem?.type || "media"
: "text"}
</div>
</div>
</div>
))}
{activeElements.length === 0 && (
<div className="text-muted-foreground text-xs py-2 text-center">
No active elements
</div>
)}
</div>
<div className="mt-2 pt-2 border-t border-border/30 text-[10px] text-muted-foreground">
Time: {currentTime.toFixed(2)}s
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -3,52 +3,45 @@
import Link from "next/link"; import Link from "next/link";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { ChevronLeft, Download } from "lucide-react"; import { ChevronLeft, Download } from "lucide-react";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
import { HeaderBase } from "./header-base"; import { HeaderBase } from "./header-base";
import { formatTimeCode } from "@/lib/time";
import { useProjectStore } from "@/stores/project-store";
export function EditorHeader() { export function EditorHeader() {
const { getTotalDuration } = useTimelineStore();
const { activeProject } = useProjectStore(); const { activeProject } = useProjectStore();
const { getTotalDuration } = useTimelineStore();
const handleExport = () => { const handleExport = () => {
// TODO: Implement export functionality // TODO: Implement export functionality
console.log("Export project"); 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 = ( const leftContent = (
<div className="flex items-center gap-2">
<Link <Link
href="/projects" href="/"
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity" className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
<span className="text-sm">{activeProject?.name}</span> <span className="text-sm">{activeProject?.name || "Loading..."}</span>
</Link> </Link>
</div>
); );
const centerContent = ( const centerContent = (
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<span> <span>{formatDuration(getTotalDuration())}</span>
{formatTimeCode(
getTotalDuration(),
"HH:MM:SS:FF",
activeProject?.fps || 30
)}
</span>
</div> </div>
); );
const rightContent = ( const rightContent = (
<nav className="flex items-center gap-2"> <nav className="flex items-center gap-2">
<Button <Button size="sm" onClick={handleExport}>
size="sm"
variant="primary"
className="h-7 text-xs"
onClick={handleExport}
>
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
<span className="text-sm">Export</span> <span className="text-sm">Export</span>
</Button> </Button>
@ -60,7 +53,7 @@ export function EditorHeader() {
leftContent={leftContent} leftContent={leftContent}
centerContent={centerContent} centerContent={centerContent}
rightContent={rightContent} rightContent={rightContent}
className="bg-background h-[3.2rem] px-4" className="bg-background border-b"
/> />
); );
} }

View File

@ -1,115 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
interface AudioWaveformProps {
audioUrl: string;
height?: number;
className?: string;
}
const AudioWaveform: React.FC<AudioWaveformProps> = ({
audioUrl,
height = 32,
className = ''
}) => {
const waveformRef = useRef<HTMLDivElement>(null);
const wavesurfer = useRef<WaveSurfer | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let mounted = true;
const initWaveSurfer = async () => {
if (!waveformRef.current || !audioUrl) return;
try {
// Clean up any existing instance
if (wavesurfer.current) {
try {
wavesurfer.current.destroy();
} catch (e) {
// Silently ignore destroy errors
}
wavesurfer.current = null;
}
wavesurfer.current = WaveSurfer.create({
container: waveformRef.current,
waveColor: 'rgba(255, 255, 255, 0.6)',
progressColor: 'rgba(255, 255, 255, 0.9)',
cursorColor: 'transparent',
barWidth: 2,
barGap: 1,
height: height,
normalize: true,
interact: false,
});
// Event listeners
wavesurfer.current.on('ready', () => {
if (mounted) {
setIsLoading(false);
setError(false);
}
});
wavesurfer.current.on('error', (err) => {
console.error('WaveSurfer error:', err);
if (mounted) {
setError(true);
setIsLoading(false);
}
});
await wavesurfer.current.load(audioUrl);
} catch (err) {
console.error('Failed to initialize WaveSurfer:', err);
if (mounted) {
setError(true);
setIsLoading(false);
}
}
};
initWaveSurfer();
return () => {
mounted = false;
if (wavesurfer.current) {
try {
wavesurfer.current.destroy();
} catch (e) {
// Silently ignore destroy errors
}
wavesurfer.current = null;
}
};
}, [audioUrl, height]);
if (error) {
return (
<div className={`flex items-center justify-center ${className}`} style={{ height }}>
<span className="text-xs text-foreground/60">Audio unavailable</span>
</div>
);
}
return (
<div className={`relative ${className}`}>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs text-foreground/60">Loading...</span>
</div>
)}
<div
ref={waveformRef}
className={`w-full transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`}
style={{ height }}
/>
</div>
);
};
export default AudioWaveform;

View File

@ -1,64 +1,43 @@
"use client"; "use client";
import { useDragDrop } from "@/hooks/use-drag-drop"; import { Button } from "../ui/button";
import { AspectRatio } from "../ui/aspect-ratio";
import { DragOverlay } from "../ui/drag-overlay";
import { useMediaStore } from "@/stores/media-store";
import { processMediaFiles } from "@/lib/media-processing"; import { processMediaFiles } from "@/lib/media-processing";
import { useMediaStore, type MediaItem } from "@/stores/media-store"; import { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react";
import { Image, Music, Plus, Upload, Video } from "lucide-react"; import { useDragDrop } from "@/hooks/use-drag-drop";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; 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() { // 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 { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
const { activeProject } = useProjectStore();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [mediaFilter, setMediaFilter] = useState("all"); const [mediaFilter, setMediaFilter] = useState("all");
const processFiles = async (files: FileList | File[]) => { const processFiles = async (files: FileList | File[]) => {
if (!files || files.length === 0) return; // If no files, do nothing
if (!activeProject) { if (!files?.length) return;
toast.error("No active project");
return;
}
setIsProcessing(true); setIsProcessing(true);
setProgress(0);
try { try {
// Process files (extract metadata, generate thumbnails, etc.) // Process files (extract metadata, generate thumbnails, etc.)
const processedItems = await processMediaFiles(files, (p) => const items = await processMediaFiles(files);
setProgress(p)
);
// Add each processed media item to the store // Add each processed media item to the store
for (const item of processedItems) { items.forEach((item) => {
await addMediaItem(activeProject.id, item); addMediaItem(item);
} });
} catch (error) { } catch (error) {
// Show error toast if processing fails // Show error if processing fails
console.error("Error processing files:", error); console.error("File processing failed:", error);
toast.error("Failed to process files"); toast.error("Failed to process files");
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
setProgress(0);
} }
}; };
@ -75,17 +54,10 @@ export function MediaView() {
e.target.value = ""; // Reset input e.target.value = ""; // Reset input
}; };
const handleRemove = async (e: React.MouseEvent, id: string) => { const handleRemove = (e: React.MouseEvent, id: string) => {
// Remove a media item from the store // Remove a media item from the store
e.stopPropagation(); e.stopPropagation();
removeMediaItem(id);
if (!activeProject) {
toast.error("No active project");
return;
}
// Media store now handles cascade deletion automatically
await removeMediaItem(activeProject.id, id);
}; };
const formatDuration = (duration: number) => { const formatDuration = (duration: number) => {
@ -95,18 +67,28 @@ export function MediaView() {
return `${min}:${sec.toString().padStart(2, "0")}`; return `${min}:${sec.toString().padStart(2, "0")}`;
}; };
const startDrag = (e: React.DragEvent, item: any) => {
// 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); const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
useEffect(() => { useEffect(() => {
const filtered = mediaItems.filter((item) => { const filtered = mediaItems.filter((item) => {
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { if (mediaFilter && mediaFilter !== 'all' && item.type !== mediaFilter) {
return false; return false;
} }
if ( if (searchQuery && !item.name.toLowerCase().includes(searchQuery.toLowerCase())) {
searchQuery &&
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false; return false;
} }
@ -116,25 +98,33 @@ export function MediaView() {
setFilteredMediaItems(filtered); setFilteredMediaItems(filtered);
}, [mediaItems, mediaFilter, searchQuery]); }, [mediaItems, mediaFilter, searchQuery]);
const renderPreview = (item: MediaItem) => { const renderPreview = (item: any) => {
// Render a preview for each media type (image, video, audio, unknown) // 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") { if (item.type === "image") {
return ( return (
<div className="w-full h-full flex items-center justify-center">
<img <img
src={item.url} src={item.url}
alt={item.name} alt={item.name}
className="max-w-full max-h-full object-contain" className="w-full h-full object-cover rounded cursor-grab active:cursor-grabbing"
loading="lazy" loading="lazy"
{...baseDragProps}
/> />
</div>
); );
} }
if (item.type === "video") { if (item.type === "video") {
if (item.thumbnailUrl) { if (item.thumbnailUrl) {
return ( return (
<div className="relative w-full h-full"> <div
className="relative w-full h-full cursor-grab active:cursor-grabbing"
{...baseDragProps}
>
<img <img
src={item.thumbnailUrl} src={item.thumbnailUrl}
alt={item.name} alt={item.name}
@ -153,7 +143,10 @@ export function MediaView() {
); );
} }
return ( return (
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"> <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" /> <Video className="h-6 w-6 mb-1" />
<span className="text-xs">Video</span> <span className="text-xs">Video</span>
{item.duration && ( {item.duration && (
@ -167,7 +160,10 @@ export function MediaView() {
if (item.type === "audio") { if (item.type === "audio") {
return ( 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"> <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" /> <Music className="h-6 w-6 mb-1" />
<span className="text-xs">Audio</span> <span className="text-xs">Audio</span>
{item.duration && ( {item.duration && (
@ -180,7 +176,10 @@ export function MediaView() {
} }
return ( return (
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"> <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" /> <Image className="h-6 w-6" />
<span className="text-xs mt-1">Unknown</span> <span className="text-xs mt-1">Unknown</span>
</div> </div>
@ -200,31 +199,30 @@ export function MediaView() {
/> />
<div <div
className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`} className={`h-full flex flex-col transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
{...dragProps} {...dragProps}
> >
{/* Show overlay when dragging files over the panel */} {/* Show overlay when dragging files over the panel */}
<DragOverlay isVisible={isDragOver} /> <DragOverlay isVisible={isDragOver} />
<div className="p-3 pb-2"> <div className="p-2 border-b">
{/* Button to add/upload media */} {/* Button to add/upload media */}
<div className="flex gap-2"> <div className="flex gap-2">
{/* Search and filter controls */} {/* Search and filter controls */}
<Select value={mediaFilter} onValueChange={setMediaFilter}> <select
<SelectTrigger className="w-[80px] h-full text-xs"> value={mediaFilter}
<SelectValue /> onChange={(e) => setMediaFilter(e.target.value)}
</SelectTrigger> className="px-2 py-1 text-xs border rounded bg-background"
<SelectContent className=""> >
<SelectItem value="all">All</SelectItem> <option value="all">All</option>
<SelectItem value="video">Video</SelectItem> <option value="video">Video</option>
<SelectItem value="audio">Audio</SelectItem> <option value="audio">Audio</option>
<SelectItem value="image">Image</SelectItem> <option value="image">Image</option>
</SelectContent> </select>
</Select> <input
<Input
type="text" type="text"
placeholder="Search media..." placeholder="Search media..."
className="min-w-[60px] flex-1 h-full text-xs" className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
@ -235,26 +233,24 @@ export function MediaView() {
size="sm" size="sm"
onClick={handleFileSelect} onClick={handleFileSelect}
disabled={isProcessing} disabled={isProcessing}
className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center" className="flex-none min-w-[80px] whitespace-nowrap"
> >
{isProcessing ? ( {isProcessing ? (
<> <>
<Upload className="h-4 w-4 animate-spin" /> <Upload className="h-4 w-4 mr-2 animate-spin" />
<span className="hidden md:inline ml-2">{progress}%</span> Processing...
</> </>
) : ( ) : (
<> <>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
<span className="hidden sm:inline ml-2" aria-label="Add file">
Add Add
</span>
</> </>
)} )}
</Button> </Button>
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto p-3 pt-0"> <div className="flex-1 overflow-y-auto p-2">
{/* Show message if no media, otherwise show media grid */} {/* Show message if no media, otherwise show media grid */}
{filteredMediaItems.length === 0 ? ( {filteredMediaItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center h-full"> <div className="flex flex-col items-center justify-center py-8 text-center h-full">
@ -269,38 +265,32 @@ export function MediaView() {
</p> </p>
</div> </div>
) : ( ) : (
<div <div className="grid grid-cols-2 gap-2">
className="grid gap-2"
style={{
gridTemplateColumns: "repeat(auto-fill, 160px)",
}}
>
{/* Render each media item as a draggable button */} {/* Render each media item as a draggable button */}
{filteredMediaItems.map((item) => ( {filteredMediaItems.map((item) => (
<ContextMenu key={item.id}> <div key={item.id} className="relative group">
<ContextMenuTrigger> <Button
<DraggableMediaItem variant="outline"
name={item.name} className="flex flex-col gap-2 p-2 h-auto w-full relative"
preview={renderPreview(item)} >
dragData={{ <AspectRatio ratio={item.aspectRatio}>
id: item.id, {renderPreview(item)}
type: item.type, </AspectRatio>
name: item.name, <span className="text-xs truncate px-1">{item.name}</span>
}} </Button>
showPlusOnDrag={false}
rounded={false} {/* Show remove button on hover */}
/> <div className="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity">
</ContextMenuTrigger> <Button
<ContextMenuContent>
<ContextMenuItem>Export clips</ContextMenuItem>
<ContextMenuItem
variant="destructive" variant="destructive"
size="icon"
className="h-6 w-6"
onClick={(e) => handleRemove(e, item.id)} onClick={(e) => handleRemove(e, item.id)}
> >
Delete <Trash2 className="h-3 w-3" />
</ContextMenuItem> </Button>
</ContextMenuContent> </div>
</ContextMenu> </div>
))} ))}
</div> </div>
)} )}

View File

@ -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>
);
}

View File

@ -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 }),
}));

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -1,497 +1,213 @@
"use client"; "use client";
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
import { TimelineElement, TimelineTrack } from "@/types/timeline"; import { useMediaStore } from "@/stores/media-store";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-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 { VideoPlayer } from "@/components/ui/video-player";
import { AudioPlayer } from "@/components/ui/audio-player";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { Play, Pause } from "lucide-react";
DropdownMenu, import { useState, useRef } from "react";
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Play, Pause, Expand } 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 { // Debug flag - set to false to hide active clips info
element: TimelineElement; const SHOW_DEBUG_INFO = process.env.NODE_ENV === 'development';
track: TimelineTrack;
mediaItem: MediaItem | null;
}
export function PreviewPanel() { export function PreviewPanel() {
const { tracks } = useTimelineStore(); const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore(); const { mediaItems } = useMediaStore();
const { currentTime } = usePlaybackStore(); const { isPlaying, toggle, currentTime } = usePlaybackStore();
const { canvasSize } = useEditorStore(); const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
const [showDebug, setShowDebug] = useState(SHOW_DEBUG_INFO);
const previewRef = useRef<HTMLDivElement>(null); 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 // Get active clips at current time
useEffect(() => { const getActiveClips = () => {
const updatePreviewSize = () => { const activeClips: Array<{
if (!containerRef.current) return; clip: any;
track: any;
const container = containerRef.current.getBoundingClientRect(); mediaItem: any;
const computedStyle = getComputedStyle(containerRef.current); }> = [];
// Get padding values
const paddingTop = parseFloat(computedStyle.paddingTop);
const paddingBottom = parseFloat(computedStyle.paddingBottom);
const paddingLeft = parseFloat(computedStyle.paddingLeft);
const paddingRight = parseFloat(computedStyle.paddingRight);
// Get gap value (gap-4 = 1rem = 16px)
const gap = parseFloat(computedStyle.gap) || 16;
// Get toolbar height if it exists
const toolbar = containerRef.current.querySelector("[data-toolbar]");
const toolbarHeight = toolbar
? toolbar.getBoundingClientRect().height
: 0;
// Calculate available space after accounting for padding, gap, and toolbar
const availableWidth = container.width - paddingLeft - paddingRight;
const availableHeight =
container.height -
paddingTop -
paddingBottom -
toolbarHeight -
(toolbarHeight > 0 ? gap : 0);
const targetRatio = canvasSize.width / canvasSize.height;
const containerRatio = availableWidth / availableHeight;
let width, height;
if (containerRatio > targetRatio) {
// Container is wider - constrain by height
height = availableHeight;
width = height * targetRatio;
} else {
// Container is taller - constrain by width
width = availableWidth;
height = width / targetRatio;
}
setPreviewDimensions({ width, height });
};
updatePreviewSize();
const resizeObserver = new ResizeObserver(updatePreviewSize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => resizeObserver.disconnect();
}, [canvasSize.width, canvasSize.height]);
// Get active elements at current time
const getActiveElements = (): ActiveElement[] => {
const activeElements: ActiveElement[] = [];
tracks.forEach((track) => { tracks.forEach((track) => {
track.elements.forEach((element) => { track.clips.forEach((clip) => {
const elementStart = element.startTime; const clipStart = clip.startTime;
const elementEnd = const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime >= elementStart && currentTime < elementEnd) { if (currentTime >= clipStart && currentTime < clipEnd) {
let mediaItem = null; const mediaItem = clip.mediaId === "test"
? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
: mediaItems.find((item) => item.id === clip.mediaId);
// Only get media item for media elements if (mediaItem || clip.mediaId === "test") {
if (element.type === "media") { activeClips.push({ clip, track, mediaItem });
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 });
} }
}); });
}); });
return activeElements; return activeClips;
}; };
const activeElements = getActiveElements(); const activeClips = getActiveClips();
const aspectRatio = canvasSize.width / canvasSize.height;
// Check if there are any elements in the timeline at all // Render a clip
const hasAnyElements = tracks.some((track) => track.elements.length > 0); const renderClip = (clipData: any, index: number) => {
const { clip, mediaItem } = clipData;
// Get media elements for blur background (video/image only) // Test clips
const getBlurBackgroundElements = (): ActiveElement[] => { if (!mediaItem || clip.mediaId === "test") {
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") {
return ( return (
<div <div
key={`blur-${element.id}`} key={clip.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",
}}
>
<VideoPlayer
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"
/>
</div>
);
}
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",
}}
>
<img
src={mediaItem.url!}
alt={mediaItem.name}
className="w-full h-full object-cover"
draggable={false}
/>
</div>
);
}
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;
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
}}
>
<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>
</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" 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-center">
<div className="text-2xl mb-2">🎬</div> <div className="text-2xl mb-2">🎬</div>
<p className="text-xs text-white">{element.name}</p> <p className="text-xs text-white">{clip.name}</p>
</div> </div>
</div> </div>
); );
} }
// Video elements // Video clips
if (mediaItem.type === "video") { if (mediaItem.type === "video") {
return ( return (
<div <div key={clip.id} className="absolute inset-0">
key={element.id}
className="absolute inset-0 flex items-center justify-center"
>
<VideoPlayer <VideoPlayer
src={mediaItem.url!} src={mediaItem.url}
poster={mediaItem.thumbnailUrl} poster={mediaItem.thumbnailUrl}
clipStartTime={element.startTime} clipStartTime={clip.startTime}
trimStart={element.trimStart} trimStart={clip.trimStart}
trimEnd={element.trimEnd} trimEnd={clip.trimEnd}
clipDuration={element.duration} clipDuration={clip.duration}
/> />
</div> </div>
); );
} }
// Image elements // Image clips
if (mediaItem.type === "image") { if (mediaItem.type === "image") {
return ( return (
<div <div key={clip.id} className="absolute inset-0">
key={element.id}
className="absolute inset-0 flex items-center justify-center"
>
<img <img
src={mediaItem.url!} src={mediaItem.url}
alt={mediaItem.name} alt={mediaItem.name}
className="max-w-full max-h-full object-contain" className="w-full h-full object-cover"
draggable={false} draggable={false}
/> />
</div> </div>
); );
} }
// Audio elements (no visual representation) // Audio clips (visual representation)
if (mediaItem.type === "audio") { if (mediaItem.type === "audio") {
return ( return (
<div key={element.id} className="absolute inset-0"> <div
<AudioPlayer key={clip.id}
src={mediaItem.url!} className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
clipStartTime={element.startTime} >
trimStart={element.trimStart} <div className="text-center">
trimEnd={element.trimEnd} <div className="text-2xl mb-2">🎵</div>
clipDuration={element.duration} <p className="text-xs text-white">{mediaItem.name}</p>
trackMuted={elementData.track.muted} </div>
/>
</div> </div>
); );
} }
}
return null; 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 ( 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">
<div {/* Controls */}
ref={containerRef} <div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0">
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-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"
> >
<div className="flex-1"></div> {canvasPresets.map(preset => (
{hasAnyElements ? ( <option key={preset.name} value={`${preset.width}x${preset.height}`}>
{preset.name} ({preset.width}×{preset.height})
</option>
))}
</select>
{/* Debug Toggle - Only show in development */}
{SHOW_DEBUG_INFO && (
<Button
variant="text"
size="sm"
onClick={() => setShowDebug(!showDebug)}
className="text-xs"
>
Debug {showDebug ? 'ON' : 'OFF'}
</Button>
)}
<Button variant="outline" size="sm" onClick={toggle} className="ml-auto">
{isPlaying ? <Pause className="h-3 w-3 mr-1" /> : <Play className="h-3 w-3 mr-1" />}
{isPlaying ? "Pause" : "Play"}
</Button>
</div>
{/* Preview Area */}
<div className="flex-1 flex items-center justify-center p-2 sm:p-4 bg-gray-900 min-h-0 min-w-0">
<div <div
ref={previewRef} ref={previewRef}
className="relative overflow-hidden rounded-sm border" className="relative overflow-hidden rounded-sm max-w-full max-h-full bg-black border border-gray-600"
style={{ style={{
width: previewDimensions.width, aspectRatio: aspectRatio.toString(),
height: previewDimensions.height, width: "100%",
backgroundColor: height: "100%",
activeProject?.backgroundType === "blur"
? "transparent"
: activeProject?.backgroundColor || "#000000",
}} }}
> >
{renderBlurBackground()} {activeClips.length === 0 ? (
{activeElements.length === 0 ? ( <div className="absolute inset-0 flex items-center justify-center text-white/50">
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground"> {tracks.length === 0 ? "Drop media to start editing" : "No clips at current time"}
No elements at current time
</div> </div>
) : ( ) : (
activeElements.map((elementData, index) => activeClips.map((clipData, index) => renderClip(clipData, 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> </div>
) : null}
<div className="flex-1"></div>
<PreviewToolbar hasAnyElements={hasAnyElements} />
</div> </div>
</div>
);
}
function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { {/* Debug Info Panel - Conditionally rendered */}
const { isPlaying, toggle, currentTime } = usePlaybackStore(); {showDebug && (
const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore(); <div className="border-t bg-background p-2 flex-shrink-0">
const { getTotalDuration } = useTimelineStore(); <div className="text-xs font-medium mb-1">Debug: Active Clips ({activeClips.length})</div>
const { activeProject } = useProjectStore(); <div className="flex gap-2 overflow-x-auto">
const { {activeClips.map((clipData, index) => (
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);
};
return (
<div <div
data-toolbar key={clipData.clip.id}
className="flex items-end justify-between gap-2 p-1 pt-2 w-full" className="flex items-center gap-1 px-2 py-1 bg-muted rounded text-xs whitespace-nowrap"
> >
<div> <span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4">
<p {index + 1}
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>
<span className="opacity-50">/</span> <span>{clipData.clip.name}</span>
<span className="tabular-nums"> <span className="text-muted-foreground">({clipData.mediaItem?.type || 'test'})</span>
{formatTimeCode(
getTotalDuration(),
"HH:MM:SS:FF",
activeProject?.fps || 30
)}
</span>
</p>
</div> </div>
<Button
variant="text"
size="icon"
onClick={toggle}
disabled={!hasAnyElements}
className="h-auto p-0"
>
{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> {activeClips.length === 0 && (
</DropdownMenu> <span className="text-muted-foreground">No active clips</span>
<Button )}
variant="text"
size="icon"
className="!size-4 text-muted-foreground"
>
<Expand className="!size-4" />
</Button>
</div> </div>
</div> </div>
)}
</div>
); );
} }

View File

@ -0,0 +1,217 @@
"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";
export function PropertiesPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const [backgroundType, setBackgroundType] = useState<
"blur" | "mirror" | "color"
>("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: any) => 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>
);
}

View File

@ -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>;
}

View File

@ -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>
);
}

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -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>
);
}

View File

@ -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)",
}}
/>
);
}

View File

@ -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>
);
}

View File

@ -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 };

View File

@ -1,219 +0,0 @@
"use client";
import type { TrackType } from "@/types/timeline";
import {
ArrowLeftToLine,
ArrowRightToLine,
Copy,
Pause,
Play,
Scissors,
Snowflake,
SplitSquareHorizontal,
Trash2,
} from "lucide-react";
import { Button } from "../ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
interface TimelineToolbarProps {
isPlaying: boolean;
currentTime: number;
duration: number;
speed: number;
tracks: any[];
toggle: () => void;
setSpeed: (speed: number) => void;
addTrack: (type: TrackType) => string;
addClipToTrack: (trackId: string, clip: any) => void;
handleSplitSelected: () => void;
handleDuplicateSelected: () => void;
handleFreezeSelected: () => void;
handleDeleteSelected: () => void;
}
export function TimelineToolbar({
isPlaying,
currentTime,
duration,
speed,
tracks,
toggle,
setSpeed,
addTrack,
addClipToTrack,
handleSplitSelected,
handleDuplicateSelected,
handleFreezeSelected,
handleDeleteSelected,
}: TimelineToolbarProps) {
return (
<div className="border-b flex items-center px-2 py-1 gap-1">
<TooltipProvider delayDuration={500}>
{/* Play/Pause Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={toggle}
className="mr-2"
>
{isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isPlaying ? "Pause (Space)" : "Play (Space)"}
</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
{/* Time Display */}
<div
className="text-xs text-muted-foreground font-mono px-2"
style={{ minWidth: "18ch", textAlign: "center" }}
>
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
</div>
{/* Test Clip Button - for debugging */}
{tracks.length === 0 && (
<>
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => {
const trackId = addTrack("media");
addClipToTrack(trackId, {
mediaId: "test",
name: "Test Clip",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
});
}}
className="text-xs"
>
Add Test Clip
</Button>
</TooltipTrigger>
<TooltipContent>Add a test clip to try playback</TooltipContent>
</Tooltip>
</>
)}
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleSplitSelected}>
<Scissors className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split clip (S)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon">
<ArrowLeftToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep left (A)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon">
<ArrowRightToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep right (D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon">
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Separate audio (E)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleDuplicateSelected}
>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicate clip (Ctrl+D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleFreezeSelected}>
<Snowflake className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Freeze frame (F)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleDeleteSelected}>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete clip (Delete)</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
{/* Speed Control */}
<Tooltip>
<TooltipTrigger asChild>
<Select
value={speed.toFixed(1)}
onValueChange={(value) => setSpeed(parseFloat(value))}
>
<SelectTrigger className="w-[90px] h-8">
<SelectValue placeholder="1.0x" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.5">0.5x</SelectItem>
<SelectItem value="1.0">1.0x</SelectItem>
<SelectItem value="1.5">1.5x</SelectItem>
<SelectItem value="2.0">2.0x</SelectItem>
</SelectContent>
</Select>
</TooltipTrigger>
<TooltipContent>Playback Speed</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}

View File

@ -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

View File

@ -1,124 +0,0 @@
"use client";
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 Image from "next/image";
export function Footer() {
const [star, setStar] = useState<string>();
useEffect(() => {
const fetchStars = async () => {
try {
const data = await getStars();
setStar(data);
} catch (err) {
console.error("Failed to fetch GitHub stars", err);
}
};
fetchStars();
}, []);
return (
<motion.footer
className="bg-background border-t"
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="grid grid-cols-1 md:grid-cols-2 gap-12 mb-8">
{/* Brand Section */}
<div className="md:col-span-1 max-w-sm">
<div className="flex items-center gap-2 mb-4">
<Image src="/logo.svg" alt="OpenCut" width={24} height={24} />
<span className="font-bold text-lg">OpenCut</span>
</div>
<p className="text-sm text-muted-foreground mb-5">
The open source video editor that gets the job done. Simple,
powerful, and works on any platform.
</p>
<div className="flex gap-3">
<Link
href="https://github.com/OpenCut-app/OpenCut"
className="text-muted-foreground hover:text-foreground transition-colors"
target="_blank"
rel="noopener noreferrer"
>
<RiGithubLine className="h-5 w-5" />
</Link>
<Link
href="https://x.com/OpenCutApp"
className="text-muted-foreground hover:text-foreground transition-colors"
target="_blank"
rel="noopener noreferrer"
>
<RiTwitterXLine className="h-5 w-5" />
</Link>
</div>
</div>
<div className="flex gap-12 justify-end items-start py-2">
<div>
<h3 className="font-semibold text-foreground mb-4">Resources</h3>
<ul className="space-y-2 text-sm">
<li>
<Link
href="/privacy"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Privacy policy
</Link>
</li>
<li>
<Link
href="/terms"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Terms of use
</Link>
</li>
</ul>
</div>
{/* Company Links */}
<div>
<h3 className="font-semibold text-foreground mb-4">Company</h3>
<ul className="space-y-2 text-sm">
<li>
<Link
href="/contributors"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Contributors
</Link>
</li>
<li>
<Link
href="https://github.com/OpenCut-app/OpenCut/blob/main/README.md"
className="text-muted-foreground hover:text-foreground transition-colors"
target="_blank"
rel="noopener noreferrer"
>
About
</Link>
</li>
</ul>
</div>
</div>
</div>
{/* Bottom Section */}
<div className="pt-2 flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>© 2025 OpenCut, All Rights Reserved</span>
</div>
</div>
</div>
</motion.footer>
);
}

View File

@ -29,7 +29,7 @@ export function HeaderBase({
return ( return (
<header <header
className={cn("px-6 h-14 flex justify-between items-center", className)} className={cn("px-6 h-16 flex justify-between items-center", className)}
> >
{leftContent && <div className="flex items-center">{leftContent}</div>} {leftContent && <div className="flex items-center">{leftContent}</div>}
{centerContent && ( {centerContent && (

View File

@ -1,13 +1,14 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { HeaderBase } from "./header-base"; import { HeaderBase } from "./header-base";
import { useSession } from "@opencut/auth/client"; import { useSession } from "@opencut/auth/client";
import { getStars } from "@/lib/fetch-github-stars"; import { getStars } from "@/lib/fetchGhStars";
import { Star } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Image from "next/image";
export function Header() { export function Header() {
const { data: session } = useSession(); const { data: session } = useSession();
@ -28,43 +29,31 @@ export function Header() {
const leftContent = ( const leftContent = (
<Link href="/" className="flex items-center gap-3"> <Link href="/" className="flex items-center gap-3">
<Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} /> <Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
<span className="text-xl font-medium hidden md:block">OpenCut</span> <span className="font-medium tracking-tight">OpenCut</span>
</Link> </Link>
); );
const rightContent = ( const rightContent = (
<nav className="flex items-center gap-3"> <nav className="flex items-center">
<Link href="/contributors"> <Link href="/contributors">
<Button variant="text" className="text-sm p-0"> <Button variant="text" className="text-sm">
Contributors Contributors
</Button> </Button>
</Link> </Link>
{process.env.NODE_ENV === "development" ? (
<Link href="/projects">
<Button size="sm" className="text-sm ml-4">
Projects
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
) : (
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank"> <Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
<Button variant="text" className="text-sm">
GitHub
</Button>
</Link>
<Link href={session ? "/editor" : "/auth/login"}>
<Button size="sm" className="text-sm ml-4"> <Button size="sm" className="text-sm ml-4">
GitHub {star}+ Start editing
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
)}
</nav> </nav>
); );
return ( return <HeaderBase leftContent={leftContent} rightContent={rightContent} />;
<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]"
leftContent={leftContent}
rightContent={rightContent}
/>
</div>
);
} }

View File

@ -29,7 +29,7 @@ export function GithubIcon({ className }: { className?: string }) {
viewBox="0 -3.5 256 256" viewBox="0 -3.5 256 256"
preserveAspectRatio="xMinYMin meet" preserveAspectRatio="xMinYMin meet"
> >
<g fill="currentColor"> <g fill="#161614">
<path d="M127.505 0C57.095 0 0 57.085 0 127.505c0 56.336 36.534 104.13 87.196 120.99 6.372 1.18 8.712-2.766 8.712-6.134 0-3.04-.119-13.085-.173-23.739-35.473 7.713-42.958-15.044-42.958-15.044-5.8-14.738-14.157-18.656-14.157-18.656-11.568-7.914.872-7.752.872-7.752 12.804.9 19.546 13.14 19.546 13.14 11.372 19.493 29.828 13.857 37.104 10.6 1.144-8.242 4.449-13.866 8.095-17.05-28.32-3.225-58.092-14.158-58.092-63.014 0-13.92 4.981-25.295 13.138-34.224-1.324-3.212-5.688-16.18 1.235-33.743 0 0 10.707-3.427 35.073 13.07 10.17-2.826 21.078-4.242 31.914-4.29 10.836.048 21.752 1.464 31.942 4.29 24.337-16.497 35.029-13.07 35.029-13.07 6.94 17.563 2.574 30.531 1.25 33.743 8.175 8.929 13.122 20.303 13.122 34.224 0 48.972-29.828 59.756-58.22 62.912 4.573 3.957 8.648 11.717 8.648 23.612 0 17.06-.148 30.791-.148 34.991 0 3.393 2.295 7.369 8.759 6.117 50.634-16.879 87.122-64.656 87.122-120.973C255.009 57.085 197.922 0 127.505 0" /> <path d="M127.505 0C57.095 0 0 57.085 0 127.505c0 56.336 36.534 104.13 87.196 120.99 6.372 1.18 8.712-2.766 8.712-6.134 0-3.04-.119-13.085-.173-23.739-35.473 7.713-42.958-15.044-42.958-15.044-5.8-14.738-14.157-18.656-14.157-18.656-11.568-7.914.872-7.752.872-7.752 12.804.9 19.546 13.14 19.546 13.14 11.372 19.493 29.828 13.857 37.104 10.6 1.144-8.242 4.449-13.866 8.095-17.05-28.32-3.225-58.092-14.158-58.092-63.014 0-13.92 4.981-25.295 13.138-34.224-1.324-3.212-5.688-16.18 1.235-33.743 0 0 10.707-3.427 35.073 13.07 10.17-2.826 21.078-4.242 31.914-4.29 10.836.048 21.752 1.464 31.942 4.29 24.337-16.497 35.029-13.07 35.029-13.07 6.94 17.563 2.574 30.531 1.25 33.743 8.175 8.929 13.122 20.303 13.122 34.224 0 48.972-29.828 59.756-58.22 62.912 4.573 3.957 8.648 11.717 8.648 23.612 0 17.06-.148 30.791-.148 34.991 0 3.393 2.295 7.369 8.759 6.117 50.634-16.879 87.122-64.656 87.122-120.973C255.009 57.085 197.922 0 127.505 0" />
<path d="M47.755 181.634c-.28.633-1.278.823-2.185.389-.925-.416-1.445-1.28-1.145-1.916.275-.652 1.273-.834 2.196-.396.927.415 1.455 1.287 1.134 1.923M54.027 187.23c-.608.564-1.797.302-2.604-.589-.834-.889-.99-2.077-.373-2.65.627-.563 1.78-.3 2.616.59.834.899.996 2.08.36 2.65M58.33 194.39c-.782.543-2.06.034-2.849-1.1-.781-1.133-.781-2.493.017-3.038.792-.545 2.05-.055 2.85 1.07.78 1.153.78 2.513-.019 3.069M65.606 202.683c-.699.77-2.187.564-3.277-.488-1.114-1.028-1.425-2.487-.724-3.258.707-.772 2.204-.555 3.302.488 1.107 1.026 1.445 2.496.7 3.258M75.01 205.483c-.307.998-1.741 1.452-3.185 1.028-1.442-.437-2.386-1.607-2.095-2.616.3-1.005 1.74-1.478 3.195-1.024 1.44.435 2.386 1.596 2.086 2.612M85.714 206.67c.036 1.052-1.189 1.924-2.705 1.943-1.525.033-2.758-.818-2.774-1.852 0-1.062 1.197-1.926 2.721-1.951 1.516-.03 2.758.815 2.758 1.86M96.228 206.267c.182 1.026-.872 2.08-2.377 2.36-1.48.27-2.85-.363-3.039-1.38-.184-1.052.89-2.105 2.367-2.378 1.508-.262 2.857.355 3.049 1.398" /> <path d="M47.755 181.634c-.28.633-1.278.823-2.185.389-.925-.416-1.445-1.28-1.145-1.916.275-.652 1.273-.834 2.196-.396.927.415 1.455 1.287 1.134 1.923M54.027 187.23c-.608.564-1.797.302-2.604-.589-.834-.889-.99-2.077-.373-2.65.627-.563 1.78-.3 2.616.59.834.899.996 2.08.36 2.65M58.33 194.39c-.782.543-2.06.034-2.849-1.1-.781-1.133-.781-2.493.017-3.038.792-.545 2.05-.055 2.85 1.07.78 1.153.78 2.513-.019 3.069M65.606 202.683c-.699.77-2.187.564-3.277-.488-1.114-1.028-1.425-2.487-.724-3.258.707-.772 2.204-.555 3.302.488 1.107 1.026 1.445 2.496.7 3.258M75.01 205.483c-.307.998-1.741 1.452-3.185 1.028-1.442-.437-2.386-1.607-2.095-2.616.3-1.005 1.74-1.478 3.195-1.024 1.44.435 2.386 1.596 2.086 2.612M85.714 206.67c.036 1.052-1.189 1.924-2.705 1.943-1.525.033-2.758-.818-2.774-1.852 0-1.062 1.197-1.926 2.721-1.951 1.516-.03 2.758.815 2.758 1.86M96.228 206.267c.182 1.026-.872 2.08-2.377 2.36-1.48.27-2.85-.363-3.039-1.38-.184-1.052.89-2.105 2.367-2.378 1.508-.262 2.857.355 3.049 1.398" />
@ -37,64 +37,3 @@ export function GithubIcon({ className }: { className?: string }) {
</svg> </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>
);
}

View File

@ -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>
);
};

View File

@ -4,21 +4,34 @@ import { motion } from "motion/react";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { useState } from "react"; import Link from "next/link";
import { useEffect, useState } from "react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { getStars } from "@/lib/fetchGhStars";
import Image from "next/image";
import { Handlebars } from "./handlebars";
interface HeroProps { interface HeroProps {
signupCount: number; signupCount: number;
} }
export function Hero({ signupCount }: HeroProps) { export function Hero({ signupCount }: HeroProps) {
const [star, setStar] = useState<string>();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
useEffect(() => {
const fetchStars = async () => {
try {
const data = await getStars();
setStar(data);
} catch (err) {
console.error("Failed to fetch GitHub stars", err);
}
};
fetchStars();
}, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -42,7 +55,7 @@ export function Hero({ signupCount }: HeroProps) {
body: JSON.stringify({ email: email.trim() }), body: JSON.stringify({ email: email.trim() }),
}); });
const data = (await response.json()) as { error: string }; const data = await response.json();
if (response.ok) { if (response.ok) {
toast({ toast({
@ -53,9 +66,7 @@ export function Hero({ signupCount }: HeroProps) {
} else { } else {
toast({ toast({
title: "Oops!", title: "Oops!",
description: description: data.error || "Something went wrong. Please try again.",
(data as { error: string }).error ||
"Something went wrong. Please try again.",
variant: "destructive", variant: "destructive",
}); });
} }
@ -71,32 +82,29 @@ export function Hero({ signupCount }: HeroProps) {
}; };
return ( 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"> <div className="relative min-h-[calc(100vh-4rem)] flex flex-col items-center justify-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"
/>
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: 1 }} transition={{ duration: 1 }}
className="max-w-3xl mx-auto w-full flex-1 flex flex-col justify-center" className="max-w-3xl mx-auto"
> >
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.8 }} transition={{ delay: 0.2, duration: 0.8 }}
className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]" className="inline-block"
> >
<h1>The Open Source</h1> <h1 className="text-5xl sm:text-7xl font-bold tracking-tighter">
<Handlebars>Video Editor</Handlebars> The open source
</h1>
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter mt-2">
video editor
</h1>
</motion.div> </motion.div>
<motion.p <motion.p
className="mt-10 text-base sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto" className="mt-10 text-lg sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.4, duration: 0.8 }} transition={{ delay: 0.4, duration: 0.8 }}
@ -111,11 +119,7 @@ export function Hero({ signupCount }: HeroProps) {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.6, duration: 0.8 }} transition={{ delay: 0.6, duration: 0.8 }}
> >
<form <form onSubmit={handleSubmit} className="flex gap-3 w-full max-w-lg">
onSubmit={handleSubmit}
className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"
>
<div className="relative w-full">
<Input <Input
type="email" type="email"
placeholder="Enter your email" placeholder="Enter your email"
@ -125,11 +129,10 @@ export function Hero({ signupCount }: HeroProps) {
disabled={isSubmitting} disabled={isSubmitting}
required required
/> />
</div>
<Button <Button
type="submit" type="submit"
size="lg" size="lg"
className="px-6 h-11 text-base !bg-foreground" className="px-6 h-11 text-base"
disabled={isSubmitting} disabled={isSubmitting}
> >
<span className="relative z-10"> <span className="relative z-10">
@ -145,13 +148,28 @@ export function Hero({ signupCount }: HeroProps) {
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8, duration: 0.6 }} transition={{ delay: 0.8, duration: 0.6 }}
className="mt-8 inline-flex items-center gap-2 text-sm text-muted-foreground justify-center" className="mt-6 inline-flex items-center gap-2 bg-muted/30 px-4 py-2 rounded-full text-sm text-muted-foreground"
> >
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" /> <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span>{signupCount.toLocaleString()} people already joined</span> <span>{signupCount.toLocaleString()} people already joined</span>
</motion.div> </motion.div>
)} )}
</motion.div> </motion.div>
<motion.div
className="absolute bottom-12 left-0 right-0 text-center text-sm text-muted-foreground/60"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8, duration: 0.8 }}
>
Currently in beta Open source on{" "}
<Link
href="https://github.com/OpenCut-app/OpenCut"
className="text-foreground underline"
>
GitHub {star}+
</Link>
</motion.div>
</div> </div>
); );
} }

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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()}
/>
);
}

View File

@ -10,8 +10,6 @@ const buttonVariants = cva(
variants: { variants: {
variant: { variant: {
default: default:
"bg-foreground text-background shadow hover:bg-foreground/90",
primary:
"bg-primary text-primary-foreground shadow hover:bg-primary/90", "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
@ -24,7 +22,7 @@ const buttonVariants = cva(
}, },
size: { size: {
default: "h-9 px-4 py-2", 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", lg: "h-10 rounded-md px-8",
icon: "h-7 w-7", icon: "h-7 w-7",
}, },

View File

@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"rounded-xl border bg-card text-card-foreground", "rounded-xl border bg-card text-card-foreground shadow",
className className
)} )}
{...props} {...props}

View File

@ -3,7 +3,6 @@
import * as React from "react"; import * as React from "react";
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"; import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import { Check, ChevronRight, Circle } from "lucide-react"; import { Check, ChevronRight, Circle } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
@ -19,40 +18,23 @@ const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; 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< const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>, React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & { React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean; inset?: boolean;
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
} }
>(({ className, inset, children, variant = "default", ...props }, ref) => ( >(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger <ContextMenuPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( className={cn(
contextMenuItemVariants({ variant }), "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",
"data-[state=open]:bg-accent data-[state=open]:opacity-65",
inset && "pl-8", inset && "pl-8",
className className
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRight className="ml-auto" /> <ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger> </ContextMenuPrimitive.SubTrigger>
)); ));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
@ -80,8 +62,7 @@ const ContextMenuContent = React.forwardRef<
<ContextMenuPrimitive.Content <ContextMenuPrimitive.Content
ref={ref} ref={ref}
className={cn( 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",
"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 className
)} )}
{...props} {...props}
@ -94,13 +75,12 @@ const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>, React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean; inset?: boolean;
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
} }
>(({ className, inset, variant = "default", ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item <ContextMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( 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", inset && "pl-8",
className className
)} )}
@ -111,13 +91,14 @@ ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef< const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>, React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & { React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; >(({ className, children, checked, ...props }, ref) => (
}
>(({ className, children, checked, variant = "default", ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem <ContextMenuPrimitive.CheckboxItem
ref={ref} 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} checked={checked}
{...props} {...props}
> >
@ -134,18 +115,19 @@ ContextMenuCheckboxItem.displayName =
const ContextMenuRadioItem = React.forwardRef< const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>, React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & { React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; >(({ className, children, ...props }, ref) => (
}
>(({ className, children, variant = "default", ...props }, ref) => (
<ContextMenuPrimitive.RadioItem <ContextMenuPrimitive.RadioItem
ref={ref} 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} {...props}
> >
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator> <ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" /> <Circle className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator> </ContextMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
@ -162,7 +144,7 @@ const ContextMenuLabel = React.forwardRef<
<ContextMenuPrimitive.Label <ContextMenuPrimitive.Label
ref={ref} ref={ref}
className={cn( 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", inset && "pl-8",
className className
)} )}
@ -177,7 +159,7 @@ const ContextMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator <ContextMenuPrimitive.Separator
ref={ref} 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} {...props}
/> />
)); ));
@ -189,7 +171,10 @@ const ContextMenuShortcut = ({
}: React.HTMLAttributes<HTMLSpanElement>) => { }: React.HTMLAttributes<HTMLSpanElement>) => {
return ( return (
<span <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} {...props}
/> />
); );

View File

@ -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>
);
}

View File

@ -19,33 +19,16 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 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< const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean; inset?: boolean;
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
} }
>(({ className, inset, children, variant = "default", ...props }, ref) => ( >(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( className={cn(
dropdownMenuItemVariants({ variant }), "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",
"data-[state=open]:bg-accent data-[state=open]:opacity-65",
inset && "pl-8", inset && "pl-8",
className className
)} )}
@ -82,12 +65,8 @@ const DropdownMenuContent = React.forwardRef<
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
onCloseAutoFocus={(e) => {
e.stopPropagation();
e.preventDefault();
}}
className={cn( 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", "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 className
)} )}
@ -97,6 +76,22 @@ const DropdownMenuContent = React.forwardRef<
)); ));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; 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< const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
@ -118,15 +113,12 @@ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef< const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; >(({ className, children, checked, ...props }, ref) => (
}
>(({ className, children, checked, variant = "default", ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
ref={ref} ref={ref}
className={cn( className={cn(
dropdownMenuItemVariants({ variant }), "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",
"pl-8 pr-2",
className className
)} )}
checked={checked} checked={checked}
@ -145,15 +137,12 @@ DropdownMenuCheckboxItem.displayName =
const DropdownMenuRadioItem = React.forwardRef< const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; >(({ className, children, ...props }, ref) => (
}
>(({ className, children, variant = "default", ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
ref={ref} ref={ref}
className={cn( className={cn(
dropdownMenuItemVariants({ variant }), "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",
"pl-8 pr-2",
className className
)} )}
{...props} {...props}
@ -192,7 +181,7 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
ref={ref} 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} {...props}
/> />
)); ));

View File

@ -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>
);
}

View File

@ -2,14 +2,13 @@
import { useState } from "react"; import { useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { BackgroundType } from "@/types/editor";
interface ImageTimelineTreatmentProps { interface ImageTimelineTreatmentProps {
src: string; src: string;
alt: string; alt: string;
targetAspectRatio?: number; // Default to 16:9 for video targetAspectRatio?: number; // Default to 16:9 for video
className?: string; className?: string;
backgroundType?: BackgroundType; backgroundType?: "blur" | "mirror" | "color";
backgroundColor?: string; backgroundColor?: string;
} }

View File

@ -11,7 +11,14 @@ interface InputProps extends React.ComponentProps<"input"> {
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
( (
{ className, type, showPassword, onShowPasswordChange, value, ...props }, {
className,
type,
showPassword,
onShowPasswordChange,
value,
...props
},
ref ref
) => { ) => {
const isPassword = type === "password"; const isPassword = type === "password";
@ -19,7 +26,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
const inputType = isPassword && showPassword ? "text" : type; const inputType = isPassword && showPassword ? "text" : type;
return ( return (
<div className={showPassword ? "relative w-full" : ""}> <div className="relative w-full">
<input <input
type={inputType} type={inputType}
className={cn( className={cn(

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import { GripVertical } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels"; import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
@ -28,11 +29,17 @@ const ResizableHandle = ({
}) => ( }) => (
<ResizablePrimitive.PanelResizeHandle <ResizablePrimitive.PanelResizeHandle
className={cn( 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 className
)} )}
{...props} {...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 }; export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@ -3,7 +3,6 @@
import * as React from "react"; import * as React from "react";
import { Select as SelectPrimitive } from "radix-ui"; import { Select as SelectPrimitive } from "radix-ui";
import { Check, ChevronDown, ChevronUp } from "lucide-react"; import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
@ -13,21 +12,6 @@ const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value; 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< const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>, React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
@ -97,10 +81,6 @@ const SelectContent = React.forwardRef<
className className
)} )}
position={position} position={position}
onCloseAutoFocus={(e) => {
e.preventDefault();
e.stopPropagation();
}}
{...props} {...props}
> >
<SelectScrollUpButton /> <SelectScrollUpButton />
@ -133,13 +113,14 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef< const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>, React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
variant?: VariantProps<typeof selectItemVariants>["variant"]; >(({ className, children, ...props }, ref) => (
}
>(({ className, children, variant = "default", ...props }, ref) => (
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} 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} {...props}
> >
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> <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) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.Separator <SelectPrimitive.Separator
ref={ref} 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} {...props}
/> />
)); ));

Some files were not shown because too many files have changed in this diff Show More