Compare commits
374 Commits
izadoesdev
...
main
Author | SHA1 | Date | |
---|---|---|---|
b0e9901730 | |||
3b4b7e2009 | |||
3037a7ecbf | |||
ad7ace3fd2 | |||
d0769fdcaa | |||
9756559811 | |||
0ee043b319 | |||
9d828063c1 | |||
70517fec18 | |||
22db0b8b89 | |||
71f666c6d7 | |||
e57ca15dc6 | |||
e96832bf9b | |||
0a0f68d711 | |||
57dace960c | |||
145d01c8e4 | |||
25d12a6fa4 | |||
a9b02df2e3 | |||
b76e84354d | |||
233f78bf52 | |||
f0b0451616 | |||
4d0382cc4b | |||
800e720e6e | |||
457d828c45 | |||
d925b62a47 | |||
84d153e2c5 | |||
27da1838cc | |||
8545d95070 | |||
0726c27221 | |||
2de4c7c153 | |||
ba809effb6 | |||
09577b712a | |||
da06c5946d | |||
d48a3fe287 | |||
b4c93182d0 | |||
799fd2981a | |||
ac1eef7bd0 | |||
6e6f5211fc | |||
338e13a601 | |||
410f8da1c9 | |||
6ecc359d80 | |||
fd017d6aca | |||
4d8760d0e1 | |||
9b78503562 | |||
76229a1da5 | |||
ac0d089bf7 | |||
4d67e366ad | |||
6c19dbb6bb | |||
d643a9a277 | |||
92d534760d | |||
4880e3b10c | |||
3a241d9112 | |||
bb65d4fb96 | |||
eabcdb0988 | |||
c86d200297 | |||
3d6786a587 | |||
f43021e994 | |||
4fc14947ad | |||
6c59fed5c0 | |||
7ec9167aeb | |||
f984f615ce | |||
fe6492f359 | |||
ed4e9dad19 | |||
eadd6940e4 | |||
0acead5bb1 | |||
98d536a474 | |||
9bbb42c357 | |||
445d01fc8f | |||
3ada352730 | |||
6aa071ef8d | |||
3e45be5c47 | |||
e8b0057cc4 | |||
055a6af055 | |||
1376bee16d | |||
0f175b232f | |||
aa0482b012 | |||
0223c34a1e | |||
44f504f401 | |||
d9d54df431 | |||
4d0c3268cc | |||
5e74906e19 | |||
2fe67febd6 | |||
3a34485cc7 | |||
1459cd7232 | |||
f8e8de4438 | |||
8dd6f9a9b3 | |||
7bf6671c0a | |||
b6aa8e10d6 | |||
6ba1021149 | |||
059a4f4205 | |||
f4fbdf14a9 | |||
db8cd93a99 | |||
27d65ca7c5 | |||
53d6d0e1af | |||
3089fb0418 | |||
346368cf75 | |||
5e1f780fff | |||
3d685f57dd | |||
612fc03cde | |||
aadc253fee | |||
c02f276303 | |||
dd35c91f39 | |||
51894544b2 | |||
c5d96a0ded | |||
612fa55937 | |||
9c25814717 | |||
9c8985d115 | |||
7a706f3bbc | |||
44ff4fe638 | |||
e5892fdea6 | |||
ad45c8c1ed | |||
c6cfb8ce87 | |||
813dbcb9c2 | |||
66da1e20d3 | |||
3ef17cecb4 | |||
24b9c89084 | |||
c60098987f | |||
60a1273206 | |||
91d89f56d7 | |||
b5d04d591f | |||
e26c3bccdb | |||
74408541fd | |||
65afd3f18b | |||
ea59cc3950 | |||
c0cc4c009e | |||
d750d7f41d | |||
9d2fd50fbc | |||
d36df2fb62 | |||
acda7064bd | |||
f3763b8465 | |||
bd0c7f2206 | |||
11c0b89bd1 | |||
25c9ffc131 | |||
85a93ce090 | |||
6edd5b36cf | |||
40c7fbb4f8 | |||
0e32c732dd | |||
13b2fad50f | |||
3d1efeaf36 | |||
163489f499 | |||
562cf38341 | |||
d04ba1468e | |||
4728884931 | |||
fb9f47117c | |||
9dbfa980c2 | |||
8be05901fb | |||
37d684748f | |||
b5af50b0d8 | |||
c413b53c33 | |||
434a832d8e | |||
1372c218ad | |||
8f365915a2 | |||
f991d707ea | |||
59a6c539a1 | |||
ef0828a13d | |||
b90f9922a1 | |||
d3d5bbf51a | |||
b1ade266e5 | |||
a4d7bdda24 | |||
bc3fbec541 | |||
dcf3fccca1 | |||
5d02169d63 | |||
d95b7a9316 | |||
8bf865df0d | |||
b14e9e82fe | |||
394d9f684c | |||
5dfe9c0aac | |||
a3309b4c45 | |||
fe289db9b0 | |||
849fb3d2af | |||
364e541d57 | |||
d623ba6b4b | |||
fb487681b6 | |||
a16c86092a | |||
baf5e9907f | |||
e4683e38db | |||
ac4ff63438 | |||
ee973cad21 | |||
9c8594d8f3 | |||
c37c64c1b9 | |||
1a01871cfc | |||
9b37ce6610 | |||
d11d835c7c | |||
1fa4c9c72f | |||
50e3d92b92 | |||
9e01efdc88 | |||
011be3d9a5 | |||
b474ad6b15 | |||
3e916f0f00 | |||
d50cd0b40d | |||
d0ae75d0b4 | |||
09373eb4a3 | |||
cd30c205b4 | |||
16a319f2e4 | |||
1466dd42e2 | |||
b461234c65 | |||
822323d883 | |||
ca29be23ff | |||
796308e68e | |||
507d6a6a7e | |||
c414b83bc4 | |||
e4f2ce9221 | |||
bfba482098 | |||
3bc00f8e40 | |||
02d7a92e06 | |||
4e0352d4d6 | |||
8aa0aeb6e3 | |||
90eaa40bc6 | |||
306c2885f1 | |||
5a6872537e | |||
8fe09c83b1 | |||
901d0baafd | |||
8934cc610f | |||
cdfb5ea7b0 | |||
dc99516a3f | |||
1f257d30dc | |||
f53f2802eb | |||
c32daa4f2e | |||
87e90a5e24 | |||
089f7f8d71 | |||
01140fd7bb | |||
dfde7592bb | |||
6391147a96 | |||
679ebc02b5 | |||
d5db470150 | |||
f3c45ee892 | |||
d9809c70c3 | |||
17ef810074 | |||
ebb3bc89c3 | |||
6d98c2af24 | |||
0bdbd7e2b3 | |||
6262f2b379 | |||
3f0fe9d20e | |||
0383000ada | |||
a816cc503f | |||
f74bebeb8b | |||
7ca5bcfa50 | |||
efdd2aa6ed | |||
12a2ec59fd | |||
b799615654 | |||
3224dd974a | |||
49787bdfe5 | |||
fff95afbc6 | |||
f76555dae5 | |||
53184217bf | |||
1284c232a3 | |||
b428a28aea | |||
23f118969b | |||
b10bb8ed55 | |||
d4048772ad | |||
063094e70a | |||
cd88a92c6d | |||
70352ced92 | |||
896f2ee554 | |||
b66e85cf4d | |||
46614d4008 | |||
2d7f2f8503 | |||
449673b79e | |||
4f74018648 | |||
4ed9858725 | |||
2f5bde1051 | |||
6626a8c413 | |||
ebb3456c10 | |||
a8d6bbd03a | |||
0f470ba7a7 | |||
029152dd5b | |||
d6a2de21d0 | |||
53e88df0d7 | |||
dd80064be6 | |||
e225272ec3 | |||
b01421f115 | |||
75eede20af | |||
e23cf66373 | |||
2f3a148dd4 | |||
e5fc3f9bbb | |||
0c97bc8c3f | |||
3eac1bcb0b | |||
3ea6b00254 | |||
e7dabd1444 | |||
389a546478 | |||
4fb0d014af | |||
c0684ca62d | |||
ef806ceab8 | |||
515be65bc4 | |||
4fe0ff3010 | |||
e1481391c7 | |||
777b0f7000 | |||
926aebe004 | |||
2775ac427d | |||
f5c546d416 | |||
d1e313450d | |||
27b0e51265 | |||
ebcb898e4e | |||
ffb6a35e20 | |||
2ddd8cb0d7 | |||
baa5c43e7c | |||
bf64ec1133 | |||
de3d3e210e | |||
3bb640c453 | |||
0f343dfbda | |||
ba14d65605 | |||
94d39dfa7e | |||
00d7b3ba8d | |||
266d3bc0a2 | |||
b94cc121d2 | |||
181d3fca06 | |||
57bfb7610f | |||
5977a5d253 | |||
0723623eaf | |||
f9d0be20d0 | |||
a511c57729 | |||
76c3293ac4 | |||
a969f3df86 | |||
cca25a6103 | |||
c322ad1124 | |||
2e5b623d90 | |||
e786d437a5 | |||
0ae852c185 | |||
db60354349 | |||
ba9870a908 | |||
e7d35c667f | |||
067b6e3d73 | |||
2326faf526 | |||
9ba57d86b6 | |||
92a8466368 | |||
56a8098442 | |||
6a666efa39 | |||
07351ca7e6 | |||
d9fcef9385 | |||
7f0bcea4ca | |||
903cdf5fff | |||
354dbbd0f9 | |||
51a83f9f21 | |||
e2a0f745c8 | |||
c18b173bc9 | |||
a4201c8be6 | |||
1722055a72 | |||
01b9dbccc2 | |||
d39edd8521 | |||
3a6cfd73e4 | |||
e14e137212 | |||
fe1a2542cc | |||
66eb6f13d5 | |||
4db515df51 | |||
992b0cbf23 | |||
7444ab2894 | |||
fd2cf591b7 | |||
d8494185e0 | |||
3989da93c3 | |||
10d11a5baa | |||
998ee086e5 | |||
402b004ef3 | |||
affa239f7b | |||
151b3dfc51 | |||
4a8ff91ae3 | |||
411a9a5f1c | |||
b8057cdd30 | |||
e01142b798 | |||
7461b763b1 | |||
1f2c505efe | |||
547f566fc2 | |||
7de09adce6 | |||
84d206c01e | |||
22ae5d0e37 | |||
bc28ce09cb | |||
a9293ffb48 | |||
274d03185b | |||
9bb19712e5 | |||
bc3fbabfb2 | |||
9354d69acf | |||
f8a4cd4ac6 | |||
5fc205fc57 | |||
8f583c25e0 | |||
27471ba532 |
5
.github/CONTRIBUTING.md
vendored
@ -10,6 +10,11 @@ Thank you for your interest in contributing to OpenCut! This document provides g
|
||||
4. Install dependencies: `bun install`
|
||||
5. Start the development server: `bun run dev`
|
||||
|
||||
> **Note:** If you see an error like `Unsupported URL Type "workspace:*"` when running `npm install`, you have two options:
|
||||
>
|
||||
> 1. Upgrade to a recent npm version (v9 or later), which has full workspace protocol support.
|
||||
> 2. Use an alternative package manager such as **bun** or **pnpm**.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,31 +0,0 @@
|
||||
---
|
||||
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
Normal file
@ -0,0 +1,70 @@
|
||||
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
@ -1,19 +0,0 @@
|
||||
---
|
||||
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
Normal file
@ -0,0 +1,42 @@
|
||||
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
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
|
||||
with:
|
||||
bun-version: 1.2.2
|
||||
bun-version: 1.2.18
|
||||
|
||||
- name: Cache Bun modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('apps/web/bun.lock') }}
|
||||
key: ${{ runner.os }}-bun-1.2.18-${{ hashFiles('apps/web/bun.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/web
|
||||
|
5
.gitignore
vendored
@ -28,3 +28,8 @@ node_modules
|
||||
.turbo
|
||||
|
||||
*.env
|
||||
|
||||
# cursor
|
||||
|
||||
.cursor/
|
||||
bun.lockb
|
162
README.md
@ -1,12 +1,14 @@
|
||||
<img src="apps/web/public/logo.png" align="left" width="130" height="130">
|
||||
|
||||
<div align="right">
|
||||
|
||||
|
||||
|
||||
# OpenCut (prev AppCut)
|
||||
### A free, open-source video editor for web, desktop, and mobile.
|
||||
</div>
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td align="left" width="120">
|
||||
<img src="apps/web/public/logo.png" alt="OpenCut Logo" width="100" />
|
||||
</td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Why?
|
||||
|
||||
@ -43,72 +45,122 @@ Before you begin, ensure you have the following installed on your system:
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd OpenCut
|
||||
```
|
||||
## Getting Started
|
||||
|
||||
2. **Start backend services**
|
||||
From the project root, start the PostgreSQL and Redis services:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
1. Fork the repository
|
||||
2. Clone your fork locally
|
||||
3. Navigate to the web app directory: `cd apps/web`
|
||||
4. Install dependencies: `bun install`
|
||||
5. Start the development server: `bun run dev`
|
||||
|
||||
3. **Set up environment variables**
|
||||
Navigate into the web app's directory and create a `.env` file from the example:
|
||||
```bash
|
||||
cd apps/web
|
||||
cp .env.example .env
|
||||
```
|
||||
*The default values in the `.env` file should work for local development.*
|
||||
## Development Setup
|
||||
|
||||
4. **Install dependencies**
|
||||
Install the project dependencies using `bun` (recommended) or `npm`.
|
||||
```bash
|
||||
# With bun
|
||||
bun install
|
||||
### Prerequisites
|
||||
|
||||
# Or with npm
|
||||
npm install
|
||||
```
|
||||
- Node.js 18+
|
||||
- Bun (latest version)
|
||||
- Docker (for local database)
|
||||
|
||||
5. **Run database migrations**
|
||||
Apply the database schema to your local database:
|
||||
```bash
|
||||
# With bun
|
||||
bun run db:push:local
|
||||
### Local Development
|
||||
|
||||
# Or with npm
|
||||
npm run db:push:local
|
||||
```
|
||||
1. Start the database and Redis services:
|
||||
|
||||
6. **Start the development server**
|
||||
```bash
|
||||
# With bun
|
||||
bun run dev
|
||||
```bash
|
||||
# From project root
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
# Or with npm
|
||||
npm run dev
|
||||
```
|
||||
2. Navigate to the web app directory:
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
```
|
||||
|
||||
3. Copy `.env.example` to `.env.local`:
|
||||
|
||||
```bash
|
||||
# Unix/Linux/Mac
|
||||
cp .env.example .env.local
|
||||
|
||||
# Windows Command Prompt
|
||||
copy .env.example .env.local
|
||||
|
||||
# Windows PowerShell
|
||||
Copy-Item .env.example .env.local
|
||||
```
|
||||
|
||||
4. Configure required environment variables in `.env.local`:
|
||||
|
||||
**Required Variables:**
|
||||
|
||||
```bash
|
||||
# Database (matches docker-compose.yaml)
|
||||
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
|
||||
|
||||
# Generate a secure secret for Better Auth
|
||||
BETTER_AUTH_SECRET="your-generated-secret-here"
|
||||
BETTER_AUTH_URL="http://localhost:3000"
|
||||
|
||||
# Redis (matches docker-compose.yaml)
|
||||
UPSTASH_REDIS_REST_URL="http://localhost:8079"
|
||||
UPSTASH_REDIS_REST_TOKEN="example_token"
|
||||
|
||||
# Development
|
||||
NODE_ENV="development"
|
||||
```
|
||||
|
||||
**Generate BETTER_AUTH_SECRET:**
|
||||
|
||||
```bash
|
||||
# Unix/Linux/Mac
|
||||
openssl rand -base64 32
|
||||
|
||||
# Windows PowerShell (simple method)
|
||||
[System.Web.Security.Membership]::GeneratePassword(32, 0)
|
||||
|
||||
# Cross-platform (using Node.js)
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
|
||||
# Or use an online generator: https://generate-secret.vercel.app/32
|
||||
```
|
||||
|
||||
**Optional Variables (for Google OAuth):**
|
||||
|
||||
```bash
|
||||
# Only needed if you want to test Google login
|
||||
GOOGLE_CLIENT_ID="your-google-client-id"
|
||||
GOOGLE_CLIENT_SECRET="your-google-client-secret"
|
||||
```
|
||||
|
||||
5. Run database migrations: `bun run db:migrate` from (inside apps/web)
|
||||
6. Start the development server: `bun run dev` from (inside apps/web)
|
||||
|
||||
The application will be available at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
=======
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
||||
=======
|
||||
**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)
|
||||
|
||||
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
|
||||
|
||||
Quick start for contributors:
|
||||
**Quick start for contributors:**
|
||||
|
||||
- Fork the repo and clone locally
|
||||
- Follow the setup instructions in CONTRIBUTING.md
|
||||
- Create a feature branch and submit a PR
|
||||
|
||||
## Sponsors
|
||||
|
||||
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FOpenCut-app%2FOpenCut&project-name=opencut&repository-name=opencut)
|
||||
|
||||
## License
|
||||
|
||||
[MIT LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
@ -1,30 +1,45 @@
|
||||
FROM oven/bun:latest AS base
|
||||
FROM oven/bun:alpine AS base
|
||||
|
||||
# Install dependencies
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Build the application
|
||||
# Install dependencies and build the application
|
||||
FROM base AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
COPY package.json package.json
|
||||
COPY bun.lock bun.lock
|
||||
COPY turbo.json turbo.json
|
||||
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
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
|
||||
|
||||
# Production image
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
|
||||
RUN chown nextjs:nodejs apps
|
||||
|
||||
USER nextjs
|
||||
|
||||
@ -33,4 +48,4 @@ EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["bun", "server.js"]
|
||||
CMD ["bun", "apps/web/server.js"]
|
@ -13,7 +13,7 @@ if (!process.env.DATABASE_URL) {
|
||||
}
|
||||
|
||||
export default {
|
||||
schema: "./src/lib/db/schema.ts",
|
||||
schema: "../../packages/db/src/schema.ts",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL,
|
||||
|
@ -6,6 +6,19 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
reactStrictMode: true,
|
||||
productionBrowserSourceMaps: true,
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "plus.unsplash.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
@ -2,9 +2,9 @@
|
||||
"name": "opencut",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1.2.17",
|
||||
"packageManager": "bun@1.2.18",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
@ -21,7 +21,6 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@opencut/auth": "workspace:*",
|
||||
"@opencut/db": "workspace:*",
|
||||
"@types/pg": "^8.15.4",
|
||||
"@upstash/ratelimit": "^2.0.5",
|
||||
"@upstash/redis": "^1.35.0",
|
||||
"@vercel/analytics": "^1.4.1",
|
||||
@ -57,6 +56,7 @@
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.15.4",
|
||||
"@types/bun": "latest",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
|
11
apps/web/public/browserconfig.xml
Normal file
@ -0,0 +1,11 @@
|
||||
<?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>
|
BIN
apps/web/public/favicon.ico
Normal file
After Width: | Height: | Size: 1.1 KiB |
10
apps/web/public/frame.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<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>
|
After Width: | Height: | Size: 716 B |
BIN
apps/web/public/icons/android-icon-144x144.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/web/public/icons/android-icon-192x192.png
Normal file
After Width: | Height: | Size: 741 B |
BIN
apps/web/public/icons/android-icon-36x36.png
Normal file
After Width: | Height: | Size: 768 B |
BIN
apps/web/public/icons/android-icon-48x48.png
Normal file
After Width: | Height: | Size: 802 B |
BIN
apps/web/public/icons/android-icon-72x72.png
Normal file
After Width: | Height: | Size: 826 B |
BIN
apps/web/public/icons/android-icon-96x96.png
Normal file
After Width: | Height: | Size: 906 B |
BIN
apps/web/public/icons/apple-icon-114x114.png
Normal file
After Width: | Height: | Size: 985 B |
BIN
apps/web/public/icons/apple-icon-120x120.png
Normal file
After Width: | Height: | Size: 998 B |
BIN
apps/web/public/icons/apple-icon-144x144.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/web/public/icons/apple-icon-152x152.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/web/public/icons/apple-icon-180x180.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
apps/web/public/icons/apple-icon-57x57.png
Normal file
After Width: | Height: | Size: 809 B |
BIN
apps/web/public/icons/apple-icon-60x60.png
Normal file
After Width: | Height: | Size: 843 B |
BIN
apps/web/public/icons/apple-icon-72x72.png
Normal file
After Width: | Height: | Size: 826 B |
BIN
apps/web/public/icons/apple-icon-76x76.png
Normal file
After Width: | Height: | Size: 820 B |
BIN
apps/web/public/icons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 670 B |
BIN
apps/web/public/icons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 747 B |
BIN
apps/web/public/icons/favicon-96x96.png
Normal file
After Width: | Height: | Size: 906 B |
BIN
apps/web/public/icons/ms-icon-144x144.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/web/public/icons/ms-icon-150x150.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/web/public/icons/ms-icon-310x310.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
apps/web/public/icons/ms-icon-70x70.png
Normal file
After Width: | Height: | Size: 814 B |
BIN
apps/web/public/landing-page-bg.png
Normal file
After Width: | Height: | Size: 225 KiB |
10
apps/web/public/logo.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<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>
|
After Width: | Height: | Size: 362 B |
44
apps/web/public/manifest.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
BIN
apps/web/public/opengraph-image.jpg
Normal file
After Width: | Height: | Size: 54 KiB |
@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "@opencut/auth/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@ -10,7 +9,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Suspense, useState } from "react";
|
||||
import { memo, Suspense } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@ -18,121 +17,22 @@ import Link from "next/link";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { GoogleIcon } from "@/components/icons";
|
||||
import { useLogin } from "@/hooks/auth/useLogin";
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setError(null);
|
||||
setIsEmailLoading(true);
|
||||
|
||||
const { error } = await signIn.email({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message || "An unexpected error occurred.");
|
||||
setIsEmailLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/editor");
|
||||
};
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
setError(null);
|
||||
setIsGoogleLoading(true);
|
||||
|
||||
try {
|
||||
await signIn.social({
|
||||
provider: "google",
|
||||
});
|
||||
router.push("/editor");
|
||||
} catch (error) {
|
||||
setError("Failed to sign in with Google. Please try again.");
|
||||
setIsGoogleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isAnyLoading = isEmailLoading || isGoogleLoading;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
disabled={isAnyLoading}
|
||||
>
|
||||
{isGoogleLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<GoogleIcon />
|
||||
)}{" "}
|
||||
Continue with Google
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator className="w-full" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
disabled={isAnyLoading || !email || !password}
|
||||
className="w-full h-11"
|
||||
size="lg"
|
||||
>
|
||||
{isEmailLoading ? <Loader2 className="animate-spin" /> : "Sign in"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const LoginPage = () => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
error,
|
||||
isAnyLoading,
|
||||
isEmailLoading,
|
||||
isGoogleLoading,
|
||||
handleLogin,
|
||||
handleGoogleLogin,
|
||||
} = useLogin();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center relative">
|
||||
@ -158,19 +58,85 @@ export default function LoginPage() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LoginForm />
|
||||
<div className="flex flex-col space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
disabled={isAnyLoading}
|
||||
>
|
||||
{isGoogleLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<GoogleIcon />
|
||||
)}{" "}
|
||||
Continue with Google
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator className="w-full" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
disabled={isAnyLoading || !email || !password}
|
||||
className="w-full h-11"
|
||||
size="lg"
|
||||
>
|
||||
{isEmailLoading ? <Loader2 className="animate-spin" /> : "Sign in"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href="/signup"
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</Suspense>
|
||||
<div className="mt-6 text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href="/signup"
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(LoginPage);
|
||||
|
@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signUp, signIn } from "@opencut/auth/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@ -10,151 +9,32 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Suspense, useState } from "react";
|
||||
import { memo, Suspense } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import Link from "next/link";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Loader2, ArrowLeft } from "lucide-react";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { GoogleIcon } from "@/components/icons";
|
||||
import { useSignUp } from "@/hooks/auth/useSignUp";
|
||||
|
||||
function SignUpForm() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
|
||||
const handleSignUp = async () => {
|
||||
setError(null);
|
||||
setIsEmailLoading(true);
|
||||
|
||||
const { error } = await signUp.email({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message || "An unexpected error occurred.");
|
||||
setIsEmailLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
const handleGoogleSignUp = async () => {
|
||||
setError(null);
|
||||
setIsGoogleLoading(true);
|
||||
|
||||
try {
|
||||
await signIn.social({
|
||||
provider: "google",
|
||||
});
|
||||
|
||||
router.push("/editor");
|
||||
} catch (error) {
|
||||
setError("Failed to sign up with Google. Please try again.");
|
||||
setIsGoogleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isAnyLoading = isEmailLoading || isGoogleLoading;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleGoogleSignUp}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
disabled={isAnyLoading}
|
||||
>
|
||||
{isGoogleLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<GoogleIcon />
|
||||
)}{" "}
|
||||
Continue with Google
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator className="w-full" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Create a strong password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSignUp}
|
||||
disabled={isAnyLoading || !name || !email || !password}
|
||||
className="w-full h-11"
|
||||
size="lg"
|
||||
>
|
||||
{isEmailLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
"Create account"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SignUpPage() {
|
||||
const SignUpPage = () => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
name,
|
||||
setName,
|
||||
email,
|
||||
setEmail,
|
||||
password,
|
||||
setPassword,
|
||||
error,
|
||||
isAnyLoading,
|
||||
isEmailLoading,
|
||||
isGoogleLoading,
|
||||
handleSignUp,
|
||||
handleGoogleSignUp,
|
||||
} = useSignUp();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center relative">
|
||||
@ -165,7 +45,6 @@ export default function SignUpPage() {
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" /> Back
|
||||
</Button>
|
||||
|
||||
<Card className="w-[400px] shadow-lg border-0">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-2xl font-semibold">
|
||||
@ -183,19 +62,101 @@ export default function SignUpPage() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SignUpForm />
|
||||
<div className="flex flex-col space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleGoogleSignUp}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
disabled={isAnyLoading}
|
||||
>
|
||||
{isGoogleLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<GoogleIcon />
|
||||
)}{" "}
|
||||
Continue with Google
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator className="w-full" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Create a strong password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isAnyLoading}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSignUp}
|
||||
disabled={isAnyLoading || !name || !email || !password}
|
||||
className="w-full h-11"
|
||||
size="lg"
|
||||
>
|
||||
{isEmailLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
"Create account"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</Suspense>
|
||||
<div className="mt-6 text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SignUpPage);
|
||||
|
5
apps/web/src/app/api/health/route.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return new Response("OK", { status: 200 });
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@opencut/db";
|
||||
import { db, eq } from "@opencut/db";
|
||||
import { waitlist } from "@opencut/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { waitlistRateLimit } from "@/lib/rate-limit";
|
||||
import { z } from "zod";
|
||||
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { GithubIcon } from "@/components/icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Contributors - OpenCut",
|
||||
@ -46,10 +47,10 @@ async function getContributors(): Promise<Contributor[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
const contributors = await response.json();
|
||||
const contributors = (await response.json()) as Contributor[];
|
||||
|
||||
const filteredContributors = contributors.filter(
|
||||
(contributor: any) => contributor.type === "User"
|
||||
(contributor: Contributor) => contributor.type === "User"
|
||||
);
|
||||
|
||||
return filteredContributors;
|
||||
@ -61,8 +62,8 @@ async function getContributors(): Promise<Contributor[]> {
|
||||
|
||||
export default async function ContributorsPage() {
|
||||
const contributors = await getContributors();
|
||||
const topContributor = contributors[0];
|
||||
const otherContributors = contributors.slice(1);
|
||||
const topContributors = contributors.slice(0, 2);
|
||||
const otherContributors = contributors.slice(2);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
@ -77,10 +78,15 @@ export default async function ContributorsPage() {
|
||||
<div className="relative container mx-auto px-4 py-16">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-20">
|
||||
<div className="inline-flex items-center gap-2 bg-muted/50 text-muted-foreground px-3 py-1 rounded-full text-sm mb-6">
|
||||
<GithubIcon className="h-3 w-3" />
|
||||
Open Source
|
||||
</div>
|
||||
<Link
|
||||
href={"https://github.com/OpenCut-app/OpenCut"}
|
||||
target="_blank"
|
||||
>
|
||||
<Badge variant="secondary" className="gap-2 mb-6">
|
||||
<GithubIcon className="h-3 w-3" />
|
||||
Open Source
|
||||
</Badge>
|
||||
</Link>
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
||||
Contributors
|
||||
</h1>
|
||||
@ -105,54 +111,56 @@ export default async function ContributorsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{topContributor && (
|
||||
{topContributors.length > 0 && (
|
||||
<div className="mb-20">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-2xl font-semibold mb-2">
|
||||
Top Contributor
|
||||
Top Contributors
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Leading the way in contributions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={topContributor.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group block"
|
||||
>
|
||||
<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" />
|
||||
<Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
|
||||
<CardContent className="p-8 text-center">
|
||||
<div className="relative mb-6">
|
||||
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
|
||||
<AvatarImage
|
||||
src={topContributor.avatar_url}
|
||||
alt={`${topContributor.login}'s avatar`}
|
||||
/>
|
||||
<AvatarFallback className="text-lg font-semibold">
|
||||
{topContributor.login.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="absolute -top-2 -right-2 bg-foreground text-background rounded-full w-8 h-8 flex items-center justify-center text-sm font-bold">
|
||||
1
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
|
||||
{topContributor.login}
|
||||
</h3>
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{topContributor.contributions}
|
||||
</span>
|
||||
<span>contributions</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex flex-col md:flex-row gap-6 justify-center max-w-4xl mx-auto">
|
||||
{topContributors.map((contributor, index) => (
|
||||
<Link
|
||||
key={contributor.id}
|
||||
href={contributor.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group block flex-1"
|
||||
>
|
||||
<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" />
|
||||
<Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
|
||||
<CardContent className="p-8 text-center">
|
||||
<div className="relative mb-6">
|
||||
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
|
||||
<AvatarImage
|
||||
src={contributor.avatar_url}
|
||||
alt={`${contributor.login}'s avatar`}
|
||||
/>
|
||||
<AvatarFallback className="text-lg font-semibold">
|
||||
{contributor.login.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
|
||||
{contributor.login}
|
||||
</h3>
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{contributor.contributions}
|
||||
</span>
|
||||
<span>contributions</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -167,7 +175,7 @@ export default async function ContributorsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
||||
{otherContributors.map((contributor, index) => (
|
||||
<Link
|
||||
key={contributor.id}
|
||||
@ -179,8 +187,8 @@ export default async function ContributorsPage() {
|
||||
animationDelay: `${index * 50}ms`,
|
||||
}}
|
||||
>
|
||||
<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 ring-2 ring-transparent group-hover:ring-muted-foreground/20 transition-all duration-300">
|
||||
<div className="text-center p-2 rounded-xl transition-all duration-300 hover:opacity-50">
|
||||
<Avatar className="h-16 w-16 mx-auto mb-3">
|
||||
<AvatarImage
|
||||
src={contributor.avatar_url}
|
||||
alt={`${contributor.login}'s avatar`}
|
||||
|
@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import "./editor.css";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "../../components/ui/resizable";
|
||||
import { MediaPanel } from "../../components/editor/media-panel";
|
||||
// import { PropertiesPanel } from "../../components/editor/properties-panel";
|
||||
import { Timeline } from "../../components/editor/timeline";
|
||||
import { PreviewPanel } from "../../components/editor/preview-panel";
|
||||
} from "../../../components/ui/resizable";
|
||||
import { MediaPanel } from "../../../components/editor/media-panel";
|
||||
import { PropertiesPanel } from "../../../components/editor/properties-panel";
|
||||
import { Timeline } from "../../../components/editor/timeline";
|
||||
import { PreviewPanel } from "../../../components/editor/preview-panel";
|
||||
import { EditorHeader } from "@/components/editor-header";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
@ -21,32 +21,47 @@ export default function Editor() {
|
||||
const {
|
||||
toolsPanel,
|
||||
previewPanel,
|
||||
propertiesPanel,
|
||||
mainContent,
|
||||
timeline,
|
||||
setToolsPanel,
|
||||
setPreviewPanel,
|
||||
setPropertiesPanel,
|
||||
setMainContent,
|
||||
setTimeline,
|
||||
propertiesPanel,
|
||||
setPropertiesPanel,
|
||||
} = usePanelStore();
|
||||
|
||||
const { activeProject, createNewProject } = useProjectStore();
|
||||
const { activeProject, loadProject, createNewProject } = useProjectStore();
|
||||
const params = useParams();
|
||||
const projectId = params.project_id as string;
|
||||
|
||||
usePlaybackControls();
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProject) {
|
||||
createNewProject("Untitled Project");
|
||||
}
|
||||
}, [activeProject, createNewProject]);
|
||||
const initializeProject = async () => {
|
||||
if (projectId && (!activeProject || activeProject.id !== projectId)) {
|
||||
try {
|
||||
await loadProject(projectId);
|
||||
} catch (error) {
|
||||
console.error("Failed to load project:", error);
|
||||
// If project doesn't exist, create a new one
|
||||
await createNewProject("Untitled Project");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeProject();
|
||||
}, [projectId, activeProject, loadProject, createNewProject]);
|
||||
|
||||
return (
|
||||
<EditorProvider>
|
||||
<div className="h-screen w-screen flex flex-col bg-background overflow-hidden">
|
||||
<EditorHeader />
|
||||
<div className="flex-1 min-h-0 min-w-0">
|
||||
<ResizablePanelGroup direction="vertical" className="h-full w-full">
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
minSize={30}
|
||||
@ -55,7 +70,10 @@ export default function Editor() {
|
||||
className="min-h-0"
|
||||
>
|
||||
{/* Main content area */}
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-[0.19rem] px-2"
|
||||
>
|
||||
{/* Tools Panel */}
|
||||
<ResizablePanel
|
||||
defaultSize={toolsPanel}
|
||||
@ -81,8 +99,7 @@ export default function Editor() {
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Properties Panel - Hidden for now but ready */}
|
||||
{/* <ResizablePanel
|
||||
<ResizablePanel
|
||||
defaultSize={propertiesPanel}
|
||||
minSize={15}
|
||||
maxSize={40}
|
||||
@ -90,7 +107,7 @@ export default function Editor() {
|
||||
className="min-w-0"
|
||||
>
|
||||
<PropertiesPanel />
|
||||
</ResizablePanel> */}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
|
||||
@ -102,7 +119,7 @@ export default function Editor() {
|
||||
minSize={15}
|
||||
maxSize={70}
|
||||
onResize={setTimeline}
|
||||
className="min-h-0"
|
||||
className="min-h-0 px-2 pb-2"
|
||||
>
|
||||
<Timeline />
|
||||
</ResizablePanel>
|
@ -1,4 +0,0 @@
|
||||
/* Prevent scroll jumping on Mac devices when using the editor */
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
Before Width: | Height: | Size: 4.2 KiB |
@ -39,13 +39,13 @@
|
||||
--sidebar-ring: 0 0% 3.9%;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 8%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--background: 0 0% 4%;
|
||||
--foreground: 0 0% 89%;
|
||||
--card: 0 0% 14.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover: 0 0% 14.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary: 180 95% 40%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
@ -55,7 +55,7 @@
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 100% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--border: 0 0% 17%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
@ -71,6 +71,8 @@
|
||||
--sidebar-accent-foreground: 0 0% 98%;
|
||||
--sidebar-border: 0 0% 14.9%;
|
||||
--sidebar-ring: 0 0% 83.1%;
|
||||
--panel-background: 0 0% 11%;
|
||||
--panel-accent: 0 0% 15%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,5 +82,7 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Prevent back/forward swipe */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
}
|
||||
|
@ -1,22 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import Script from "next/script";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { TooltipProvider } from "../components/ui/tooltip";
|
||||
import { DevelopmentDebug } from "../components/development-debug";
|
||||
import { StorageProvider } from "../components/storage-provider";
|
||||
import { baseMetaData } from "./metadata";
|
||||
import { defaultFont } from "../lib/font-config";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "OpenCut",
|
||||
description:
|
||||
"A simple but powerful video editor that gets the job done. In your browser.",
|
||||
};
|
||||
export const metadata = baseMetaData;
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
@ -25,21 +18,23 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<ThemeProvider attribute="class" forcedTheme="dark" enableSystem>
|
||||
<body className={`${defaultFont.className} font-sans antialiased`}>
|
||||
<ThemeProvider attribute="class" forcedTheme="dark">
|
||||
<TooltipProvider>
|
||||
{children}
|
||||
<StorageProvider>{children}</StorageProvider>
|
||||
<Analytics />
|
||||
<Toaster />
|
||||
<DevelopmentDebug />
|
||||
<Script
|
||||
src="https://app.databuddy.cc/databuddy.js"
|
||||
src="https://cdn.databuddy.cc/databuddy.js"
|
||||
strategy="afterInteractive"
|
||||
async
|
||||
data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
|
||||
data-track-attributes={true}
|
||||
data-track-attributes={false}
|
||||
data-track-errors={true}
|
||||
data-track-outgoing-links={true}
|
||||
data-track-web-vitals={true}
|
||||
data-track-outgoing-links={false}
|
||||
data-track-web-vitals={false}
|
||||
data-track-sessions={false}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
|
66
apps/web/src/app/metadata.ts
Normal file
@ -0,0 +1,66 @@
|
||||
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,5 +1,6 @@
|
||||
import { Hero } from "@/components/landing/hero";
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { getWaitlistCount } from "@/lib/waitlist";
|
||||
|
||||
// Force dynamic rendering so waitlist count updates in real-time
|
||||
@ -12,6 +13,7 @@ export default async function Home() {
|
||||
<div>
|
||||
<Header />
|
||||
<Hero signupCount={signupCount} />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
552
apps/web/src/app/projects/page.tsx
Normal file
@ -0,0 +1,552 @@
|
||||
"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>
|
||||
);
|
||||
}
|
191
apps/web/src/app/why-not-capcut/page.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import { Header } from "@/components/header";
|
||||
|
||||
export default function WhyNotCapcut() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background px-5">
|
||||
<Header />
|
||||
|
||||
<main className="relative mt-12">
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative container mx-auto px-4 py-16">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-20">
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
||||
Fuck CapCut
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
|
||||
Roasting time, so get ready motherfucker.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto space-y-12">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
Seriously, what the fuck else do you want?
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
You probably use CapCut and think your video editing is
|
||||
special. You think your fucking TikTok with 47 transitions and
|
||||
12 different fonts is going to get you some viral fame. You
|
||||
think loading up every goddamn effect in their library makes
|
||||
your content better. Wrong, motherfucker. Let me describe what
|
||||
CapCut actually gives you:
|
||||
</p>
|
||||
<ul className="text-lg space-y-2 mb-6 list-disc list-inside">
|
||||
<li>A paywall every time you breathe</li>
|
||||
<li>Terms of service that steal your shit</li>
|
||||
<li>
|
||||
More "Get Pro" dialogs than a Windows 95 error message
|
||||
</li>
|
||||
<li>
|
||||
Features that disappear behind paywalls while you're fucking
|
||||
using them
|
||||
</li>
|
||||
<li>Bugs disguised as "premium features"</li>
|
||||
</ul>
|
||||
<p className="text-lg mb-6">
|
||||
<strong>Well guess what, motherfucker:</strong>
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
You. Are. Getting. Scammed. Look at this shit. It's a fucking
|
||||
video editor. Why the fuck do you need to pay $20/month just
|
||||
to remove a goddamn watermark? You spent hours editing your
|
||||
video and they slap their logo on it like they fucking made
|
||||
it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
The "Get Pro" dialog is everywhere
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
This motherfucking dialog pops up more than ads on a pirated
|
||||
movie site. Want to add a transition? Get Pro. Want to export
|
||||
without their watermark? Get Pro. Want to use more than 2
|
||||
fonts? Get fucking Pro, peasant.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
Did you seriously think you could edit a video without seeing
|
||||
this dialog 47 times? You click one button and BAM - there it
|
||||
is again, asking for your credit card like a desperate ex
|
||||
asking for money.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
Everything costs money now
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
You dumbass. You thought CapCut was free, but no. Free means
|
||||
they let you open the app. Everything else costs money. Basic
|
||||
shake effect? That'll be $20/month. A decent transition that isn't
|
||||
"fade"? Pay up, motherfucker.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
Here's my favorite piece of bullshit: You import an MP3 file -
|
||||
you know, AUDIO - and try to export. "Sorry, can't export
|
||||
because you're using our premium extract audio feature!"
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
<strong>
|
||||
My MP3 was already fucking audio, you absolute morons.
|
||||
</strong>
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
But wait, there's more! If you drag that same MP3 to their
|
||||
media panel first, then to the timeline, it magically works.
|
||||
This isn't a bug, it's a fucking scam disguised as software
|
||||
engineering.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
Their Terms of Service are insane
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
Look at this shit. You upload your content and they basically
|
||||
say "thanks for the free content, we own it now, but if Disney
|
||||
sues anyone, that's your problem."
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
<strong>CapCut's Terms of Service:</strong> We get full rights
|
||||
to use, modify, distribute, and monetize everything you upload
|
||||
- permanently and without paying you shit. But you're still
|
||||
responsible if anything goes wrong.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
Translation: "We'll make money off your viral video, you
|
||||
handle the lawsuits." Brilliant legal strategy, you fucks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
The editor is actually good
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
Here's the thing that makes me want to punch my monitor: the
|
||||
actual video editor is fucking good. It's intuitive, powerful,
|
||||
and anyone can figure it out. When it's not begging for money
|
||||
every 30 seconds, it actually works well.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
Which makes everything else so much worse. They built
|
||||
something people want to use, then turned it into a digital
|
||||
slot machine. Every click might trigger a payment request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
This is a video editor. Look at it. You've never seen one
|
||||
before.
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
Like the person who's never used software that doesn't
|
||||
constantly beg for money, you have no fucking idea what a
|
||||
video editor should be. All you've ever seen are predatory
|
||||
apps disguised as creative tools.
|
||||
</p>
|
||||
<p className="text-lg mb-6">
|
||||
A real video editor lets you edit videos. It doesn't steal
|
||||
your content. It doesn't pop up payment dialogs every 5
|
||||
seconds. It doesn't charge you separately for basic features
|
||||
that should be free.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
Yes, this is fucking satire, you fuck
|
||||
</h2>
|
||||
<p className="text-lg mb-6">
|
||||
I'm not actually saying all video editors should be basic as
|
||||
shit. What I'm saying is that all the problems we have with
|
||||
video editing apps are{" "}
|
||||
<strong>ones they create themselves</strong>. Video editors
|
||||
aren't broken by default - they edit videos, export them, and
|
||||
let you use basic features without constantly begging for
|
||||
money. CapCut breaks them. They turn them into payment
|
||||
processors with video editing as a side feature.
|
||||
</p>
|
||||
<p className="text-lg">
|
||||
<em>"Good software gets out of your way."</em>
|
||||
<br />- Some smart motherfucker who definitely wasn't working
|
||||
at CapCut
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,398 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
184
apps/web/src/components/background-settings.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
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>
|
||||
);
|
||||
}
|
53
apps/web/src/components/delete-project-dialog.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
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>
|
||||
);
|
||||
}
|
108
apps/web/src/components/development-debug.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
"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,45 +3,52 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "./ui/button";
|
||||
import { ChevronLeft, Download } from "lucide-react";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { HeaderBase } from "./header-base";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
|
||||
export function EditorHeader() {
|
||||
const { activeProject } = useProjectStore();
|
||||
const { getTotalDuration } = useTimelineStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
|
||||
const handleExport = () => {
|
||||
// TODO: Implement export functionality
|
||||
console.log("Export project");
|
||||
};
|
||||
|
||||
// Format duration from seconds to MM:SS format
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const leftContent = (
|
||||
<Link
|
||||
href="/"
|
||||
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="text-sm">{activeProject?.name || "Loading..."}</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/projects"
|
||||
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="text-sm">{activeProject?.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
const centerContent = (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{formatDuration(getTotalDuration())}</span>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span>
|
||||
{formatTimeCode(
|
||||
getTotalDuration(),
|
||||
"HH:MM:SS:FF",
|
||||
activeProject?.fps || 30
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const rightContent = (
|
||||
<nav className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={handleExport}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-sm">Export</span>
|
||||
</Button>
|
||||
@ -53,7 +60,7 @@ export function EditorHeader() {
|
||||
leftContent={leftContent}
|
||||
centerContent={centerContent}
|
||||
rightContent={rightContent}
|
||||
className="bg-background border-b"
|
||||
className="bg-background h-[3.2rem] px-4"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
115
apps/web/src/components/editor/audio-waveform.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
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;
|
55
apps/web/src/components/editor/media-panel/index.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
"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>
|
||||
);
|
||||
}
|
73
apps/web/src/components/editor/media-panel/store.ts
Normal file
@ -0,0 +1,73 @@
|
||||
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 }),
|
||||
}));
|
124
apps/web/src/components/editor/media-panel/tabbar.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
"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,43 +1,64 @@
|
||||
"use client";
|
||||
|
||||
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 { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react";
|
||||
import { useDragDrop } from "@/hooks/use-drag-drop";
|
||||
import { processMediaFiles } from "@/lib/media-processing";
|
||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||
import { Image, Music, Plus, Upload, Video } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DragOverlay } from "@/components/ui/drag-overlay";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
|
||||
// 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() {
|
||||
export function MediaView() {
|
||||
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [mediaFilter, setMediaFilter] = useState("all");
|
||||
|
||||
const processFiles = async (files: FileList | File[]) => {
|
||||
// If no files, do nothing
|
||||
if (!files?.length) return;
|
||||
if (!files || files.length === 0) return;
|
||||
if (!activeProject) {
|
||||
toast.error("No active project");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setProgress(0);
|
||||
try {
|
||||
// Process files (extract metadata, generate thumbnails, etc.)
|
||||
const items = await processMediaFiles(files);
|
||||
const processedItems = await processMediaFiles(files, (p) =>
|
||||
setProgress(p)
|
||||
);
|
||||
// Add each processed media item to the store
|
||||
items.forEach((item) => {
|
||||
addMediaItem(item);
|
||||
});
|
||||
for (const item of processedItems) {
|
||||
await addMediaItem(activeProject.id, item);
|
||||
}
|
||||
} catch (error) {
|
||||
// Show error if processing fails
|
||||
console.error("File processing failed:", error);
|
||||
// Show error toast if processing fails
|
||||
console.error("Error processing files:", error);
|
||||
toast.error("Failed to process files");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
@ -54,10 +75,17 @@ export function MediaPanel() {
|
||||
e.target.value = ""; // Reset input
|
||||
};
|
||||
|
||||
const handleRemove = (e: React.MouseEvent, id: string) => {
|
||||
const handleRemove = async (e: React.MouseEvent, id: string) => {
|
||||
// Remove a media item from the store
|
||||
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) => {
|
||||
@ -67,28 +95,18 @@ export function MediaPanel() {
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = mediaItems.filter((item) => {
|
||||
if (mediaFilter && mediaFilter !== 'all' && item.type !== mediaFilter) {
|
||||
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (searchQuery && !item.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
if (
|
||||
searchQuery &&
|
||||
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -98,33 +116,25 @@ export function MediaPanel() {
|
||||
setFilteredMediaItems(filtered);
|
||||
}, [mediaItems, mediaFilter, searchQuery]);
|
||||
|
||||
const renderPreview = (item: any) => {
|
||||
const renderPreview = (item: MediaItem) => {
|
||||
// Render a preview for each media type (image, video, audio, unknown)
|
||||
// Each preview is draggable to the timeline
|
||||
const baseDragProps = {
|
||||
draggable: true,
|
||||
onDragStart: (e: React.DragEvent) => startDrag(e, item),
|
||||
};
|
||||
|
||||
if (item.type === "image") {
|
||||
return (
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover rounded cursor-grab active:cursor-grabbing"
|
||||
loading="lazy"
|
||||
{...baseDragProps}
|
||||
/>
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "video") {
|
||||
if (item.thumbnailUrl) {
|
||||
return (
|
||||
<div
|
||||
className="relative w-full h-full cursor-grab active:cursor-grabbing"
|
||||
{...baseDragProps}
|
||||
>
|
||||
<div className="relative w-full h-full">
|
||||
<img
|
||||
src={item.thumbnailUrl}
|
||||
alt={item.name}
|
||||
@ -143,10 +153,7 @@ export function MediaPanel() {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded cursor-grab active:cursor-grabbing"
|
||||
{...baseDragProps}
|
||||
>
|
||||
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
|
||||
<Video className="h-6 w-6 mb-1" />
|
||||
<span className="text-xs">Video</span>
|
||||
{item.duration && (
|
||||
@ -160,10 +167,7 @@ export function MediaPanel() {
|
||||
|
||||
if (item.type === "audio") {
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20 cursor-grab active:cursor-grabbing"
|
||||
{...baseDragProps}
|
||||
>
|
||||
<div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
|
||||
<Music className="h-6 w-6 mb-1" />
|
||||
<span className="text-xs">Audio</span>
|
||||
{item.duration && (
|
||||
@ -176,10 +180,7 @@ export function MediaPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded cursor-grab active:cursor-grabbing"
|
||||
{...baseDragProps}
|
||||
>
|
||||
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
|
||||
<Image className="h-6 w-6" />
|
||||
<span className="text-xs mt-1">Unknown</span>
|
||||
</div>
|
||||
@ -199,33 +200,34 @@ export function MediaPanel() {
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`h-full flex flex-col transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
|
||||
className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
|
||||
{...dragProps}
|
||||
>
|
||||
{/* Show overlay when dragging files over the panel */}
|
||||
<DragOverlay isVisible={isDragOver} />
|
||||
|
||||
<div className="p-2 border-b">
|
||||
<div className="p-3 pb-2">
|
||||
{/* Button to add/upload media */}
|
||||
<div className="flex gap-2">
|
||||
{/* Search and filter controls */}
|
||||
<select
|
||||
value={mediaFilter}
|
||||
onChange={(e) => setMediaFilter(e.target.value)}
|
||||
className="px-2 py-1 text-xs border rounded bg-background"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="audio">Audio</option>
|
||||
<option value="image">Image</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search media..."
|
||||
className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Select value={mediaFilter} onValueChange={setMediaFilter}>
|
||||
<SelectTrigger className="w-[80px] h-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="">
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="video">Video</SelectItem>
|
||||
<SelectItem value="audio">Audio</SelectItem>
|
||||
<SelectItem value="image">Image</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search media..."
|
||||
className="min-w-[60px] flex-1 h-full text-xs"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Add media button */}
|
||||
<Button
|
||||
@ -233,24 +235,26 @@ export function MediaPanel() {
|
||||
size="sm"
|
||||
onClick={handleFileSelect}
|
||||
disabled={isProcessing}
|
||||
className="flex-none min-w-[80px] whitespace-nowrap"
|
||||
className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Upload className="h-4 w-4 mr-2 animate-spin" />
|
||||
Processing...
|
||||
<Upload className="h-4 w-4 animate-spin" />
|
||||
<span className="hidden md:inline ml-2">{progress}%</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add
|
||||
<span className="hidden sm:inline ml-2" aria-label="Add file">
|
||||
Add
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<div className="flex-1 overflow-y-auto p-3 pt-0">
|
||||
{/* Show message if no media, otherwise show media grid */}
|
||||
{filteredMediaItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center h-full">
|
||||
@ -265,32 +269,38 @@ export function MediaPanel() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{
|
||||
gridTemplateColumns: "repeat(auto-fill, 160px)",
|
||||
}}
|
||||
>
|
||||
{/* Render each media item as a draggable button */}
|
||||
{filteredMediaItems.map((item) => (
|
||||
<div key={item.id} className="relative group">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex flex-col gap-2 p-2 h-auto w-full relative"
|
||||
>
|
||||
<AspectRatio ratio={item.aspectRatio}>
|
||||
{renderPreview(item)}
|
||||
</AspectRatio>
|
||||
<span className="text-xs truncate px-1">{item.name}</span>
|
||||
</Button>
|
||||
|
||||
{/* Show remove button on hover */}
|
||||
<div className="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
<ContextMenu key={item.id}>
|
||||
<ContextMenuTrigger>
|
||||
<DraggableMediaItem
|
||||
name={item.name}
|
||||
preview={renderPreview(item)}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
}}
|
||||
showPlusOnDrag={false}
|
||||
rounded={false}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Export clips</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => handleRemove(e, item.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
)}
|
24
apps/web/src/components/editor/media-panel/views/text.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
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,96 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
|
||||
import { VideoPlayer } from "@/components/ui/video-player";
|
||||
import { AudioPlayer } from "@/components/ui/audio-player";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
import { useState, useRef } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
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";
|
||||
|
||||
// Debug flag - set to false to hide active clips info
|
||||
const SHOW_DEBUG_INFO = process.env.NODE_ENV === 'development';
|
||||
interface ActiveElement {
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
mediaItem: MediaItem | null;
|
||||
}
|
||||
|
||||
export function PreviewPanel() {
|
||||
const { tracks } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const { isPlaying, toggle, currentTime } = usePlaybackStore();
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
|
||||
const [showDebug, setShowDebug] = useState(SHOW_DEBUG_INFO);
|
||||
const { currentTime } = usePlaybackStore();
|
||||
const { canvasSize } = useEditorStore();
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [previewDimensions, setPreviewDimensions] = useState({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
const { activeProject } = useProjectStore();
|
||||
|
||||
// Get active clips at current time
|
||||
const getActiveClips = () => {
|
||||
const activeClips: Array<{
|
||||
clip: any;
|
||||
track: any;
|
||||
mediaItem: any;
|
||||
}> = [];
|
||||
// Calculate optimal preview size that fits in container while maintaining aspect ratio
|
||||
useEffect(() => {
|
||||
const updatePreviewSize = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const container = containerRef.current.getBoundingClientRect();
|
||||
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) => {
|
||||
track.clips.forEach((clip) => {
|
||||
const clipStart = clip.startTime;
|
||||
const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
||||
track.elements.forEach((element) => {
|
||||
const elementStart = element.startTime;
|
||||
const elementEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime >= clipStart && currentTime < clipEnd) {
|
||||
const mediaItem = clip.mediaId === "test"
|
||||
? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
|
||||
: mediaItems.find((item) => item.id === clip.mediaId);
|
||||
if (currentTime >= elementStart && currentTime < elementEnd) {
|
||||
let mediaItem = null;
|
||||
|
||||
if (mediaItem || clip.mediaId === "test") {
|
||||
activeClips.push({ clip, track, mediaItem });
|
||||
// Only get media item for media elements
|
||||
if (element.type === "media") {
|
||||
mediaItem =
|
||||
element.mediaId === "test"
|
||||
? null // Test elements don't have a real media item
|
||||
: mediaItems.find((item) => item.id === element.mediaId) ||
|
||||
null;
|
||||
}
|
||||
|
||||
activeElements.push({ element, track, mediaItem });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return activeClips;
|
||||
return activeElements;
|
||||
};
|
||||
|
||||
const activeClips = getActiveClips();
|
||||
const aspectRatio = canvasSize.width / canvasSize.height;
|
||||
const activeElements = getActiveElements();
|
||||
|
||||
// Render a clip
|
||||
const renderClip = (clipData: any, index: number) => {
|
||||
const { clip, mediaItem } = clipData;
|
||||
// Check if there are any elements in the timeline at all
|
||||
const hasAnyElements = tracks.some((track) => track.elements.length > 0);
|
||||
|
||||
// Test clips
|
||||
if (!mediaItem || clip.mediaId === "test") {
|
||||
return (
|
||||
<div
|
||||
key={clip.id}
|
||||
className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">🎬</div>
|
||||
<p className="text-xs text-white">{clip.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// Get media elements for blur background (video/image only)
|
||||
const getBlurBackgroundElements = (): ActiveElement[] => {
|
||||
return activeElements.filter(
|
||||
({ element, mediaItem }) =>
|
||||
element.type === "media" &&
|
||||
mediaItem &&
|
||||
(mediaItem.type === "video" || mediaItem.type === "image") &&
|
||||
element.mediaId !== "test" // Exclude test elements
|
||||
);
|
||||
};
|
||||
|
||||
const blurBackgroundElements = getBlurBackgroundElements();
|
||||
|
||||
// Render blur background layer
|
||||
const renderBlurBackground = () => {
|
||||
if (
|
||||
!activeProject?.backgroundType ||
|
||||
activeProject.backgroundType !== "blur" ||
|
||||
blurBackgroundElements.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Video clips
|
||||
// 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 (
|
||||
<div key={clip.id} className="absolute inset-0">
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<VideoPlayer
|
||||
src={mediaItem.url}
|
||||
src={mediaItem.url!}
|
||||
poster={mediaItem.thumbnailUrl}
|
||||
clipStartTime={clip.startTime}
|
||||
trimStart={clip.trimStart}
|
||||
trimEnd={clip.trimEnd}
|
||||
clipDuration={clip.duration}
|
||||
clipStartTime={element.startTime}
|
||||
trimStart={element.trimStart}
|
||||
trimEnd={element.trimEnd}
|
||||
clipDuration={element.duration}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Image clips
|
||||
if (mediaItem.type === "image") {
|
||||
return (
|
||||
<div key={clip.id} className="absolute inset-0">
|
||||
<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}
|
||||
src={mediaItem.url!}
|
||||
alt={mediaItem.name}
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
@ -99,115 +215,283 @@ export function PreviewPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
// Audio clips (visual representation)
|
||||
if (mediaItem.type === "audio") {
|
||||
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={clip.id}
|
||||
className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
|
||||
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="text-center">
|
||||
<div className="text-2xl mb-2">🎵</div>
|
||||
<p className="text-xs text-white">{mediaItem.name}</p>
|
||||
<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"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">🎬</div>
|
||||
<p className="text-xs text-white">{element.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Video elements
|
||||
if (mediaItem.type === "video") {
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<VideoPlayer
|
||||
src={mediaItem.url!}
|
||||
poster={mediaItem.thumbnailUrl}
|
||||
clipStartTime={element.startTime}
|
||||
trimStart={element.trimStart}
|
||||
trimEnd={element.trimEnd}
|
||||
clipDuration={element.duration}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Image elements
|
||||
if (mediaItem.type === "image") {
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<img
|
||||
src={mediaItem.url!}
|
||||
alt={mediaItem.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Audio elements (no visual representation)
|
||||
if (mediaItem.type === "audio") {
|
||||
return (
|
||||
<div key={element.id} className="absolute inset-0">
|
||||
<AudioPlayer
|
||||
src={mediaItem.url!}
|
||||
clipStartTime={element.startTime}
|
||||
trimStart={element.trimStart}
|
||||
trimEnd={element.trimEnd}
|
||||
clipDuration={element.duration}
|
||||
trackMuted={elementData.track.muted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Canvas presets
|
||||
const canvasPresets = [
|
||||
{ name: "16:9 HD", width: 1920, height: 1080 },
|
||||
{ name: "16:9 4K", width: 3840, height: 2160 },
|
||||
{ name: "9:16 Mobile", width: 1080, height: 1920 },
|
||||
{ name: "1:1 Square", width: 1080, height: 1080 },
|
||||
{ name: "4:3 Standard", width: 1440, height: 1080 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col min-h-0 min-w-0">
|
||||
{/* Controls */}
|
||||
<div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0">
|
||||
<span className="text-muted-foreground">Canvas:</span>
|
||||
<select
|
||||
value={`${canvasSize.width}x${canvasSize.height}`}
|
||||
onChange={(e) => {
|
||||
const preset = canvasPresets.find(p => `${p.width}x${p.height}` === e.target.value);
|
||||
if (preset) setCanvasSize({ width: preset.width, height: preset.height });
|
||||
}}
|
||||
className="bg-background border rounded px-2 py-1 text-xs"
|
||||
>
|
||||
{canvasPresets.map(preset => (
|
||||
<option key={preset.name} value={`${preset.width}x${preset.height}`}>
|
||||
{preset.name} ({preset.width}×{preset.height})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Debug Toggle - Only show in development */}
|
||||
{SHOW_DEBUG_INFO && (
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onClick={() => setShowDebug(!showDebug)}
|
||||
className="text-xs"
|
||||
<div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0"
|
||||
>
|
||||
<div className="flex-1"></div>
|
||||
{hasAnyElements ? (
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="relative overflow-hidden rounded-sm border"
|
||||
style={{
|
||||
width: previewDimensions.width,
|
||||
height: previewDimensions.height,
|
||||
backgroundColor:
|
||||
activeProject?.backgroundType === "blur"
|
||||
? "transparent"
|
||||
: activeProject?.backgroundColor || "#000000",
|
||||
}}
|
||||
>
|
||||
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
|
||||
ref={previewRef}
|
||||
className="relative overflow-hidden rounded-sm max-w-full max-h-full bg-black border border-gray-600"
|
||||
style={{
|
||||
aspectRatio: aspectRatio.toString(),
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{activeClips.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-white/50">
|
||||
{tracks.length === 0 ? "Drop media to start editing" : "No clips at current time"}
|
||||
</div>
|
||||
) : (
|
||||
activeClips.map((clipData, index) => renderClip(clipData, index))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Debug Info Panel - Conditionally rendered */}
|
||||
{showDebug && (
|
||||
<div className="border-t bg-background p-2 flex-shrink-0">
|
||||
<div className="text-xs font-medium mb-1">Debug: Active Clips ({activeClips.length})</div>
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{activeClips.map((clipData, index) => (
|
||||
<div
|
||||
key={clipData.clip.id}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-muted rounded text-xs whitespace-nowrap"
|
||||
>
|
||||
<span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span>{clipData.clip.name}</span>
|
||||
<span className="text-muted-foreground">({clipData.mediaItem?.type || 'test'})</span>
|
||||
{renderBlurBackground()}
|
||||
{activeElements.length === 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
||||
No elements at current time
|
||||
</div>
|
||||
))}
|
||||
{activeClips.length === 0 && (
|
||||
<span className="text-muted-foreground">No active clips</span>
|
||||
) : (
|
||||
activeElements.map((elementData, index) =>
|
||||
renderElement(elementData, index)
|
||||
)
|
||||
)}
|
||||
{/* Show message when blur is selected but no media available */}
|
||||
{activeProject?.backgroundType === "blur" &&
|
||||
blurBackgroundElements.length === 0 &&
|
||||
activeElements.length > 0 && (
|
||||
<div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded">
|
||||
Add a video or image to use blur background
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<div className="flex-1"></div>
|
||||
|
||||
<PreviewToolbar hasAnyElements={hasAnyElements} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
|
||||
const { isPlaying, toggle, currentTime } = usePlaybackStore();
|
||||
const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore();
|
||||
const { getTotalDuration } = useTimelineStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const {
|
||||
currentPreset,
|
||||
isOriginal,
|
||||
getOriginalAspectRatio,
|
||||
getDisplayName,
|
||||
canvasPresets,
|
||||
} = useAspectRatio();
|
||||
|
||||
const handlePresetSelect = (preset: { width: number; height: number }) => {
|
||||
setCanvasSize({ width: preset.width, height: preset.height });
|
||||
};
|
||||
|
||||
const handleOriginalSelect = () => {
|
||||
const aspectRatio = getOriginalAspectRatio();
|
||||
setCanvasSizeToOriginal(aspectRatio);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-toolbar
|
||||
className="flex items-end justify-between gap-2 p-1 pt-2 w-full"
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[0.75rem] text-muted-foreground flex items-center gap-1",
|
||||
!hasAnyElements && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<span className="text-primary tabular-nums">
|
||||
{formatTimeCode(
|
||||
currentTime,
|
||||
"HH:MM:SS:FF",
|
||||
activeProject?.fps || 30
|
||||
)}
|
||||
</span>
|
||||
<span className="opacity-50">/</span>
|
||||
<span className="tabular-nums">
|
||||
{formatTimeCode(
|
||||
getTotalDuration(),
|
||||
"HH:MM:SS:FF",
|
||||
activeProject?.fps || 30
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={toggle}
|
||||
disabled={!hasAnyElements}
|
||||
className="h-auto p-0"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-3 w-3" />
|
||||
) : (
|
||||
<Play className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<BackgroundSettings />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="!bg-panel-accent text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light"
|
||||
disabled={!hasAnyElements}
|
||||
>
|
||||
{getDisplayName()}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={handleOriginalSelect}
|
||||
className={cn("text-xs", isOriginal && "font-semibold")}
|
||||
>
|
||||
Original
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{canvasPresets.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset.name}
|
||||
onClick={() => handlePresetSelect(preset)}
|
||||
className={cn(
|
||||
"text-xs",
|
||||
currentPreset?.name === preset.name && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{preset.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="!size-4 text-muted-foreground"
|
||||
>
|
||||
<Expand className="!size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,217 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { MediaElement } from "@/types/timeline";
|
||||
|
||||
export function AudioProperties({ element }: { element: MediaElement }) {
|
||||
return <div className="space-y-4 p-5">Audio properties</div>;
|
||||
}
|
109
apps/web/src/components/editor/properties-panel/index.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
"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>
|
||||
);
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { MediaElement } from "@/types/timeline";
|
||||
|
||||
export function MediaProperties({ element }: { element: MediaElement }) {
|
||||
return <div className="space-y-4 p-5">Media properties</div>;
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
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>;
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
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>
|
||||
);
|
||||
}
|
58
apps/web/src/components/editor/selection-box.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
"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)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
405
apps/web/src/components/editor/timeline-element.tsx
Normal file
@ -0,0 +1,405 @@
|
||||
"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>
|
||||
);
|
||||
}
|
110
apps/web/src/components/editor/timeline-playhead.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
"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 };
|
219
apps/web/src/components/editor/timeline-toolbar.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
"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>
|
||||
);
|
||||
}
|
962
apps/web/src/components/editor/timeline-track.tsx
Normal file
@ -0,0 +1,962 @@
|
||||
"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>
|
||||
);
|
||||
}
|
124
apps/web/src/components/footer.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
"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 (
|
||||
<header
|
||||
className={cn("px-6 h-16 flex justify-between items-center", className)}
|
||||
className={cn("px-6 h-14 flex justify-between items-center", className)}
|
||||
>
|
||||
{leftContent && <div className="flex items-center">{leftContent}</div>}
|
||||
{centerContent && (
|
||||
|
@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Button } from "./ui/button";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { HeaderBase } from "./header-base";
|
||||
import { useSession } from "@opencut/auth/client";
|
||||
import { getStars } from "@/lib/fetchGhStars";
|
||||
import { Star } from "lucide-react";
|
||||
import { getStars } from "@/lib/fetch-github-stars";
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
export function Header() {
|
||||
const { data: session } = useSession();
|
||||
@ -29,26 +28,43 @@ export function Header() {
|
||||
|
||||
const leftContent = (
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
|
||||
<span className="font-medium tracking-tight">OpenCut</span>
|
||||
<Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
|
||||
<span className="text-xl font-medium hidden md:block">OpenCut</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const rightContent = (
|
||||
<nav className="flex items-center">
|
||||
<nav className="flex items-center gap-3">
|
||||
<Link href="/contributors">
|
||||
<Button variant="text" className="text-sm">
|
||||
<Button variant="text" className="text-sm p-0">
|
||||
Contributors
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
|
||||
<Button size="sm" className="text-sm ml-4">
|
||||
GitHub
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</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">
|
||||
<Button size="sm" className="text-sm ml-4">
|
||||
GitHub {star}+
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
|
||||
return <HeaderBase leftContent={leftContent} rightContent={rightContent} />;
|
||||
return (
|
||||
<div className="mx-4 md:mx-0">
|
||||
<HeaderBase
|
||||
className="bg-accent border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
|
||||
leftContent={leftContent}
|
||||
rightContent={rightContent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ export function GithubIcon({ className }: { className?: string }) {
|
||||
viewBox="0 -3.5 256 256"
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
>
|
||||
<g fill="#161614">
|
||||
<g fill="currentColor">
|
||||
<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" />
|
||||
@ -37,3 +37,64 @@ export function GithubIcon({ className }: { className?: string }) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackgroundIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="353"
|
||||
height="353"
|
||||
viewBox="0 0 353 353"
|
||||
fill="none"
|
||||
className={className}
|
||||
>
|
||||
<g clipPath="url(#clip0_1_3)">
|
||||
<rect
|
||||
x="-241.816"
|
||||
y="233.387"
|
||||
width="592.187"
|
||||
height="17.765"
|
||||
transform="rotate(-37 -241.816 233.387)"
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
x="-189.907"
|
||||
y="306.804"
|
||||
width="592.187"
|
||||
height="17.765"
|
||||
transform="rotate(-37 -189.907 306.804)"
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
x="-146.928"
|
||||
y="389.501"
|
||||
width="592.187"
|
||||
height="17.765"
|
||||
transform="rotate(-37 -146.928 389.501)"
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
x="-103.144"
|
||||
y="477.904"
|
||||
width="592.187"
|
||||
height="17.765"
|
||||
transform="rotate(-37 -103.144 477.904)"
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
x="-57.169"
|
||||
y="570.714"
|
||||
width="592.187"
|
||||
height="17.765"
|
||||
transform="rotate(-37 -57.169 570.714)"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1_3">
|
||||
<rect width="353" height="353" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
164
apps/web/src/components/landing/handlebars.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
"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,34 +4,21 @@ import { motion } from "motion/react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { getStars } from "@/lib/fetchGhStars";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Handlebars } from "./handlebars";
|
||||
|
||||
interface HeroProps {
|
||||
signupCount: number;
|
||||
}
|
||||
|
||||
export function Hero({ signupCount }: HeroProps) {
|
||||
const [star, setStar] = useState<string>();
|
||||
const [email, setEmail] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStars = async () => {
|
||||
try {
|
||||
const data = await getStars();
|
||||
setStar(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch GitHub stars", err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStars();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@ -55,7 +42,7 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
body: JSON.stringify({ email: email.trim() }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as { error: string };
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
@ -66,7 +53,9 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
} else {
|
||||
toast({
|
||||
title: "Oops!",
|
||||
description: data.error || "Something went wrong. Please try again.",
|
||||
description:
|
||||
(data as { error: string }).error ||
|
||||
"Something went wrong. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
@ -82,7 +71,14 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-4rem)] flex flex-col justify-between items-center text-center px-4">
|
||||
<div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4">
|
||||
<Image
|
||||
className="absolute top-0 left-0 -z-50 size-full object-cover"
|
||||
src="/landing-page-bg.png"
|
||||
height={1903.5}
|
||||
width={1269}
|
||||
alt="landing-page.bg"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@ -93,18 +89,14 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.8 }}
|
||||
className="inline-block"
|
||||
className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
|
||||
>
|
||||
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter">
|
||||
The open source
|
||||
</h1>
|
||||
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter mt-2">
|
||||
video editor
|
||||
</h1>
|
||||
<h1>The Open Source</h1>
|
||||
<Handlebars>Video Editor</Handlebars>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
className="mt-10 text-lg sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
|
||||
className="mt-10 text-base sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4, duration: 0.8 }}
|
||||
@ -119,20 +111,25 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6, duration: 0.8 }}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="flex gap-3 w-full max-w-lg">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
className="h-11 text-base flex-1"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"
|
||||
>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
className="h-11 text-base flex-1"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="px-6 h-11 text-base"
|
||||
className="px-6 h-11 text-base !bg-foreground"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span className="relative z-10">
|
||||
@ -148,28 +145,13 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8, duration: 0.6 }}
|
||||
className="mt-8 inline-flex items-center gap-2 bg-muted/30 px-4 py-2 rounded-full text-sm text-muted-foreground justify-center"
|
||||
className="mt-8 inline-flex items-center gap-2 text-sm text-muted-foreground justify-center"
|
||||
>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
<span>{signupCount.toLocaleString()} people already joined</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="mb-8 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>
|
||||
);
|
||||
}
|
||||
|
73
apps/web/src/components/rename-project-dialog.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
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>
|
||||
);
|
||||
}
|
80
apps/web/src/components/storage-provider.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
"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>
|
||||
);
|
||||
}
|
127
apps/web/src/components/ui/audio-player.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
"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,6 +10,8 @@ const buttonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-foreground text-background shadow hover:bg-foreground/90",
|
||||
primary:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
@ -22,7 +24,7 @@ const buttonVariants = cva(
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
sm: "h-8 rounded-sm px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-7 w-7",
|
||||
},
|
||||
|
@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
"rounded-xl border bg-card text-card-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
@ -3,6 +3,7 @@
|
||||
import * as React from "react";
|
||||
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
@ -18,23 +19,40 @@ const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||
|
||||
const contextMenuItemVariants = cva(
|
||||
"relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "focus:opacity-65 focus:text-accent-foreground",
|
||||
destructive: "text-destructive focus:text-destructive/80",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
>(({ className, inset, children, variant = "default", ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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",
|
||||
contextMenuItemVariants({ variant }),
|
||||
"data-[state=open]:bg-accent data-[state=open]:opacity-65",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
<ChevronRight className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
@ -62,7 +80,8 @@ const ContextMenuContent = React.forwardRef<
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -75,12 +94,13 @@ const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
>(({ className, inset, variant = "default", ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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",
|
||||
contextMenuItemVariants({ variant }),
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
@ -91,14 +111,13 @@ ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {
|
||||
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, children, checked, variant = "default", ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
@ -115,19 +134,18 @@ ContextMenuCheckboxItem.displayName =
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & {
|
||||
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, children, variant = "default", ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-4 w-4 fill-current" />
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@ -144,7 +162,7 @@ const ContextMenuLabel = React.forwardRef<
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
@ -159,7 +177,7 @@ const ContextMenuSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
className={cn("-mx-1 my-1 h-px bg-foreground/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@ -171,10 +189,7 @@ const ContextMenuShortcut = ({
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
150
apps/web/src/components/ui/draggable-item.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
"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,16 +19,33 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const dropdownMenuItemVariants = cva(
|
||||
"relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "focus:opacity-65 focus:text-accent-foreground",
|
||||
destructive: "text-destructive focus:text-destructive/80",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
>(({ className, inset, children, variant = "default", ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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",
|
||||
dropdownMenuItemVariants({ variant }),
|
||||
"data-[state=open]:bg-accent data-[state=open]:opacity-65",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
@ -65,8 +82,12 @@ const DropdownMenuContent = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
@ -76,22 +97,6 @@ const DropdownMenuContent = React.forwardRef<
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const dropdownMenuItemVariants = cva(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "focus:bg-accent focus:text-accent-foreground",
|
||||
destructive:
|
||||
"text-destructive focus:bg-destructive focus:text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
@ -113,12 +118,15 @@ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, children, checked, variant = "default", ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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",
|
||||
dropdownMenuItemVariants({ variant }),
|
||||
"pl-8 pr-2",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
@ -137,12 +145,15 @@ DropdownMenuCheckboxItem.displayName =
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, children, variant = "default", ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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",
|
||||
dropdownMenuItemVariants({ variant }),
|
||||
"pl-8 pr-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -181,7 +192,7 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
className={cn("-mx-1 my-1 h-px bg-foreground/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
40
apps/web/src/components/ui/font-picker.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
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,13 +2,14 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BackgroundType } from "@/types/editor";
|
||||
|
||||
interface ImageTimelineTreatmentProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
targetAspectRatio?: number; // Default to 16:9 for video
|
||||
className?: string;
|
||||
backgroundType?: "blur" | "mirror" | "color";
|
||||
backgroundType?: BackgroundType;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
|
@ -11,14 +11,7 @@ interface InputProps extends React.ComponentProps<"input"> {
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
type,
|
||||
showPassword,
|
||||
onShowPasswordChange,
|
||||
value,
|
||||
...props
|
||||
},
|
||||
{ className, type, showPassword, onShowPasswordChange, value, ...props },
|
||||
ref
|
||||
) => {
|
||||
const isPassword = type === "password";
|
||||
@ -26,7 +19,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
const inputType = isPassword && showPassword ? "text" : type;
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className={showPassword ? "relative w-full" : ""}>
|
||||
<input
|
||||
type={inputType}
|
||||
className={cn(
|
||||
|
@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { GripVertical } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
@ -29,17 +28,11 @@ const ResizableHandle = ({
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"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",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
/>
|
||||
);
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
|
@ -3,6 +3,7 @@
|
||||
import * as React from "react";
|
||||
import { Select as SelectPrimitive } from "radix-ui";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
@ -12,6 +13,21 @@ const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const selectItemVariants = cva(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "focus:opacity-65 focus:text-accent-foreground",
|
||||
destructive: "text-destructive focus:text-destructive/80",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
@ -81,6 +97,10 @@ const SelectContent = React.forwardRef<
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
@ -113,14 +133,13 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||
variant?: VariantProps<typeof selectItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, children, variant = "default", ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
className={cn(selectItemVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
@ -139,7 +158,7 @@ const SelectSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
className={cn("-mx-1 my-1 h-px bg-foreground/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-xs",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|