Compare commits
2 Commits
main
...
mazeincodi
Author | SHA1 | Date | |
---|---|---|---|
99965c0674 | |||
87c4bd4c95 |
5
.github/CONTRIBUTING.md
vendored
@ -10,11 +10,6 @@ Thank you for your interest in contributing to OpenCut! This document provides g
|
|||||||
4. Install dependencies: `bun install`
|
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
@ -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.
|
70
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -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
|
|
19
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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.
|
42
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@ -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
|
|
4
.github/workflows/bun-ci.yml
vendored
@ -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
@ -28,8 +28,3 @@ node_modules
|
|||||||
.turbo
|
.turbo
|
||||||
|
|
||||||
*.env
|
*.env
|
||||||
|
|
||||||
# cursor
|
|
||||||
|
|
||||||
.cursor/
|
|
||||||
bun.lockb
|
|
143
README.md
@ -1,13 +1,15 @@
|
|||||||
<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)
|
||||||
</td>
|
### A free, open-source video editor for web, desktop, and mobile.
|
||||||
</tr>
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
## Why?
|
## Why?
|
||||||
@ -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.
|
|
||||||
|
|
||||||
[](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)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||

|
|
||||||
|
@ -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"]
|
@ -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,
|
||||||
|
@ -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"
|
@ -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;
|
||||||
|
@ -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",
|
||||||
|
@ -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>
|
|
Before Width: | Height: | Size: 1.1 KiB |
@ -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 |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 741 B |
Before Width: | Height: | Size: 768 B |
Before Width: | Height: | Size: 802 B |
Before Width: | Height: | Size: 826 B |
Before Width: | Height: | Size: 906 B |
Before Width: | Height: | Size: 985 B |
Before Width: | Height: | Size: 998 B |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 809 B |
Before Width: | Height: | Size: 843 B |
Before Width: | Height: | Size: 826 B |
Before Width: | Height: | Size: 820 B |
Before Width: | Height: | Size: 670 B |
Before Width: | Height: | Size: 747 B |
Before Width: | Height: | Size: 906 B |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 2.7 KiB |
Before Width: | Height: | Size: 814 B |
Before Width: | Height: | Size: 225 KiB |
@ -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 |
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
Before Width: | Height: | Size: 54 KiB |
@ -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);
|
|
||||||
|
@ -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);
|
|
||||||
|
@ -1,5 +0,0 @@
|
|||||||
import { NextRequest } from "next/server";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
return new Response("OK", { status: 200 });
|
|
||||||
}
|
|
@ -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";
|
||||||
|
@ -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`}
|
||||||
|
4
apps/web/src/app/editor/editor.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/* Prevent scroll jumping on Mac devices when using the editor */
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
@ -1,16 +1,16 @@
|
|||||||
"use client";
|
"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>
|
BIN
apps/web/src/app/favicon.ico
Normal file
After Width: | Height: | Size: 4.2 KiB |
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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"
|
|
||||||
}
|
|
||||||
};
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,552 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import {
|
|
||||||
ChevronLeft,
|
|
||||||
Plus,
|
|
||||||
Calendar,
|
|
||||||
MoreHorizontal,
|
|
||||||
Video,
|
|
||||||
Loader2,
|
|
||||||
X,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { TProject } from "@/types/project";
|
|
||||||
import Image from "next/image";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { DeleteProjectDialog } from "@/components/delete-project-dialog";
|
|
||||||
import { RenameProjectDialog } from "@/components/rename-project-dialog";
|
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
|
||||||
const {
|
|
||||||
createNewProject,
|
|
||||||
savedProjects,
|
|
||||||
isLoading,
|
|
||||||
isInitialized,
|
|
||||||
deleteProject,
|
|
||||||
} = useProjectStore();
|
|
||||||
const router = useRouter();
|
|
||||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
|
||||||
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
|
|
||||||
new Set()
|
|
||||||
);
|
|
||||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleCreateProject = async () => {
|
|
||||||
const projectId = await createNewProject("New Project");
|
|
||||||
console.log("projectId", projectId);
|
|
||||||
router.push(`/editor/${projectId}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectProject = (projectId: string, checked: boolean) => {
|
|
||||||
const newSelected = new Set(selectedProjects);
|
|
||||||
if (checked) {
|
|
||||||
newSelected.add(projectId);
|
|
||||||
} else {
|
|
||||||
newSelected.delete(projectId);
|
|
||||||
}
|
|
||||||
setSelectedProjects(newSelected);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAll = (checked: boolean) => {
|
|
||||||
if (checked) {
|
|
||||||
setSelectedProjects(new Set(savedProjects.map((p) => p.id)));
|
|
||||||
} else {
|
|
||||||
setSelectedProjects(new Set());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelSelection = () => {
|
|
||||||
setIsSelectionMode(false);
|
|
||||||
setSelectedProjects(new Set());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkDelete = async () => {
|
|
||||||
await Promise.all(
|
|
||||||
Array.from(selectedProjects).map((projectId) => deleteProject(projectId))
|
|
||||||
);
|
|
||||||
setSelectedProjects(new Set());
|
|
||||||
setIsSelectionMode(false);
|
|
||||||
setIsBulkDeleteDialogOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const allSelected =
|
|
||||||
savedProjects.length > 0 && selectedProjects.size === savedProjects.length;
|
|
||||||
const someSelected =
|
|
||||||
selectedProjects.size > 0 && selectedProjects.size < savedProjects.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background">
|
|
||||||
<div className="pt-6 px-6 flex items-center justify-between w-full h-16">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="flex items-center gap-1 hover:text-muted-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="!size-5 shrink-0" />
|
|
||||||
<span className="text-sm font-medium">Back</span>
|
|
||||||
</Link>
|
|
||||||
<div className="block md:hidden">
|
|
||||||
{isSelectionMode ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCancelSelection}
|
|
||||||
>
|
|
||||||
<X className="!size-4" />
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
{selectedProjects.size > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsBulkDeleteDialogOpen(true)}
|
|
||||||
>
|
|
||||||
<Trash2 className="!size-4" />
|
|
||||||
Delete ({selectedProjects.size})
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<CreateButton onClick={handleCreateProject} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<main className="max-w-6xl mx-auto px-6 pt-6 pb-6">
|
|
||||||
<div className="mb-8 flex items-center justify-between">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
|
|
||||||
Your Projects
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{savedProjects.length}{" "}
|
|
||||||
{savedProjects.length === 1 ? "project" : "projects"}
|
|
||||||
{isSelectionMode && selectedProjects.size > 0 && (
|
|
||||||
<span className="ml-2 text-primary">
|
|
||||||
• {selectedProjects.size} selected
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="hidden md:block">
|
|
||||||
{isSelectionMode ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" onClick={handleCancelSelection}>
|
|
||||||
<X className="!size-4" />
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
{selectedProjects.size > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => setIsBulkDeleteDialogOpen(true)}
|
|
||||||
>
|
|
||||||
<Trash2 className="!size-4" />
|
|
||||||
Delete Selected ({selectedProjects.size})
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsSelectionMode(true)}
|
|
||||||
disabled={savedProjects.length === 0}
|
|
||||||
>
|
|
||||||
Select Projects
|
|
||||||
</Button>
|
|
||||||
<CreateButton onClick={handleCreateProject} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isSelectionMode && savedProjects.length > 0 && (
|
|
||||||
<div className="mb-6 p-4 bg-muted/30 rounded-lg border">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Checkbox
|
|
||||||
checked={allSelected}
|
|
||||||
ref={(el) => {
|
|
||||||
if (el) {
|
|
||||||
const checkboxElement = el.querySelector(
|
|
||||||
"input"
|
|
||||||
) as HTMLInputElement;
|
|
||||||
if (checkboxElement) {
|
|
||||||
checkboxElement.indeterminate = someSelected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCheckedChange={handleSelectAll}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{allSelected ? "Deselect All" : "Select All"}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
({selectedProjects.size} of {savedProjects.length} selected)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading || !isInitialized ? (
|
|
||||||
<div className="flex items-center justify-center py-16">
|
|
||||||
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : savedProjects.length === 0 ? (
|
|
||||||
<NoProjects onCreateProject={handleCreateProject} />
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
|
|
||||||
{savedProjects.map((project) => (
|
|
||||||
<ProjectCard
|
|
||||||
key={project.id}
|
|
||||||
project={project}
|
|
||||||
isSelectionMode={isSelectionMode}
|
|
||||||
isSelected={selectedProjects.has(project.id)}
|
|
||||||
onSelect={handleSelectProject}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<DeleteProjectDialog
|
|
||||||
isOpen={isBulkDeleteDialogOpen}
|
|
||||||
onOpenChange={setIsBulkDeleteDialogOpen}
|
|
||||||
onConfirm={handleBulkDelete}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProjectCardProps {
|
|
||||||
project: TProject;
|
|
||||||
isSelectionMode?: boolean;
|
|
||||||
isSelected?: boolean;
|
|
||||||
onSelect?: (projectId: string, checked: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProjectCard({
|
|
||||||
project,
|
|
||||||
isSelectionMode = false,
|
|
||||||
isSelected = false,
|
|
||||||
onSelect,
|
|
||||||
}: ProjectCardProps) {
|
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
||||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
|
||||||
const { deleteProject, renameProject, duplicateProject } = useProjectStore();
|
|
||||||
|
|
||||||
const formatDate = (date: Date): string => {
|
|
||||||
return date.toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteProject = async () => {
|
|
||||||
await deleteProject(project.id);
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRenameProject = async (newName: string) => {
|
|
||||||
await renameProject(project.id, newName);
|
|
||||||
setIsRenameDialogOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDuplicateProject = async () => {
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
await duplicateProject(project.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCardClick = (e: React.MouseEvent) => {
|
|
||||||
if (isSelectionMode) {
|
|
||||||
e.preventDefault();
|
|
||||||
onSelect?.(project.id, !isSelected);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{isSelectionMode ? (
|
|
||||||
<div onClick={handleCardClick} className="block group cursor-pointer">
|
|
||||||
<Card
|
|
||||||
className={`overflow-hidden bg-background border-none p-0 transition-all ${
|
|
||||||
isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`relative aspect-square bg-muted transition-opacity ${
|
|
||||||
isDropdownOpen
|
|
||||||
? "opacity-65"
|
|
||||||
: "opacity-100 group-hover:opacity-65"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Selection checkbox */}
|
|
||||||
{isSelectionMode && (
|
|
||||||
<div className="absolute top-3 left-3 z-10">
|
|
||||||
<div className="w-5 h-5 rounded bg-background/80 backdrop-blur-sm border flex items-center justify-center">
|
|
||||||
<Checkbox
|
|
||||||
checked={isSelected}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onSelect?.(project.id, checked as boolean)
|
|
||||||
}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="w-4 h-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Thumbnail preview or placeholder */}
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
{project.thumbnail ? (
|
|
||||||
<Image
|
|
||||||
src={project.thumbnail}
|
|
||||||
alt="Project thumbnail"
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full bg-muted/50 flex items-center justify-center">
|
|
||||||
<Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardContent className="px-0 pt-5 flex flex-col gap-1">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
|
|
||||||
{project.name}
|
|
||||||
</h3>
|
|
||||||
{!isSelectionMode && (
|
|
||||||
<DropdownMenu
|
|
||||||
open={isDropdownOpen}
|
|
||||||
onOpenChange={setIsDropdownOpen}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="sm"
|
|
||||||
className={`size-6 p-0 transition-all shrink-0 ml-2 ${
|
|
||||||
isDropdownOpen
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0 group-hover:opacity-100"
|
|
||||||
}`}
|
|
||||||
onClick={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<MoreHorizontal />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="end"
|
|
||||||
onCloseAutoFocus={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
setIsRenameDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Rename
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDuplicateProject();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Duplicate
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
setIsDeleteDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
||||||
<Calendar className="!size-4" />
|
|
||||||
<span>Created {formatDate(project.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Link href={`/editor/${project.id}`} className="block group">
|
|
||||||
<Card
|
|
||||||
className={`overflow-hidden bg-background border-none p-0 transition-all ${
|
|
||||||
isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`relative aspect-square bg-muted transition-opacity ${
|
|
||||||
isDropdownOpen
|
|
||||||
? "opacity-65"
|
|
||||||
: "opacity-100 group-hover:opacity-65"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Thumbnail preview or placeholder */}
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
{project.thumbnail ? (
|
|
||||||
<Image
|
|
||||||
src={project.thumbnail}
|
|
||||||
alt="Project thumbnail"
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full bg-muted/50 flex items-center justify-center">
|
|
||||||
<Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardContent className="px-0 pt-5 flex flex-col gap-1">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
|
|
||||||
{project.name}
|
|
||||||
</h3>
|
|
||||||
<DropdownMenu
|
|
||||||
open={isDropdownOpen}
|
|
||||||
onOpenChange={setIsDropdownOpen}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="sm"
|
|
||||||
className={`size-6 p-0 transition-all shrink-0 ml-2 ${
|
|
||||||
isDropdownOpen
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0 group-hover:opacity-100"
|
|
||||||
}`}
|
|
||||||
onClick={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<MoreHorizontal />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="end"
|
|
||||||
onCloseAutoFocus={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
setIsRenameDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Rename
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDuplicateProject();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Duplicate
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
setIsDeleteDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
||||||
<Calendar className="!size-4" />
|
|
||||||
<span>Created {formatDate(project.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<DeleteProjectDialog
|
|
||||||
isOpen={isDeleteDialogOpen}
|
|
||||||
onOpenChange={setIsDeleteDialogOpen}
|
|
||||||
onConfirm={handleDeleteProject}
|
|
||||||
/>
|
|
||||||
<RenameProjectDialog
|
|
||||||
isOpen={isRenameDialogOpen}
|
|
||||||
onOpenChange={setIsRenameDialogOpen}
|
|
||||||
onConfirm={handleRenameProject}
|
|
||||||
projectName={project.name}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreateButton({ onClick }: { onClick?: () => void }) {
|
|
||||||
return (
|
|
||||||
<Button className="flex" onClick={onClick}>
|
|
||||||
<Plus className="!size-4" />
|
|
||||||
<span className="text-sm font-medium">New project</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NoProjects({ onCreateProject }: { onCreateProject: () => void }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
||||||
<div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
|
|
||||||
<Video className="h-8 w-8 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium mb-2">No projects yet</h3>
|
|
||||||
<p className="text-muted-foreground mb-6 max-w-md">
|
|
||||||
Start creating your first video project. Import media, edit, and export
|
|
||||||
professional videos.
|
|
||||||
</p>
|
|
||||||
<Button size="lg" className="gap-2" onClick={onCreateProject}>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Create Your First Project
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,191 +0,0 @@
|
|||||||
import { Header } from "@/components/header";
|
|
||||||
|
|
||||||
export default function WhyNotCapcut() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background px-5">
|
|
||||||
<Header />
|
|
||||||
|
|
||||||
<main className="relative mt-12">
|
|
||||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
|
||||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
|
||||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative container mx-auto px-4 py-16">
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<div className="text-center mb-20">
|
|
||||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
|
||||||
Fuck CapCut
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
|
|
||||||
Roasting time, so get ready motherfucker.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-4xl mx-auto space-y-12">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-3xl font-bold mb-6">
|
|
||||||
Seriously, what the fuck else do you want?
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg mb-6">
|
|
||||||
You probably use CapCut and think your video editing is
|
|
||||||
special. You think your fucking TikTok with 47 transitions and
|
|
||||||
12 different fonts is going to get you some viral fame. You
|
|
||||||
think loading up every goddamn effect in their library makes
|
|
||||||
your content better. Wrong, motherfucker. Let me describe what
|
|
||||||
CapCut actually gives you:
|
|
||||||
</p>
|
|
||||||
<ul className="text-lg space-y-2 mb-6 list-disc list-inside">
|
|
||||||
<li>A paywall every time you breathe</li>
|
|
||||||
<li>Terms of service that steal your shit</li>
|
|
||||||
<li>
|
|
||||||
More "Get Pro" dialogs than a Windows 95 error message
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Features that disappear behind paywalls while you're fucking
|
|
||||||
using them
|
|
||||||
</li>
|
|
||||||
<li>Bugs disguised as "premium features"</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-lg mb-6">
|
|
||||||
<strong>Well guess what, motherfucker:</strong>
|
|
||||||
</p>
|
|
||||||
<p className="text-lg mb-6">
|
|
||||||
You. Are. Getting. Scammed. Look at this shit. It's a fucking
|
|
||||||
video editor. Why the fuck do you need to pay $20/month just
|
|
||||||
to remove a goddamn watermark? You spent hours editing your
|
|
||||||
video and they slap their logo on it like they fucking made
|
|
||||||
it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-3xl font-bold mb-6">
|
|
||||||
The "Get Pro" dialog is everywhere
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg mb-6">
|
|
||||||
This motherfucking dialog pops up more than ads on a pirated
|
|
||||||
movie site. Want to add a transition? Get Pro. Want to export
|
|
||||||
without their watermark? Get Pro. Want to use more than 2
|
|
||||||
fonts? Get fucking Pro, peasant.
|
|
||||||
</p>
|
|
||||||
<p className="text-lg mb-6">
|
|
||||||
Did you seriously think you could edit a video without seeing
|
|
||||||
this dialog 47 times? You click one button and BAM - there it
|
|
||||||
is again, asking for your credit card like a desperate ex
|
|
||||||
asking for money.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-3xl font-bold mb-6">
|
|
||||||
Everything costs money now
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg mb-6">
|
|
||||||
You dumbass. You thought CapCut was free, but no. Free means
|
|
||||||
they let you open the app. Everything else costs money. Basic
|
|
||||||
shake effect? That'll be $20/month. A decent transition that isn't
|
|
||||||
"fade"? Pay up, motherfucker.
|
|
||||||
</p>
|
|
||||||
<p className="text-lg mb-6">
|
|
||||||
Here's my favorite piece of bullshit: You import an MP3 file -
|
|
||||||
you know, AUDIO - and try to export. "Sorry, can't export
|
|
||||||
because you're using our premium extract audio feature!"
|
|
||||||
</p>
|
|
||||||
<p className="text-lg mb-6">
|
|
||||||
<strong>
|
|
||||||
My MP3 was already fucking audio, you absolute morons.
|
|
||||||
</strong>
|
|
||||||
</p>
|
|
||||||
<p className="text-lg mb-6">
|
|
||||||
But wait, there's more! If you drag that same MP3 to their
|
|
||||||
media panel first, then to the timeline, it magically works.
|
|
||||||
This isn't a bug, it's a fucking scam disguised as software
|
|
||||||
engineering.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-3xl font-bold mb-6">
|
|
||||||
Their Terms of Service are insane
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg mb-6">
|
|
||||||
Look at this shit. You upload your content and they basically
|
|
||||||
say "thanks for the free content, we own it now, but if Disney
|
|
||||||
sues anyone, that's your problem."
|
|
||||||
</p>
|
|
||||||
<p className="text-lg mb-6">
|
|
||||||
<strong>CapCut's Terms of Service:</strong> We get full rights
|
|
||||||
to use, modify, distribute, and monetize everything you upload
|
|
||||||
- permanently and without paying you shit. But you're still
|
|
||||||
responsible if anything goes wrong.
|
|
||||||
</p>
|
|
||||||
<p className="text-lg mb-6">
|
|
||||||
Translation: "We'll make money off your viral video, you
|
|
||||||
handle the lawsuits." Brilliant legal strategy, you fucks.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-3xl font-bold mb-6">
|
|
||||||
The editor is actually good
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg mb-6">
|
|
||||||
Here's the thing that makes me want to punch my monitor: the
|
|
||||||
actual video editor is fucking good. It's intuitive, powerful,
|
|
||||||
and anyone can figure it out. When it's not begging for money
|
|
||||||
every 30 seconds, it actually works well.
|
|
||||||
</p>
|
|
||||||
<p className="text-lg mb-6">
|
|
||||||
Which makes everything else so much worse. They built
|
|
||||||
something people want to use, then turned it into a digital
|
|
||||||
slot machine. Every click might trigger a payment request.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-3xl font-bold mb-6">
|
|
||||||
This is a video editor. Look at it. You've never seen one
|
|
||||||
before.
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg mb-6">
|
|
||||||
Like the person who's never used software that doesn't
|
|
||||||
constantly beg for money, you have no fucking idea what a
|
|
||||||
video editor should be. All you've ever seen are predatory
|
|
||||||
apps disguised as creative tools.
|
|
||||||
</p>
|
|
||||||
<p className="text-lg mb-6">
|
|
||||||
A real video editor lets you edit videos. It doesn't steal
|
|
||||||
your content. It doesn't pop up payment dialogs every 5
|
|
||||||
seconds. It doesn't charge you separately for basic features
|
|
||||||
that should be free.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-3xl font-bold mb-6">
|
|
||||||
Yes, this is fucking satire, you fuck
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg mb-6">
|
|
||||||
I'm not actually saying all video editors should be basic as
|
|
||||||
shit. What I'm saying is that all the problems we have with
|
|
||||||
video editing apps are{" "}
|
|
||||||
<strong>ones they create themselves</strong>. Video editors
|
|
||||||
aren't broken by default - they edit videos, export them, and
|
|
||||||
let you use basic features without constantly begging for
|
|
||||||
money. CapCut breaks them. They turn them into payment
|
|
||||||
processors with video editing as a side feature.
|
|
||||||
</p>
|
|
||||||
<p className="text-lg">
|
|
||||||
<em>"Good software gets out of your way."</em>
|
|
||||||
<br />- Some smart motherfucker who definitely wasn't working
|
|
||||||
at CapCut
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
398
apps/web/src/components/auth-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -1,184 +0,0 @@
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { BackgroundIcon } from "./icons";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { colors } from "@/data/colors";
|
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
|
||||||
import { PipetteIcon } from "lucide-react";
|
|
||||||
|
|
||||||
type BackgroundTab = "color" | "blur";
|
|
||||||
|
|
||||||
export function BackgroundSettings() {
|
|
||||||
const { activeProject, updateBackgroundType } = useProjectStore();
|
|
||||||
|
|
||||||
// ✅ Good: derive activeTab from activeProject during rendering
|
|
||||||
const activeTab = activeProject?.backgroundType || "color";
|
|
||||||
|
|
||||||
const handleColorSelect = (color: string) => {
|
|
||||||
updateBackgroundType("color", { backgroundColor: color });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlurSelect = (blurIntensity: number) => {
|
|
||||||
updateBackgroundType("blur", { blurIntensity });
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{
|
|
||||||
label: "Color",
|
|
||||||
value: "color",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Blur",
|
|
||||||
value: "blur",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="icon"
|
|
||||||
className="!size-4 border border-muted-foreground"
|
|
||||||
>
|
|
||||||
<BackgroundIcon className="!size-3" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0">
|
|
||||||
<div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3">
|
|
||||||
<h2 className="text-sm">Background</h2>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<span
|
|
||||||
key={tab.value}
|
|
||||||
onClick={() => {
|
|
||||||
// Switch to the background type when clicking tabs
|
|
||||||
if (tab.value === "color") {
|
|
||||||
updateBackgroundType("color", {
|
|
||||||
backgroundColor:
|
|
||||||
activeProject?.backgroundColor || "#000000",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
updateBackgroundType("blur", {
|
|
||||||
blurIntensity: activeProject?.blurIntensity || 8,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground cursor-pointer",
|
|
||||||
activeTab === tab.value && "text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{activeTab === "color" ? (
|
|
||||||
<ColorView
|
|
||||||
selectedColor={activeProject?.backgroundColor || "#000000"}
|
|
||||||
onColorSelect={handleColorSelect}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<BlurView
|
|
||||||
selectedBlur={activeProject?.blurIntensity || 8}
|
|
||||||
onBlurSelect={handleBlurSelect}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ColorView({
|
|
||||||
selectedColor,
|
|
||||||
onColorSelect,
|
|
||||||
}: {
|
|
||||||
selectedColor: string;
|
|
||||||
onColorSelect: (color: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full">
|
|
||||||
<div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-gradient-to-b from-popover to-transparent pointer-events-none"></div>
|
|
||||||
<div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto">
|
|
||||||
<div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
|
|
||||||
<PipetteIcon className="size-4" />
|
|
||||||
</div>
|
|
||||||
{colors.map((color) => (
|
|
||||||
<ColorItem
|
|
||||||
key={color}
|
|
||||||
color={color}
|
|
||||||
isSelected={color === selectedColor}
|
|
||||||
onClick={() => onColorSelect(color)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ColorItem({
|
|
||||||
color,
|
|
||||||
isSelected,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
color: string;
|
|
||||||
isSelected: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary",
|
|
||||||
isSelected && "border-2 border-primary"
|
|
||||||
)}
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
onClick={onClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BlurView({
|
|
||||||
selectedBlur,
|
|
||||||
onBlurSelect,
|
|
||||||
}: {
|
|
||||||
selectedBlur: number;
|
|
||||||
onBlurSelect: (blurIntensity: number) => void;
|
|
||||||
}) {
|
|
||||||
const blurLevels = [
|
|
||||||
{ label: "Light", value: 4 },
|
|
||||||
{ label: "Medium", value: 8 },
|
|
||||||
{ label: "Heavy", value: 18 },
|
|
||||||
];
|
|
||||||
const blurImage =
|
|
||||||
"https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-3 gap-2 w-full p-3 pt-0">
|
|
||||||
{blurLevels.map((blur) => (
|
|
||||||
<div
|
|
||||||
key={blur.value}
|
|
||||||
className={cn(
|
|
||||||
"w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden",
|
|
||||||
selectedBlur === blur.value && "border-2 border-primary"
|
|
||||||
)}
|
|
||||||
onClick={() => onBlurSelect(blur.value)}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={blurImage}
|
|
||||||
alt={`Blur preview ${blur.label}`}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
style={{ filter: `blur(${blur.value}px)` }}
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-1 left-1 right-1 text-center">
|
|
||||||
<span className="text-xs text-white bg-black/50 px-1 rounded">
|
|
||||||
{blur.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,53 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
export function DeleteProjectDialog({
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
onConfirm,
|
|
||||||
}: {
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent
|
|
||||||
onOpenAutoFocus={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete Project</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete this project? This action cannot be
|
|
||||||
undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpenChange(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" onClick={onConfirm}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
@ -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>
|
||||||
)}
|
)}
|
@ -1,55 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { TabBar } from "./tabbar";
|
|
||||||
import { MediaView } from "./views/media";
|
|
||||||
import { useMediaPanelStore, Tab } from "./store";
|
|
||||||
import { TextView } from "./views/text";
|
|
||||||
|
|
||||||
export function MediaPanel() {
|
|
||||||
const { activeTab } = useMediaPanelStore();
|
|
||||||
|
|
||||||
const viewMap: Record<Tab, React.ReactNode> = {
|
|
||||||
media: <MediaView />,
|
|
||||||
audio: (
|
|
||||||
<div className="p-4 text-muted-foreground">Audio view coming soon...</div>
|
|
||||||
),
|
|
||||||
text: <TextView />,
|
|
||||||
stickers: (
|
|
||||||
<div className="p-4 text-muted-foreground">
|
|
||||||
Stickers view coming soon...
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
effects: (
|
|
||||||
<div className="p-4 text-muted-foreground">
|
|
||||||
Effects view coming soon...
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
transitions: (
|
|
||||||
<div className="p-4 text-muted-foreground">
|
|
||||||
Transitions view coming soon...
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
captions: (
|
|
||||||
<div className="p-4 text-muted-foreground">
|
|
||||||
Captions view coming soon...
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
filters: (
|
|
||||||
<div className="p-4 text-muted-foreground">
|
|
||||||
Filters view coming soon...
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
adjustment: (
|
|
||||||
<div className="p-4 text-muted-foreground">
|
|
||||||
Adjustment view coming soon...
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden">
|
|
||||||
<TabBar />
|
|
||||||
<div className="flex-1">{viewMap[activeTab]}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,73 +0,0 @@
|
|||||||
import {
|
|
||||||
CaptionsIcon,
|
|
||||||
ArrowLeftRightIcon,
|
|
||||||
SparklesIcon,
|
|
||||||
StickerIcon,
|
|
||||||
MusicIcon,
|
|
||||||
VideoIcon,
|
|
||||||
BlendIcon,
|
|
||||||
SlidersHorizontalIcon,
|
|
||||||
LucideIcon,
|
|
||||||
TypeIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { create } from "zustand";
|
|
||||||
|
|
||||||
export type Tab =
|
|
||||||
| "media"
|
|
||||||
| "audio"
|
|
||||||
| "text"
|
|
||||||
| "stickers"
|
|
||||||
| "effects"
|
|
||||||
| "transitions"
|
|
||||||
| "captions"
|
|
||||||
| "filters"
|
|
||||||
| "adjustment";
|
|
||||||
|
|
||||||
export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
|
|
||||||
media: {
|
|
||||||
icon: VideoIcon,
|
|
||||||
label: "Media",
|
|
||||||
},
|
|
||||||
audio: {
|
|
||||||
icon: MusicIcon,
|
|
||||||
label: "Audio",
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
icon: TypeIcon,
|
|
||||||
label: "Text",
|
|
||||||
},
|
|
||||||
stickers: {
|
|
||||||
icon: StickerIcon,
|
|
||||||
label: "Stickers",
|
|
||||||
},
|
|
||||||
effects: {
|
|
||||||
icon: SparklesIcon,
|
|
||||||
label: "Effects",
|
|
||||||
},
|
|
||||||
transitions: {
|
|
||||||
icon: ArrowLeftRightIcon,
|
|
||||||
label: "Transitions",
|
|
||||||
},
|
|
||||||
captions: {
|
|
||||||
icon: CaptionsIcon,
|
|
||||||
label: "Captions",
|
|
||||||
},
|
|
||||||
filters: {
|
|
||||||
icon: BlendIcon,
|
|
||||||
label: "Filters",
|
|
||||||
},
|
|
||||||
adjustment: {
|
|
||||||
icon: SlidersHorizontalIcon,
|
|
||||||
label: "Adjustment",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
interface MediaPanelStore {
|
|
||||||
activeTab: Tab;
|
|
||||||
setActiveTab: (tab: Tab) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
|
|
||||||
activeTab: "media",
|
|
||||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
|
||||||
}));
|
|
@ -1,124 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Tab, tabs, useMediaPanelStore } from "./store";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ChevronRight, ChevronLeft } from "lucide-react";
|
|
||||||
import { useRef, useState, useEffect } from "react";
|
|
||||||
|
|
||||||
export function TabBar() {
|
|
||||||
const { activeTab, setActiveTab } = useMediaPanelStore();
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [isAtEnd, setIsAtEnd] = useState(false);
|
|
||||||
const [isAtStart, setIsAtStart] = useState(true);
|
|
||||||
|
|
||||||
const scrollToEnd = () => {
|
|
||||||
if (scrollContainerRef.current) {
|
|
||||||
scrollContainerRef.current.scrollTo({
|
|
||||||
left: scrollContainerRef.current.scrollWidth,
|
|
||||||
});
|
|
||||||
setIsAtEnd(true);
|
|
||||||
setIsAtStart(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scrollToStart = () => {
|
|
||||||
if (scrollContainerRef.current) {
|
|
||||||
scrollContainerRef.current.scrollTo({
|
|
||||||
left: 0,
|
|
||||||
});
|
|
||||||
setIsAtStart(true);
|
|
||||||
setIsAtEnd(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkScrollPosition = () => {
|
|
||||||
if (scrollContainerRef.current) {
|
|
||||||
const { scrollLeft, scrollWidth, clientWidth } =
|
|
||||||
scrollContainerRef.current;
|
|
||||||
const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1;
|
|
||||||
const isAtStartNow = scrollLeft <= 1;
|
|
||||||
setIsAtEnd(isAtEndNow);
|
|
||||||
setIsAtStart(isAtStartNow);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// We're using useEffect because we need to sync with external DOM scroll events
|
|
||||||
useEffect(() => {
|
|
||||||
const container = scrollContainerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
checkScrollPosition();
|
|
||||||
container.addEventListener("scroll", checkScrollPosition);
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(checkScrollPosition);
|
|
||||||
resizeObserver.observe(container);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
container.removeEventListener("scroll", checkScrollPosition);
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex">
|
|
||||||
<ScrollButton
|
|
||||||
direction="left"
|
|
||||||
onClick={scrollToStart}
|
|
||||||
isVisible={!isAtStart}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
ref={scrollContainerRef}
|
|
||||||
className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative"
|
|
||||||
>
|
|
||||||
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
|
|
||||||
const tab = tabs[tabKey];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col gap-0.5 items-center cursor-pointer",
|
|
||||||
activeTab === tabKey ? "text-primary" : "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
onClick={() => setActiveTab(tabKey)}
|
|
||||||
key={tabKey}
|
|
||||||
>
|
|
||||||
<tab.icon className="!size-[1.1rem]" />
|
|
||||||
<span className="text-[0.65rem]">{tab.label}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<ScrollButton
|
|
||||||
direction="right"
|
|
||||||
onClick={scrollToEnd}
|
|
||||||
isVisible={!isAtEnd}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScrollButton({
|
|
||||||
direction,
|
|
||||||
onClick,
|
|
||||||
isVisible,
|
|
||||||
}: {
|
|
||||||
direction: "left" | "right";
|
|
||||||
onClick: () => void;
|
|
||||||
isVisible: boolean;
|
|
||||||
}) {
|
|
||||||
if (!isVisible) return null;
|
|
||||||
|
|
||||||
const Icon = direction === "left" ? ChevronLeft : ChevronRight;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-panel-accent w-12 h-full flex items-center justify-center">
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10"
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<Icon className="!size-4 text-foreground" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
|
||||||
|
|
||||||
export function TextView() {
|
|
||||||
return (
|
|
||||||
<div className="p-4">
|
|
||||||
<DraggableMediaItem
|
|
||||||
name="Default text"
|
|
||||||
preview={
|
|
||||||
<div className="flex items-center justify-center w-full h-full bg-accent rounded">
|
|
||||||
<span className="text-xs select-none">Default text</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
dragData={{
|
|
||||||
id: "default-text",
|
|
||||||
type: "text",
|
|
||||||
name: "Default text",
|
|
||||||
content: "Default text",
|
|
||||||
}}
|
|
||||||
aspectRatio={1}
|
|
||||||
showLabel={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
217
apps/web/src/components/editor/properties-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -1,5 +0,0 @@
|
|||||||
import { MediaElement } from "@/types/timeline";
|
|
||||||
|
|
||||||
export function AudioProperties({ element }: { element: MediaElement }) {
|
|
||||||
return <div className="space-y-4 p-5">Audio properties</div>;
|
|
||||||
}
|
|
@ -1,109 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
|
||||||
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
|
|
||||||
import { Label } from "../../ui/label";
|
|
||||||
import { ScrollArea } from "../../ui/scroll-area";
|
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
|
||||||
import { useMediaStore } from "@/stores/media-store";
|
|
||||||
import { AudioProperties } from "./audio-properties";
|
|
||||||
import { MediaProperties } from "./media-properties";
|
|
||||||
import { TextProperties } from "./text-properties";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "../../ui/select";
|
|
||||||
import { FPS_PRESETS } from "@/constants/timeline-constants";
|
|
||||||
|
|
||||||
export function PropertiesPanel() {
|
|
||||||
const { activeProject, updateProjectFps } = useProjectStore();
|
|
||||||
const { getDisplayName, canvasSize } = useAspectRatio();
|
|
||||||
const { selectedElements, tracks } = useTimelineStore();
|
|
||||||
const { mediaItems } = useMediaStore();
|
|
||||||
|
|
||||||
const handleFpsChange = (value: string) => {
|
|
||||||
const fps = parseFloat(value);
|
|
||||||
if (!isNaN(fps) && fps > 0) {
|
|
||||||
updateProjectFps(fps);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyView = (
|
|
||||||
<div className="space-y-4 p-5">
|
|
||||||
{/* Media Properties */}
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<PropertyItem label="Name:" value={activeProject?.name || ""} />
|
|
||||||
<PropertyItem label="Aspect ratio:" value={getDisplayName()} />
|
|
||||||
<PropertyItem
|
|
||||||
label="Resolution:"
|
|
||||||
value={`${canvasSize.width} × ${canvasSize.height}`}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<Label className="text-xs text-muted-foreground">Frame rate:</Label>
|
|
||||||
<Select
|
|
||||||
value={(activeProject?.fps || 30).toString()}
|
|
||||||
onValueChange={handleFpsChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-32 h-6 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{FPS_PRESETS.map(({ value, label }) => (
|
|
||||||
<SelectItem key={value} value={value} className="text-xs">
|
|
||||||
{label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollArea className="h-full bg-panel rounded-sm">
|
|
||||||
{selectedElements.length > 0
|
|
||||||
? selectedElements.map(({ trackId, elementId }) => {
|
|
||||||
const track = tracks.find((t) => t.id === trackId);
|
|
||||||
const element = track?.elements.find((e) => e.id === elementId);
|
|
||||||
|
|
||||||
if (element?.type === "text") {
|
|
||||||
return (
|
|
||||||
<div key={elementId}>
|
|
||||||
<TextProperties element={element} trackId={trackId} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (element?.type === "media") {
|
|
||||||
const mediaItem = mediaItems.find(
|
|
||||||
(item) => item.id === element.mediaId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mediaItem?.type === "audio") {
|
|
||||||
return <AudioProperties element={element} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={elementId}>
|
|
||||||
<MediaProperties element={element} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
: emptyView}
|
|
||||||
</ScrollArea>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PropertyItem({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
|
||||||
<span className="text-xs text-right">{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
import { MediaElement } from "@/types/timeline";
|
|
||||||
|
|
||||||
export function MediaProperties({ element }: { element: MediaElement }) {
|
|
||||||
return <div className="space-y-4 p-5">Media properties</div>;
|
|
||||||
}
|
|
@ -1,47 +0,0 @@
|
|||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface PropertyItemProps {
|
|
||||||
direction?: "row" | "column";
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PropertyItem({
|
|
||||||
direction = "row",
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: PropertyItemProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex gap-2",
|
|
||||||
direction === "row"
|
|
||||||
? "items-center justify-between gap-6"
|
|
||||||
: "flex-col gap-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PropertyItemLabel({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return <label className={cn("text-xs", className)}>{children}</label>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PropertyItemValue({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return <div className={cn("flex-1", className)}>{children}</div>;
|
|
||||||
}
|
|
@ -1,76 +0,0 @@
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { FontPicker } from "@/components/ui/font-picker";
|
|
||||||
import { FontFamily } from "@/constants/font-constants";
|
|
||||||
import { TextElement } from "@/types/timeline";
|
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
|
||||||
import { Slider } from "@/components/ui/slider";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
PropertyItem,
|
|
||||||
PropertyItemLabel,
|
|
||||||
PropertyItemValue,
|
|
||||||
} from "./property-item";
|
|
||||||
|
|
||||||
export function TextProperties({
|
|
||||||
element,
|
|
||||||
trackId,
|
|
||||||
}: {
|
|
||||||
element: TextElement;
|
|
||||||
trackId: string;
|
|
||||||
}) {
|
|
||||||
const { updateTextElement } = useTimelineStore();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 p-5">
|
|
||||||
<Textarea
|
|
||||||
placeholder="Name"
|
|
||||||
defaultValue={element.content}
|
|
||||||
className="min-h-[4.5rem] resize-none bg-background/50"
|
|
||||||
onChange={(e) =>
|
|
||||||
updateTextElement(trackId, element.id, { content: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PropertyItem direction="row">
|
|
||||||
<PropertyItemLabel>Font</PropertyItemLabel>
|
|
||||||
<PropertyItemValue>
|
|
||||||
<FontPicker
|
|
||||||
defaultValue={element.fontFamily}
|
|
||||||
onValueChange={(value: FontFamily) =>
|
|
||||||
updateTextElement(trackId, element.id, { fontFamily: value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</PropertyItemValue>
|
|
||||||
</PropertyItem>
|
|
||||||
<PropertyItem direction="column">
|
|
||||||
<PropertyItemLabel>Font size</PropertyItemLabel>
|
|
||||||
<PropertyItemValue>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Slider
|
|
||||||
defaultValue={[element.fontSize]}
|
|
||||||
min={8}
|
|
||||||
max={300}
|
|
||||||
step={1}
|
|
||||||
onValueChange={([value]) =>
|
|
||||||
updateTextElement(trackId, element.id, { fontSize: value })
|
|
||||||
}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={element.fontSize}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateTextElement(trackId, element.id, {
|
|
||||||
fontSize: parseInt(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="w-12 !text-xs h-7 rounded-sm text-center
|
|
||||||
[appearance:textfield]
|
|
||||||
[&::-webkit-outer-spin-button]:appearance-none
|
|
||||||
[&::-webkit-inner-spin-button]:appearance-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PropertyItemValue>
|
|
||||||
</PropertyItem>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,58 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
interface SelectionBoxProps {
|
|
||||||
startPos: { x: number; y: number } | null;
|
|
||||||
currentPos: { x: number; y: number } | null;
|
|
||||||
containerRef: React.RefObject<HTMLElement>;
|
|
||||||
isActive: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SelectionBox({
|
|
||||||
startPos,
|
|
||||||
currentPos,
|
|
||||||
containerRef,
|
|
||||||
isActive,
|
|
||||||
}: SelectionBoxProps) {
|
|
||||||
const selectionBoxRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isActive || !startPos || !currentPos || !containerRef.current) return;
|
|
||||||
|
|
||||||
const container = containerRef.current;
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
|
||||||
|
|
||||||
// Calculate relative positions within the container
|
|
||||||
const startX = startPos.x - containerRect.left;
|
|
||||||
const startY = startPos.y - containerRect.top;
|
|
||||||
const currentX = currentPos.x - containerRect.left;
|
|
||||||
const currentY = currentPos.y - containerRect.top;
|
|
||||||
|
|
||||||
// Calculate the selection rectangle bounds
|
|
||||||
const left = Math.min(startX, currentX);
|
|
||||||
const top = Math.min(startY, currentY);
|
|
||||||
const width = Math.abs(currentX - startX);
|
|
||||||
const height = Math.abs(currentY - startY);
|
|
||||||
|
|
||||||
// Update the selection box position and size
|
|
||||||
if (selectionBoxRef.current) {
|
|
||||||
selectionBoxRef.current.style.left = `${left}px`;
|
|
||||||
selectionBoxRef.current.style.top = `${top}px`;
|
|
||||||
selectionBoxRef.current.style.width = `${width}px`;
|
|
||||||
selectionBoxRef.current.style.height = `${height}px`;
|
|
||||||
}
|
|
||||||
}, [startPos, currentPos, isActive, containerRef]);
|
|
||||||
|
|
||||||
if (!isActive || !startPos || !currentPos) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={selectionBoxRef}
|
|
||||||
className="absolute pointer-events-none z-50"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "hsl(var(--foreground) / 0.1)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,405 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "../ui/button";
|
|
||||||
import {
|
|
||||||
MoreVertical,
|
|
||||||
Scissors,
|
|
||||||
Trash2,
|
|
||||||
SplitSquareHorizontal,
|
|
||||||
Music,
|
|
||||||
ChevronRight,
|
|
||||||
ChevronLeft,
|
|
||||||
Type,
|
|
||||||
Copy,
|
|
||||||
RefreshCw,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useMediaStore } from "@/stores/media-store";
|
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
|
||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
|
||||||
import AudioWaveform from "./audio-waveform";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { TimelineElementProps, TrackType } from "@/types/timeline";
|
|
||||||
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
|
|
||||||
import {
|
|
||||||
getTrackElementClasses,
|
|
||||||
TIMELINE_CONSTANTS,
|
|
||||||
} from "@/constants/timeline-constants";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
} from "../ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
ContextMenu,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuSeparator,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
} from "../ui/context-menu";
|
|
||||||
|
|
||||||
export function TimelineElement({
|
|
||||||
element,
|
|
||||||
track,
|
|
||||||
zoomLevel,
|
|
||||||
isSelected,
|
|
||||||
onElementMouseDown,
|
|
||||||
onElementClick,
|
|
||||||
}: TimelineElementProps) {
|
|
||||||
const { mediaItems } = useMediaStore();
|
|
||||||
const {
|
|
||||||
updateElementTrim,
|
|
||||||
updateElementDuration,
|
|
||||||
removeElementFromTrack,
|
|
||||||
dragState,
|
|
||||||
splitElement,
|
|
||||||
splitAndKeepLeft,
|
|
||||||
splitAndKeepRight,
|
|
||||||
separateAudio,
|
|
||||||
addElementToTrack,
|
|
||||||
replaceElementMedia,
|
|
||||||
} = useTimelineStore();
|
|
||||||
const { currentTime } = usePlaybackStore();
|
|
||||||
|
|
||||||
const [elementMenuOpen, setElementMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
const {
|
|
||||||
resizing,
|
|
||||||
isResizing,
|
|
||||||
handleResizeStart,
|
|
||||||
handleResizeMove,
|
|
||||||
handleResizeEnd,
|
|
||||||
} = useTimelineElementResize({
|
|
||||||
element,
|
|
||||||
track,
|
|
||||||
zoomLevel,
|
|
||||||
onUpdateTrim: updateElementTrim,
|
|
||||||
onUpdateDuration: updateElementDuration,
|
|
||||||
});
|
|
||||||
|
|
||||||
const effectiveDuration =
|
|
||||||
element.duration - element.trimStart - element.trimEnd;
|
|
||||||
const elementWidth = Math.max(
|
|
||||||
TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
|
|
||||||
effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use real-time position during drag, otherwise use stored position
|
|
||||||
const isBeingDragged = dragState.elementId === element.id;
|
|
||||||
const elementStartTime =
|
|
||||||
isBeingDragged && dragState.isDragging
|
|
||||||
? dragState.currentTime
|
|
||||||
: element.startTime;
|
|
||||||
const elementLeft = elementStartTime * 50 * zoomLevel;
|
|
||||||
|
|
||||||
const handleDeleteElement = () => {
|
|
||||||
removeElementFromTrack(track.id, element.id);
|
|
||||||
setElementMenuOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSplitElement = () => {
|
|
||||||
const effectiveStart = element.startTime;
|
|
||||||
const effectiveEnd =
|
|
||||||
element.startTime +
|
|
||||||
(element.duration - element.trimStart - element.trimEnd);
|
|
||||||
|
|
||||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
|
||||||
toast.error("Playhead must be within element to split");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const secondElementId = splitElement(track.id, element.id, currentTime);
|
|
||||||
if (!secondElementId) {
|
|
||||||
toast.error("Failed to split element");
|
|
||||||
}
|
|
||||||
setElementMenuOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSplitAndKeepLeft = () => {
|
|
||||||
const effectiveStart = element.startTime;
|
|
||||||
const effectiveEnd =
|
|
||||||
element.startTime +
|
|
||||||
(element.duration - element.trimStart - element.trimEnd);
|
|
||||||
|
|
||||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
|
||||||
toast.error("Playhead must be within element");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
splitAndKeepLeft(track.id, element.id, currentTime);
|
|
||||||
setElementMenuOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSplitAndKeepRight = () => {
|
|
||||||
const effectiveStart = element.startTime;
|
|
||||||
const effectiveEnd =
|
|
||||||
element.startTime +
|
|
||||||
(element.duration - element.trimStart - element.trimEnd);
|
|
||||||
|
|
||||||
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
|
|
||||||
toast.error("Playhead must be within element");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
splitAndKeepRight(track.id, element.id, currentTime);
|
|
||||||
setElementMenuOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSeparateAudio = () => {
|
|
||||||
if (element.type !== "media") {
|
|
||||||
toast.error("Audio separation only available for media elements");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
|
|
||||||
if (!mediaItem || mediaItem.type !== "video") {
|
|
||||||
toast.error("Audio separation only available for video elements");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioElementId = separateAudio(track.id, element.id);
|
|
||||||
if (!audioElementId) {
|
|
||||||
toast.error("Failed to separate audio");
|
|
||||||
}
|
|
||||||
setElementMenuOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const canSplitAtPlayhead = () => {
|
|
||||||
const effectiveStart = element.startTime;
|
|
||||||
const effectiveEnd =
|
|
||||||
element.startTime +
|
|
||||||
(element.duration - element.trimStart - element.trimEnd);
|
|
||||||
return currentTime > effectiveStart && currentTime < effectiveEnd;
|
|
||||||
};
|
|
||||||
|
|
||||||
const canSeparateAudio = () => {
|
|
||||||
if (element.type !== "media") return false;
|
|
||||||
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
|
|
||||||
return mediaItem?.type === "video" && track.type === "media";
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleElementSplitContext = () => {
|
|
||||||
const effectiveStart = element.startTime;
|
|
||||||
const effectiveEnd =
|
|
||||||
element.startTime +
|
|
||||||
(element.duration - element.trimStart - element.trimEnd);
|
|
||||||
|
|
||||||
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
|
|
||||||
const secondElementId = splitElement(track.id, element.id, currentTime);
|
|
||||||
if (!secondElementId) {
|
|
||||||
toast.error("Failed to split element");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error("Playhead must be within element to split");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleElementDuplicateContext = () => {
|
|
||||||
const { id, ...elementWithoutId } = element;
|
|
||||||
addElementToTrack(track.id, {
|
|
||||||
...elementWithoutId,
|
|
||||||
name: element.name + " (copy)",
|
|
||||||
startTime:
|
|
||||||
element.startTime +
|
|
||||||
(element.duration - element.trimStart - element.trimEnd) +
|
|
||||||
0.1,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleElementDeleteContext = () => {
|
|
||||||
removeElementFromTrack(track.id, element.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReplaceClip = () => {
|
|
||||||
if (element.type !== "media") {
|
|
||||||
toast.error("Replace is only available for media clips");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a file input to select replacement media
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.type = "file";
|
|
||||||
input.accept = "video/*,audio/*,image/*";
|
|
||||||
input.onchange = async (e) => {
|
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const success = await replaceElementMedia(track.id, element.id, file);
|
|
||||||
if (success) {
|
|
||||||
toast.success("Clip replaced successfully");
|
|
||||||
} else {
|
|
||||||
toast.error("Failed to replace clip");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Failed to replace clip");
|
|
||||||
console.log(
|
|
||||||
JSON.stringify({ error: "Failed to replace clip", details: error })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
input.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderElementContent = () => {
|
|
||||||
if (element.type === "text") {
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex items-center justify-start pl-2">
|
|
||||||
<span className="text-xs text-foreground/80 truncate">
|
|
||||||
{element.content}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render media element ->
|
|
||||||
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
|
|
||||||
if (!mediaItem) {
|
|
||||||
return (
|
|
||||||
<span className="text-xs text-foreground/80 truncate">
|
|
||||||
{element.name}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mediaItem.type === "image") {
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
|
||||||
<div className="bg-[#004D52] py-3 w-full h-full">
|
|
||||||
<img
|
|
||||||
src={mediaItem.url}
|
|
||||||
alt={mediaItem.name}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex items-center gap-2">
|
|
||||||
<div className="w-8 h-8 flex-shrink-0">
|
|
||||||
<img
|
|
||||||
src={mediaItem.thumbnailUrl}
|
|
||||||
alt={mediaItem.name}
|
|
||||||
className="w-full h-full object-cover rounded-sm"
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-foreground/80 truncate flex-1">
|
|
||||||
{element.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render audio element ->
|
|
||||||
if (mediaItem.type === "audio") {
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex items-center gap-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<AudioWaveform
|
|
||||||
audioUrl={mediaItem.url || ""}
|
|
||||||
height={24}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="text-xs text-foreground/80 truncate">
|
|
||||||
{element.name}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleElementMouseDown = (e: React.MouseEvent) => {
|
|
||||||
if (onElementMouseDown) {
|
|
||||||
onElementMouseDown(e, element);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu>
|
|
||||||
<ContextMenuTrigger asChild>
|
|
||||||
<div
|
|
||||||
className={`absolute top-0 h-full select-none timeline-element ${
|
|
||||||
isBeingDragged ? "z-50" : "z-10"
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
left: `${elementLeft}px`,
|
|
||||||
width: `${elementWidth}px`,
|
|
||||||
}}
|
|
||||||
data-element-id={element.id}
|
|
||||||
data-track-id={track.id}
|
|
||||||
onMouseMove={resizing ? handleResizeMove : undefined}
|
|
||||||
onMouseUp={resizing ? handleResizeEnd : undefined}
|
|
||||||
onMouseLeave={resizing ? handleResizeEnd : undefined}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
|
|
||||||
track.type
|
|
||||||
)} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
|
|
||||||
isBeingDragged ? "z-50" : "z-10"
|
|
||||||
}`}
|
|
||||||
onClick={(e) => onElementClick && onElementClick(e, element)}
|
|
||||||
onMouseDown={handleElementMouseDown}
|
|
||||||
onContextMenu={(e) =>
|
|
||||||
onElementMouseDown && onElementMouseDown(e, element)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 flex items-center h-full">
|
|
||||||
{renderElementContent()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isSelected && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
|
|
||||||
onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
|
|
||||||
onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
<ContextMenuContent>
|
|
||||||
<ContextMenuItem onClick={handleElementSplitContext}>
|
|
||||||
<Scissors className="h-4 w-4 mr-2" />
|
|
||||||
Split at playhead
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem onClick={handleElementDuplicateContext}>
|
|
||||||
<Copy className="h-4 w-4 mr-2" />
|
|
||||||
Duplicate {element.type === "text" ? "text" : "clip"}
|
|
||||||
</ContextMenuItem>
|
|
||||||
{element.type === "media" && (
|
|
||||||
<ContextMenuItem onClick={handleReplaceClip}>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
Replace clip
|
|
||||||
</ContextMenuItem>
|
|
||||||
)}
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={handleElementDeleteContext}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
Delete {element.type === "text" ? "text" : "clip"}
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,110 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef } from "react";
|
|
||||||
import { TimelineTrack } from "@/types/timeline";
|
|
||||||
import {
|
|
||||||
TIMELINE_CONSTANTS,
|
|
||||||
getTotalTracksHeight,
|
|
||||||
} from "@/constants/timeline-constants";
|
|
||||||
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
|
|
||||||
|
|
||||||
interface TimelinePlayheadProps {
|
|
||||||
currentTime: number;
|
|
||||||
duration: number;
|
|
||||||
zoomLevel: number;
|
|
||||||
tracks: TimelineTrack[];
|
|
||||||
seek: (time: number) => void;
|
|
||||||
rulerRef: React.RefObject<HTMLDivElement>;
|
|
||||||
rulerScrollRef: React.RefObject<HTMLDivElement>;
|
|
||||||
tracksScrollRef: React.RefObject<HTMLDivElement>;
|
|
||||||
trackLabelsRef?: React.RefObject<HTMLDivElement>;
|
|
||||||
timelineRef: React.RefObject<HTMLDivElement>;
|
|
||||||
playheadRef?: React.RefObject<HTMLDivElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TimelinePlayhead({
|
|
||||||
currentTime,
|
|
||||||
duration,
|
|
||||||
zoomLevel,
|
|
||||||
tracks,
|
|
||||||
seek,
|
|
||||||
rulerRef,
|
|
||||||
rulerScrollRef,
|
|
||||||
tracksScrollRef,
|
|
||||||
trackLabelsRef,
|
|
||||||
timelineRef,
|
|
||||||
playheadRef: externalPlayheadRef,
|
|
||||||
}: TimelinePlayheadProps) {
|
|
||||||
const internalPlayheadRef = useRef<HTMLDivElement>(null);
|
|
||||||
const playheadRef = externalPlayheadRef || internalPlayheadRef;
|
|
||||||
const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
|
|
||||||
currentTime,
|
|
||||||
duration,
|
|
||||||
zoomLevel,
|
|
||||||
seek,
|
|
||||||
rulerRef,
|
|
||||||
rulerScrollRef,
|
|
||||||
tracksScrollRef,
|
|
||||||
playheadRef,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use timeline container height minus a few pixels for breathing room
|
|
||||||
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
|
|
||||||
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
|
|
||||||
|
|
||||||
// Get dynamic track labels width, fallback to 0 if no tracks or no ref
|
|
||||||
const trackLabelsWidth =
|
|
||||||
tracks.length > 0 && trackLabelsRef?.current
|
|
||||||
? trackLabelsRef.current.offsetWidth
|
|
||||||
: 0;
|
|
||||||
const leftPosition =
|
|
||||||
trackLabelsWidth +
|
|
||||||
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={playheadRef}
|
|
||||||
className="absolute pointer-events-auto z-[100]"
|
|
||||||
style={{
|
|
||||||
left: `${leftPosition}px`,
|
|
||||||
top: 0,
|
|
||||||
height: `${totalHeight}px`,
|
|
||||||
width: "2px", // Slightly wider for better click target
|
|
||||||
}}
|
|
||||||
onMouseDown={handlePlayheadMouseDown}
|
|
||||||
>
|
|
||||||
{/* The red line spanning full height */}
|
|
||||||
<div className="absolute left-0 w-0.5 bg-foreground cursor-col-resize h-full" />
|
|
||||||
|
|
||||||
{/* Red dot indicator at the top (in ruler area) */}
|
|
||||||
<div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-foreground rounded-full border-2 border-foreground shadow-sm" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also export a hook for getting ruler handlers
|
|
||||||
export function useTimelinePlayheadRuler({
|
|
||||||
currentTime,
|
|
||||||
duration,
|
|
||||||
zoomLevel,
|
|
||||||
seek,
|
|
||||||
rulerRef,
|
|
||||||
rulerScrollRef,
|
|
||||||
tracksScrollRef,
|
|
||||||
playheadRef,
|
|
||||||
}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
|
|
||||||
const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
|
|
||||||
currentTime,
|
|
||||||
duration,
|
|
||||||
zoomLevel,
|
|
||||||
seek,
|
|
||||||
rulerRef,
|
|
||||||
rulerScrollRef,
|
|
||||||
tracksScrollRef,
|
|
||||||
playheadRef,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { handleRulerMouseDown, isDraggingRuler };
|
|
||||||
}
|
|
||||||
|
|
||||||
export { TimelinePlayhead as default };
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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 && (
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,164 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect } from "react";
|
|
||||||
import { motion, useMotionValue, useTransform, PanInfo } from "motion/react";
|
|
||||||
|
|
||||||
interface HandlebarsProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
minWidth?: number;
|
|
||||||
maxWidth?: number;
|
|
||||||
onRangeChange?: (left: number, right: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Handlebars({
|
|
||||||
children,
|
|
||||||
minWidth = 50,
|
|
||||||
maxWidth = 400,
|
|
||||||
onRangeChange,
|
|
||||||
}: HandlebarsProps) {
|
|
||||||
const [leftHandle, setLeftHandle] = useState(0);
|
|
||||||
const [rightHandle, setRightHandle] = useState(maxWidth);
|
|
||||||
const [contentWidth, setContentWidth] = useState(maxWidth);
|
|
||||||
|
|
||||||
const leftHandleX = useMotionValue(0);
|
|
||||||
const rightHandleX = useMotionValue(maxWidth);
|
|
||||||
|
|
||||||
const visibleWidth = useTransform(
|
|
||||||
[leftHandleX, rightHandleX],
|
|
||||||
(values: number[]) => values[1] - values[0]
|
|
||||||
);
|
|
||||||
|
|
||||||
const contentLeft = useTransform(leftHandleX, (left: number) => -left);
|
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const measureRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!measureRef.current) return;
|
|
||||||
|
|
||||||
const measureContent = () => {
|
|
||||||
if (measureRef.current) {
|
|
||||||
const width = measureRef.current.scrollWidth;
|
|
||||||
const paddedWidth = width + 32;
|
|
||||||
setContentWidth(paddedWidth);
|
|
||||||
setRightHandle(paddedWidth);
|
|
||||||
rightHandleX.set(paddedWidth);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
measureContent();
|
|
||||||
const timer = setTimeout(measureContent, 50);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [children, rightHandleX]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
leftHandleX.set(leftHandle);
|
|
||||||
}, [leftHandle, leftHandleX]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
rightHandleX.set(rightHandle);
|
|
||||||
}, [rightHandle, rightHandleX]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onRangeChange?.(leftHandle, rightHandle);
|
|
||||||
}, [leftHandle, rightHandle, onRangeChange]);
|
|
||||||
|
|
||||||
const handleLeftDrag = (event: any, info: PanInfo) => {
|
|
||||||
const newLeft = Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(leftHandle + info.offset.x, rightHandle - minWidth)
|
|
||||||
);
|
|
||||||
setLeftHandle(newLeft);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRightDrag = (event: any, info: PanInfo) => {
|
|
||||||
const newRight = Math.max(
|
|
||||||
leftHandle + minWidth,
|
|
||||||
Math.min(contentWidth, rightHandle + info.offset.x)
|
|
||||||
);
|
|
||||||
setRightHandle(newRight);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
|
|
||||||
<div
|
|
||||||
ref={measureRef}
|
|
||||||
className="absolute -left-[9999px] top-0 invisible px-4 whitespace-nowrap font-[inherit]"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2"
|
|
||||||
style={{ width: contentWidth }}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between">
|
|
||||||
<motion.div
|
|
||||||
className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
x: leftHandleX,
|
|
||||||
left: 0,
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
drag="x"
|
|
||||||
dragConstraints={{ left: 0, right: rightHandle - minWidth }}
|
|
||||||
dragElastic={0}
|
|
||||||
dragMomentum={false}
|
|
||||||
onDrag={handleLeftDrag}
|
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
whileDrag={{ scale: 1.1 }}
|
|
||||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
|
||||||
>
|
|
||||||
<div className="w-2 h-8 rounded-full bg-yellow-500"></div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
x: rightHandleX,
|
|
||||||
left: -30,
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
drag="x"
|
|
||||||
dragConstraints={{
|
|
||||||
left: leftHandle + minWidth,
|
|
||||||
right: contentWidth,
|
|
||||||
}}
|
|
||||||
dragElastic={0}
|
|
||||||
dragMomentum={false}
|
|
||||||
onDrag={handleRightDrag}
|
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
whileDrag={{ scale: 1.1 }}
|
|
||||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
|
||||||
>
|
|
||||||
<div className="w-2 h-8 rounded-full bg-yellow-500"></div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="relative overflow-hidden rounded-2xl"
|
|
||||||
style={{
|
|
||||||
width: visibleWidth,
|
|
||||||
x: leftHandleX,
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
className="w-full h-full flex items-center justify-center px-4"
|
|
||||||
style={{
|
|
||||||
x: contentLeft,
|
|
||||||
width: contentWidth,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -4,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,73 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export function RenameProjectDialog({
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
onConfirm,
|
|
||||||
projectName,
|
|
||||||
}: {
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onConfirm: (name: string) => void;
|
|
||||||
projectName: string;
|
|
||||||
}) {
|
|
||||||
const [name, setName] = useState(projectName);
|
|
||||||
|
|
||||||
// Reset the name when dialog opens - this is better UX than syncing with every prop change
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
|
||||||
if (open) {
|
|
||||||
setName(projectName);
|
|
||||||
}
|
|
||||||
onOpenChange(open);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Rename Project</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Enter a new name for your project.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
onConfirm(name);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Enter a new name"
|
|
||||||
className="mt-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpenChange(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => onConfirm(name)}>Rename</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,80 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
|
||||||
import { useMediaStore } from "@/stores/media-store";
|
|
||||||
import { storageService } from "@/lib/storage/storage-service";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface StorageContextType {
|
|
||||||
isInitialized: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
hasSupport: boolean;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StorageContext = createContext<StorageContextType | null>(null);
|
|
||||||
|
|
||||||
export function useStorage() {
|
|
||||||
const context = useContext(StorageContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useStorage must be used within StorageProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StorageProviderProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StorageProvider({ children }: StorageProviderProps) {
|
|
||||||
const [status, setStatus] = useState<StorageContextType>({
|
|
||||||
isInitialized: false,
|
|
||||||
isLoading: true,
|
|
||||||
hasSupport: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadAllProjects = useProjectStore((state) => state.loadAllProjects);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const initializeStorage = async () => {
|
|
||||||
setStatus((prev) => ({ ...prev, isLoading: true }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check browser support
|
|
||||||
const hasSupport = storageService.isFullySupported();
|
|
||||||
|
|
||||||
if (!hasSupport) {
|
|
||||||
toast.warning(
|
|
||||||
"Storage not fully supported. Some features may not work."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load saved projects (media will be loaded when a project is loaded)
|
|
||||||
await loadAllProjects();
|
|
||||||
|
|
||||||
setStatus({
|
|
||||||
isInitialized: true,
|
|
||||||
isLoading: false,
|
|
||||||
hasSupport,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to initialize storage:", error);
|
|
||||||
setStatus({
|
|
||||||
isInitialized: false,
|
|
||||||
isLoading: false,
|
|
||||||
hasSupport: storageService.isFullySupported(),
|
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initializeStorage();
|
|
||||||
}, [loadAllProjects]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StorageContext.Provider value={status}>{children}</StorageContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,127 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef, useEffect } from "react";
|
|
||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
|
||||||
|
|
||||||
interface AudioPlayerProps {
|
|
||||||
src: string;
|
|
||||||
className?: string;
|
|
||||||
clipStartTime: number;
|
|
||||||
trimStart: number;
|
|
||||||
trimEnd: number;
|
|
||||||
clipDuration: number;
|
|
||||||
trackMuted?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AudioPlayer({
|
|
||||||
src,
|
|
||||||
className = "",
|
|
||||||
clipStartTime,
|
|
||||||
trimStart,
|
|
||||||
trimEnd,
|
|
||||||
clipDuration,
|
|
||||||
trackMuted = false,
|
|
||||||
}: AudioPlayerProps) {
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
|
||||||
const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
|
|
||||||
|
|
||||||
// Calculate if we're within this clip's timeline range
|
|
||||||
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
|
|
||||||
const isInClipRange =
|
|
||||||
currentTime >= clipStartTime && currentTime < clipEndTime;
|
|
||||||
|
|
||||||
// Sync playback events
|
|
||||||
useEffect(() => {
|
|
||||||
const audio = audioRef.current;
|
|
||||||
if (!audio || !isInClipRange) return;
|
|
||||||
|
|
||||||
const handleSeekEvent = (e: CustomEvent) => {
|
|
||||||
// Always update audio time, even if outside clip range
|
|
||||||
const timelineTime = e.detail.time;
|
|
||||||
const audioTime = Math.max(
|
|
||||||
trimStart,
|
|
||||||
Math.min(
|
|
||||||
clipDuration - trimEnd,
|
|
||||||
timelineTime - clipStartTime + trimStart
|
|
||||||
)
|
|
||||||
);
|
|
||||||
audio.currentTime = audioTime;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateEvent = (e: CustomEvent) => {
|
|
||||||
// Always update audio time, even if outside clip range
|
|
||||||
const timelineTime = e.detail.time;
|
|
||||||
const targetTime = Math.max(
|
|
||||||
trimStart,
|
|
||||||
Math.min(
|
|
||||||
clipDuration - trimEnd,
|
|
||||||
timelineTime - clipStartTime + trimStart
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (Math.abs(audio.currentTime - targetTime) > 0.5) {
|
|
||||||
audio.currentTime = targetTime;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSpeed = (e: CustomEvent) => {
|
|
||||||
audio.playbackRate = e.detail.speed;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
|
|
||||||
window.addEventListener(
|
|
||||||
"playback-update",
|
|
||||||
handleUpdateEvent as EventListener
|
|
||||||
);
|
|
||||||
window.addEventListener("playback-speed", handleSpeed as EventListener);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener(
|
|
||||||
"playback-seek",
|
|
||||||
handleSeekEvent as EventListener
|
|
||||||
);
|
|
||||||
window.removeEventListener(
|
|
||||||
"playback-update",
|
|
||||||
handleUpdateEvent as EventListener
|
|
||||||
);
|
|
||||||
window.removeEventListener(
|
|
||||||
"playback-speed",
|
|
||||||
handleSpeed as EventListener
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
|
|
||||||
|
|
||||||
// Sync playback state
|
|
||||||
useEffect(() => {
|
|
||||||
const audio = audioRef.current;
|
|
||||||
if (!audio) return;
|
|
||||||
|
|
||||||
if (isPlaying && isInClipRange && !trackMuted) {
|
|
||||||
audio.play().catch(() => {});
|
|
||||||
} else {
|
|
||||||
audio.pause();
|
|
||||||
}
|
|
||||||
}, [isPlaying, isInClipRange, trackMuted]);
|
|
||||||
|
|
||||||
// Sync volume and speed
|
|
||||||
useEffect(() => {
|
|
||||||
const audio = audioRef.current;
|
|
||||||
if (!audio) return;
|
|
||||||
|
|
||||||
audio.volume = volume;
|
|
||||||
audio.muted = muted || trackMuted;
|
|
||||||
audio.playbackRate = speed;
|
|
||||||
}, [volume, speed, muted, trackMuted]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<audio
|
|
||||||
ref={audioRef}
|
|
||||||
src={src}
|
|
||||||
className={className}
|
|
||||||
preload="auto"
|
|
||||||
controls={false}
|
|
||||||
style={{ display: "none" }} // Audio elements don't need visual representation
|
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -10,8 +10,6 @@ const buttonVariants = cva(
|
|||||||
variants: {
|
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",
|
||||||
},
|
},
|
||||||
|
@ -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}
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -1,150 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ReactNode, useState, useRef, useEffect } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { Plus } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export interface DraggableMediaItemProps {
|
|
||||||
name: string;
|
|
||||||
preview: ReactNode;
|
|
||||||
dragData: Record<string, any>;
|
|
||||||
onDragStart?: (e: React.DragEvent) => void;
|
|
||||||
aspectRatio?: number;
|
|
||||||
className?: string;
|
|
||||||
showPlusOnDrag?: boolean;
|
|
||||||
showLabel?: boolean;
|
|
||||||
rounded?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DraggableMediaItem({
|
|
||||||
name,
|
|
||||||
preview,
|
|
||||||
dragData,
|
|
||||||
onDragStart,
|
|
||||||
aspectRatio = 16 / 9,
|
|
||||||
className = "",
|
|
||||||
showPlusOnDrag = true,
|
|
||||||
showLabel = true,
|
|
||||||
rounded = true,
|
|
||||||
}: DraggableMediaItemProps) {
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
|
||||||
const dragRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const emptyImg = new window.Image();
|
|
||||||
emptyImg.src =
|
|
||||||
"data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDragging) return;
|
|
||||||
|
|
||||||
const handleDragOver = (e: DragEvent) => {
|
|
||||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("dragover", handleDragOver);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("dragover", handleDragOver);
|
|
||||||
};
|
|
||||||
}, [isDragging]);
|
|
||||||
|
|
||||||
const handleDragStart = (e: React.DragEvent) => {
|
|
||||||
e.dataTransfer.setDragImage(emptyImg, 0, 0);
|
|
||||||
|
|
||||||
// Set drag data
|
|
||||||
e.dataTransfer.setData(
|
|
||||||
"application/x-media-item",
|
|
||||||
JSON.stringify(dragData)
|
|
||||||
);
|
|
||||||
e.dataTransfer.effectAllowed = "copy";
|
|
||||||
|
|
||||||
// Set initial position and show custom drag preview
|
|
||||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
|
||||||
setIsDragging(true);
|
|
||||||
|
|
||||||
onDragStart?.(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
|
||||||
setIsDragging(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div ref={dragRef} className="relative group w-28 h-28">
|
|
||||||
<div
|
|
||||||
className={`flex flex-col gap-1 p-0 h-auto w-full relative cursor-default ${className}`}
|
|
||||||
>
|
|
||||||
<AspectRatio
|
|
||||||
ratio={aspectRatio}
|
|
||||||
className={cn(
|
|
||||||
"bg-accent relative overflow-hidden",
|
|
||||||
rounded && "rounded-md",
|
|
||||||
"[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding
|
|
||||||
)}
|
|
||||||
draggable={true}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
{preview}
|
|
||||||
{!isDragging && (
|
|
||||||
<PlusButton className="opacity-0 group-hover:opacity-100" />
|
|
||||||
)}
|
|
||||||
</AspectRatio>
|
|
||||||
{showLabel && (
|
|
||||||
<span
|
|
||||||
className="text-[0.7rem] text-muted-foreground truncate w-full text-left"
|
|
||||||
aria-label={name}
|
|
||||||
title={name}
|
|
||||||
>
|
|
||||||
{name.length > 8
|
|
||||||
? `${name.slice(0, 16)}...${name.slice(-3)}`
|
|
||||||
: name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Custom drag preview */}
|
|
||||||
{isDragging &&
|
|
||||||
typeof document !== "undefined" &&
|
|
||||||
createPortal(
|
|
||||||
<div
|
|
||||||
className="fixed pointer-events-none z-[9999]"
|
|
||||||
style={{
|
|
||||||
left: dragPosition.x - 40, // Center the preview (half of 80px)
|
|
||||||
top: dragPosition.y - 40, // Center the preview (half of 80px)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="w-[80px]">
|
|
||||||
<AspectRatio
|
|
||||||
ratio={1}
|
|
||||||
className="relative rounded-md overflow-hidden shadow-2xl ring ring-primary"
|
|
||||||
>
|
|
||||||
<div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none">
|
|
||||||
{preview}
|
|
||||||
</div>
|
|
||||||
{showPlusOnDrag && <PlusButton />}
|
|
||||||
</AspectRatio>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlusButton({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
className={cn("absolute bottom-2 right-2 size-4", className)}
|
|
||||||
>
|
|
||||||
<Plus className="!size-3" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
@ -19,33 +19,16 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
|||||||
|
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
const 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}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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(
|
||||||
|
@ -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 };
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|