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`
|
4. Install dependencies: `bun install`
|
||||||
5. Start the development server: `bun run dev`
|
5. Start the development server: `bun run dev`
|
||||||
|
|
||||||
|
> **Note:** If you see an error like `Unsupported URL Type "workspace:*"` when running `npm install`, you have two options:
|
||||||
|
>
|
||||||
|
> 1. Upgrade to a recent npm version (v9 or later), which has full workspace protocol support.
|
||||||
|
> 2. Use an alternative package manager such as **bun** or **pnpm**.
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -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
|
- name: Install Bun
|
||||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
|
||||||
with:
|
with:
|
||||||
bun-version: 1.2.2
|
bun-version: 1.2.18
|
||||||
|
|
||||||
- name: Cache Bun modules
|
- name: Cache Bun modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.bun/install/cache
|
path: ~/.bun/install/cache
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('apps/web/bun.lock') }}
|
key: ${{ runner.os }}-bun-1.2.18-${{ hashFiles('apps/web/bun.lock') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: apps/web
|
working-directory: apps/web
|
||||||
|
7
.gitignore
vendored
@ -27,4 +27,9 @@ node_modules
|
|||||||
.cursorignore
|
.cursorignore
|
||||||
.turbo
|
.turbo
|
||||||
|
|
||||||
*.env
|
*.env
|
||||||
|
|
||||||
|
# cursor
|
||||||
|
|
||||||
|
.cursor/
|
||||||
|
bun.lockb
|
2
.npmrc
@ -1,2 +1,2 @@
|
|||||||
install-strategy="nested"
|
install-strategy="nested"
|
||||||
node-linker=isolated
|
node-linker=isolated
|
162
README.md
@ -1,12 +1,14 @@
|
|||||||
<img src="apps/web/public/logo.png" align="left" width="130" height="130">
|
<table width="100%">
|
||||||
|
<tr>
|
||||||
<div align="right">
|
<td align="left" width="120">
|
||||||
|
<img src="apps/web/public/logo.png" alt="OpenCut Logo" width="100" />
|
||||||
|
</td>
|
||||||
|
<td align="right">
|
||||||
# OpenCut (prev AppCut)
|
<h1>OpenCut <span style="font-size: 0.7em; font-weight: normal;">(prev AppCut)</span></h1>
|
||||||
### A free, open-source video editor for web, desktop, and mobile.
|
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
|
||||||
</div>
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
## Why?
|
## Why?
|
||||||
|
|
||||||
@ -43,72 +45,122 @@ Before you begin, ensure you have the following installed on your system:
|
|||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. **Clone the repository**
|
## Getting Started
|
||||||
```bash
|
|
||||||
git clone <repo-url>
|
|
||||||
cd OpenCut
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Start backend services**
|
1. Fork the repository
|
||||||
From the project root, start the PostgreSQL and Redis services:
|
2. Clone your fork locally
|
||||||
```bash
|
3. Navigate to the web app directory: `cd apps/web`
|
||||||
docker-compose up -d
|
4. Install dependencies: `bun install`
|
||||||
```
|
5. Start the development server: `bun run dev`
|
||||||
|
|
||||||
3. **Set up environment variables**
|
## Development Setup
|
||||||
Navigate into the web app's directory and create a `.env` file from the example:
|
|
||||||
```bash
|
|
||||||
cd apps/web
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
*The default values in the `.env` file should work for local development.*
|
|
||||||
|
|
||||||
4. **Install dependencies**
|
### Prerequisites
|
||||||
Install the project dependencies using `bun` (recommended) or `npm`.
|
|
||||||
```bash
|
|
||||||
# With bun
|
|
||||||
bun install
|
|
||||||
|
|
||||||
# Or with npm
|
- Node.js 18+
|
||||||
npm install
|
- Bun (latest version)
|
||||||
```
|
- Docker (for local database)
|
||||||
|
|
||||||
5. **Run database migrations**
|
### Local Development
|
||||||
Apply the database schema to your local database:
|
|
||||||
```bash
|
|
||||||
# With bun
|
|
||||||
bun run db:push:local
|
|
||||||
|
|
||||||
# Or with npm
|
1. Start the database and Redis services:
|
||||||
npm run db:push:local
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Start the development server**
|
```bash
|
||||||
```bash
|
# From project root
|
||||||
# With bun
|
docker-compose up -d
|
||||||
bun run dev
|
```
|
||||||
|
|
||||||
# Or with npm
|
2. Navigate to the web app directory:
|
||||||
npm run dev
|
|
||||||
```
|
```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).
|
The application will be available at [http://localhost:3000](http://localhost:3000).
|
||||||
|
|
||||||
=======
|
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
## 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.
|
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
|
||||||
|
|
||||||
Quick start for contributors:
|
**Quick start for contributors:**
|
||||||
|
|
||||||
- Fork the repo and clone locally
|
- Fork the repo and clone locally
|
||||||
- Follow the setup instructions in CONTRIBUTING.md
|
- Follow the setup instructions in CONTRIBUTING.md
|
||||||
- Create a feature branch and submit a PR
|
- Create a feature branch and submit a PR
|
||||||
|
|
||||||
|
## Sponsors
|
||||||
|
|
||||||
|
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
|
||||||
|
|
||||||
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FOpenCut-app%2FOpenCut&project-name=opencut&repository-name=opencut)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT LICENSE](LICENSE)
|
[MIT LICENSE](LICENSE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|

|
||||||
|
@ -1,30 +1,45 @@
|
|||||||
FROM oven/bun:latest AS base
|
FROM oven/bun:alpine AS base
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies and build the application
|
||||||
FROM base AS deps
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package.json bun.lock ./
|
|
||||||
RUN bun install --frozen-lockfile
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
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
|
RUN bun run build
|
||||||
|
|
||||||
# Production image
|
# Production image
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||||
|
|
||||||
|
RUN chown nextjs:nodejs apps
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
@ -33,4 +48,4 @@ EXPOSE 3000
|
|||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
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 {
|
export default {
|
||||||
schema: "./src/lib/db/schema.ts",
|
schema: "../../packages/db/src/schema.ts",
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL,
|
url: process.env.DATABASE_URL,
|
||||||
|
@ -6,6 +6,19 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
productionBrowserSourceMaps: true,
|
productionBrowserSourceMaps: true,
|
||||||
|
output: "standalone",
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "plus.unsplash.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "images.unsplash.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
@ -2,9 +2,9 @@
|
|||||||
"name": "opencut",
|
"name": "opencut",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "bun@1.2.17",
|
"packageManager": "bun@1.2.18",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
@ -21,7 +21,6 @@
|
|||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@opencut/auth": "workspace:*",
|
"@opencut/auth": "workspace:*",
|
||||||
"@opencut/db": "workspace:*",
|
"@opencut/db": "workspace:*",
|
||||||
"@types/pg": "^8.15.4",
|
|
||||||
"@upstash/ratelimit": "^2.0.5",
|
"@upstash/ratelimit": "^2.0.5",
|
||||||
"@upstash/redis": "^1.35.0",
|
"@upstash/redis": "^1.35.0",
|
||||||
"@vercel/analytics": "^1.4.1",
|
"@vercel/analytics": "^1.4.1",
|
||||||
@ -57,6 +56,7 @@
|
|||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/pg": "^8.15.4",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { signIn } from "@opencut/auth/client";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@ -10,7 +9,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Suspense, useState } from "react";
|
import { memo, Suspense } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@ -18,121 +17,22 @@ import Link from "next/link";
|
|||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||||
import { GoogleIcon } from "@/components/icons";
|
import { GoogleIcon } from "@/components/icons";
|
||||||
|
import { useLogin } from "@/hooks/auth/useLogin";
|
||||||
|
|
||||||
function LoginForm() {
|
const LoginPage = () => {
|
||||||
const router = useRouter();
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
|
||||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
setError(null);
|
|
||||||
setIsEmailLoading(true);
|
|
||||||
|
|
||||||
const { error } = await signIn.email({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
setError(error.message || "An unexpected error occurred.");
|
|
||||||
setIsEmailLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push("/editor");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGoogleLogin = async () => {
|
|
||||||
setError(null);
|
|
||||||
setIsGoogleLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await signIn.social({
|
|
||||||
provider: "google",
|
|
||||||
});
|
|
||||||
router.push("/editor");
|
|
||||||
} catch (error) {
|
|
||||||
setError("Failed to sign in with Google. Please try again.");
|
|
||||||
setIsGoogleLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isAnyLoading = isEmailLoading || isGoogleLoading;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col space-y-6">
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleGoogleLogin}
|
|
||||||
variant="outline"
|
|
||||||
size="lg"
|
|
||||||
disabled={isAnyLoading}
|
|
||||||
>
|
|
||||||
{isGoogleLoading ? (
|
|
||||||
<Loader2 className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<GoogleIcon />
|
|
||||||
)}{" "}
|
|
||||||
Continue with Google
|
|
||||||
</Button>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<Separator className="w-full" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-background px-2 text-muted-foreground">
|
|
||||||
Or continue with
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="m@example.com"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
disabled={isAnyLoading}
|
|
||||||
className="h-11"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
disabled={isAnyLoading}
|
|
||||||
className="h-11"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleLogin}
|
|
||||||
disabled={isAnyLoading || !email || !password}
|
|
||||||
className="w-full h-11"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{isEmailLoading ? <Loader2 className="animate-spin" /> : "Sign in"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const {
|
||||||
|
email,
|
||||||
|
setEmail,
|
||||||
|
password,
|
||||||
|
setPassword,
|
||||||
|
error,
|
||||||
|
isAnyLoading,
|
||||||
|
isEmailLoading,
|
||||||
|
isGoogleLoading,
|
||||||
|
handleLogin,
|
||||||
|
handleGoogleLogin,
|
||||||
|
} = useLogin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center relative">
|
<div className="flex h-screen items-center justify-center relative">
|
||||||
@ -158,19 +58,85 @@ export default function LoginPage() {
|
|||||||
</div>
|
</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>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(LoginPage);
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { signUp, signIn } from "@opencut/auth/client";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@ -10,151 +9,32 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Suspense, useState } from "react";
|
import { memo, Suspense } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Loader2, ArrowLeft } from "lucide-react";
|
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||||
import { GoogleIcon } from "@/components/icons";
|
import { GoogleIcon } from "@/components/icons";
|
||||||
|
import { useSignUp } from "@/hooks/auth/useSignUp";
|
||||||
|
|
||||||
function SignUpForm() {
|
const SignUpPage = () => {
|
||||||
const router = useRouter();
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
|
||||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleSignUp = async () => {
|
|
||||||
setError(null);
|
|
||||||
setIsEmailLoading(true);
|
|
||||||
|
|
||||||
const { error } = await signUp.email({
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
setError(error.message || "An unexpected error occurred.");
|
|
||||||
setIsEmailLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push("/login");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGoogleSignUp = async () => {
|
|
||||||
setError(null);
|
|
||||||
setIsGoogleLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await signIn.social({
|
|
||||||
provider: "google",
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push("/editor");
|
|
||||||
} catch (error) {
|
|
||||||
setError("Failed to sign up with Google. Please try again.");
|
|
||||||
setIsGoogleLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isAnyLoading = isEmailLoading || isGoogleLoading;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col space-y-6">
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleGoogleSignUp}
|
|
||||||
variant="outline"
|
|
||||||
size="lg"
|
|
||||||
disabled={isAnyLoading}
|
|
||||||
>
|
|
||||||
{isGoogleLoading ? (
|
|
||||||
<Loader2 className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<GoogleIcon />
|
|
||||||
)}{" "}
|
|
||||||
Continue with Google
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<Separator className="w-full" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-background px-2 text-muted-foreground">
|
|
||||||
Or continue with
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Full Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
type="text"
|
|
||||||
placeholder="John Doe"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
disabled={isAnyLoading}
|
|
||||||
className="h-11"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="m@example.com"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
disabled={isAnyLoading}
|
|
||||||
className="h-11"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
placeholder="Create a strong password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
disabled={isAnyLoading}
|
|
||||||
className="h-11"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleSignUp}
|
|
||||||
disabled={isAnyLoading || !name || !email || !password}
|
|
||||||
className="w-full h-11"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{isEmailLoading ? (
|
|
||||||
<Loader2 className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Create account"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SignUpPage() {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
setName,
|
||||||
|
email,
|
||||||
|
setEmail,
|
||||||
|
password,
|
||||||
|
setPassword,
|
||||||
|
error,
|
||||||
|
isAnyLoading,
|
||||||
|
isEmailLoading,
|
||||||
|
isGoogleLoading,
|
||||||
|
handleSignUp,
|
||||||
|
handleGoogleSignUp,
|
||||||
|
} = useSignUp();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center relative">
|
<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
|
<ArrowLeft className="h-5 w-5" /> Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Card className="w-[400px] shadow-lg border-0">
|
<Card className="w-[400px] shadow-lg border-0">
|
||||||
<CardHeader className="text-center pb-4">
|
<CardHeader className="text-center pb-4">
|
||||||
<CardTitle className="text-2xl font-semibold">
|
<CardTitle className="text-2xl font-semibold">
|
||||||
@ -183,19 +62,101 @@ export default function SignUpPage() {
|
|||||||
</div>
|
</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>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { db } from "@opencut/db";
|
import { db, eq } from "@opencut/db";
|
||||||
import { waitlist } from "@opencut/db/schema";
|
import { waitlist } from "@opencut/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { waitlistRateLimit } from "@/lib/rate-limit";
|
import { waitlistRateLimit } from "@/lib/rate-limit";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { GithubIcon } from "@/components/icons";
|
import { GithubIcon } from "@/components/icons";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Contributors - OpenCut",
|
title: "Contributors - OpenCut",
|
||||||
@ -46,10 +47,10 @@ async function getContributors(): Promise<Contributor[]> {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const contributors = await response.json();
|
const contributors = (await response.json()) as Contributor[];
|
||||||
|
|
||||||
const filteredContributors = contributors.filter(
|
const filteredContributors = contributors.filter(
|
||||||
(contributor: any) => contributor.type === "User"
|
(contributor: Contributor) => contributor.type === "User"
|
||||||
);
|
);
|
||||||
|
|
||||||
return filteredContributors;
|
return filteredContributors;
|
||||||
@ -61,8 +62,8 @@ async function getContributors(): Promise<Contributor[]> {
|
|||||||
|
|
||||||
export default async function ContributorsPage() {
|
export default async function ContributorsPage() {
|
||||||
const contributors = await getContributors();
|
const contributors = await getContributors();
|
||||||
const topContributor = contributors[0];
|
const topContributors = contributors.slice(0, 2);
|
||||||
const otherContributors = contributors.slice(1);
|
const otherContributors = contributors.slice(2);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<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="relative container mx-auto px-4 py-16">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="text-center mb-20">
|
<div className="text-center mb-20">
|
||||||
<div className="inline-flex items-center gap-2 bg-muted/50 text-muted-foreground px-3 py-1 rounded-full text-sm mb-6">
|
<Link
|
||||||
<GithubIcon className="h-3 w-3" />
|
href={"https://github.com/OpenCut-app/OpenCut"}
|
||||||
Open Source
|
target="_blank"
|
||||||
</div>
|
>
|
||||||
|
<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">
|
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
||||||
Contributors
|
Contributors
|
||||||
</h1>
|
</h1>
|
||||||
@ -105,54 +111,56 @@ export default async function ContributorsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{topContributor && (
|
{topContributors.length > 0 && (
|
||||||
<div className="mb-20">
|
<div className="mb-20">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-2xl font-semibold mb-2">
|
<h2 className="text-2xl font-semibold mb-2">
|
||||||
Top Contributor
|
Top Contributors
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Leading the way in contributions
|
Leading the way in contributions
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<div className="flex flex-col md:flex-row gap-6 justify-center max-w-4xl mx-auto">
|
||||||
href={topContributor.html_url}
|
{topContributors.map((contributor, index) => (
|
||||||
target="_blank"
|
<Link
|
||||||
rel="noopener noreferrer"
|
key={contributor.id}
|
||||||
className="group block"
|
href={contributor.html_url}
|
||||||
>
|
target="_blank"
|
||||||
<div className="relative mx-auto max-w-md">
|
rel="noopener noreferrer"
|
||||||
<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" />
|
className="group block flex-1"
|
||||||
<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 mx-auto max-w-md">
|
||||||
<div className="relative mb-6">
|
<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" />
|
||||||
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
|
<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">
|
||||||
<AvatarImage
|
<CardContent className="p-8 text-center">
|
||||||
src={topContributor.avatar_url}
|
<div className="relative mb-6">
|
||||||
alt={`${topContributor.login}'s avatar`}
|
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
|
||||||
/>
|
<AvatarImage
|
||||||
<AvatarFallback className="text-lg font-semibold">
|
src={contributor.avatar_url}
|
||||||
{topContributor.login.charAt(0).toUpperCase()}
|
alt={`${contributor.login}'s avatar`}
|
||||||
</AvatarFallback>
|
/>
|
||||||
</Avatar>
|
<AvatarFallback className="text-lg font-semibold">
|
||||||
<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">
|
{contributor.login.charAt(0).toUpperCase()}
|
||||||
1
|
</AvatarFallback>
|
||||||
</div>
|
</Avatar>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
|
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
|
||||||
{topContributor.login}
|
{contributor.login}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{topContributor.contributions}
|
{contributor.contributions}
|
||||||
</span>
|
</span>
|
||||||
<span>contributions</span>
|
<span>contributions</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -167,7 +175,7 @@ export default async function ContributorsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-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) => (
|
{otherContributors.map((contributor, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={contributor.id}
|
key={contributor.id}
|
||||||
@ -179,8 +187,8 @@ export default async function ContributorsPage() {
|
|||||||
animationDelay: `${index * 50}ms`,
|
animationDelay: `${index * 50}ms`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-center p-4 rounded-xl hover:bg-muted/50 transition-all duration-300 group-hover:scale-105">
|
<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 ring-2 ring-transparent group-hover:ring-muted-foreground/20 transition-all duration-300">
|
<Avatar className="h-16 w-16 mx-auto mb-3">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={contributor.avatar_url}
|
src={contributor.avatar_url}
|
||||||
alt={`${contributor.login}'s avatar`}
|
alt={`${contributor.login}'s avatar`}
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import "./editor.css";
|
import { useParams } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
ResizableHandle,
|
ResizableHandle,
|
||||||
} from "../../components/ui/resizable";
|
} from "../../../components/ui/resizable";
|
||||||
import { MediaPanel } from "../../components/editor/media-panel";
|
import { MediaPanel } from "../../../components/editor/media-panel";
|
||||||
// import { PropertiesPanel } from "../../components/editor/properties-panel";
|
import { PropertiesPanel } from "../../../components/editor/properties-panel";
|
||||||
import { Timeline } from "../../components/editor/timeline";
|
import { Timeline } from "../../../components/editor/timeline";
|
||||||
import { PreviewPanel } from "../../components/editor/preview-panel";
|
import { PreviewPanel } from "../../../components/editor/preview-panel";
|
||||||
import { EditorHeader } from "@/components/editor-header";
|
import { EditorHeader } from "@/components/editor-header";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
@ -21,32 +21,47 @@ export default function Editor() {
|
|||||||
const {
|
const {
|
||||||
toolsPanel,
|
toolsPanel,
|
||||||
previewPanel,
|
previewPanel,
|
||||||
propertiesPanel,
|
|
||||||
mainContent,
|
mainContent,
|
||||||
timeline,
|
timeline,
|
||||||
setToolsPanel,
|
setToolsPanel,
|
||||||
setPreviewPanel,
|
setPreviewPanel,
|
||||||
setPropertiesPanel,
|
|
||||||
setMainContent,
|
setMainContent,
|
||||||
setTimeline,
|
setTimeline,
|
||||||
|
propertiesPanel,
|
||||||
|
setPropertiesPanel,
|
||||||
} = usePanelStore();
|
} = usePanelStore();
|
||||||
|
|
||||||
const { activeProject, createNewProject } = useProjectStore();
|
const { activeProject, loadProject, createNewProject } = useProjectStore();
|
||||||
|
const params = useParams();
|
||||||
|
const projectId = params.project_id as string;
|
||||||
|
|
||||||
usePlaybackControls();
|
usePlaybackControls();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeProject) {
|
const initializeProject = async () => {
|
||||||
createNewProject("Untitled Project");
|
if (projectId && (!activeProject || activeProject.id !== projectId)) {
|
||||||
}
|
try {
|
||||||
}, [activeProject, createNewProject]);
|
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 (
|
return (
|
||||||
<EditorProvider>
|
<EditorProvider>
|
||||||
<div className="h-screen w-screen flex flex-col bg-background overflow-hidden">
|
<div className="h-screen w-screen flex flex-col bg-background overflow-hidden">
|
||||||
<EditorHeader />
|
<EditorHeader />
|
||||||
<div className="flex-1 min-h-0 min-w-0">
|
<div className="flex-1 min-h-0 min-w-0">
|
||||||
<ResizablePanelGroup direction="vertical" className="h-full w-full">
|
<ResizablePanelGroup
|
||||||
|
direction="vertical"
|
||||||
|
className="h-full w-full gap-[0.18rem]"
|
||||||
|
>
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
defaultSize={mainContent}
|
defaultSize={mainContent}
|
||||||
minSize={30}
|
minSize={30}
|
||||||
@ -55,7 +70,10 @@ export default function Editor() {
|
|||||||
className="min-h-0"
|
className="min-h-0"
|
||||||
>
|
>
|
||||||
{/* Main content area */}
|
{/* 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 */}
|
{/* Tools Panel */}
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
defaultSize={toolsPanel}
|
defaultSize={toolsPanel}
|
||||||
@ -81,8 +99,7 @@ export default function Editor() {
|
|||||||
|
|
||||||
<ResizableHandle withHandle />
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
{/* Properties Panel - Hidden for now but ready */}
|
<ResizablePanel
|
||||||
{/* <ResizablePanel
|
|
||||||
defaultSize={propertiesPanel}
|
defaultSize={propertiesPanel}
|
||||||
minSize={15}
|
minSize={15}
|
||||||
maxSize={40}
|
maxSize={40}
|
||||||
@ -90,7 +107,7 @@ export default function Editor() {
|
|||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
>
|
>
|
||||||
<PropertiesPanel />
|
<PropertiesPanel />
|
||||||
</ResizablePanel> */}
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
@ -102,7 +119,7 @@ export default function Editor() {
|
|||||||
minSize={15}
|
minSize={15}
|
||||||
maxSize={70}
|
maxSize={70}
|
||||||
onResize={setTimeline}
|
onResize={setTimeline}
|
||||||
className="min-h-0"
|
className="min-h-0 px-2 pb-2"
|
||||||
>
|
>
|
||||||
<Timeline />
|
<Timeline />
|
||||||
</ResizablePanel>
|
</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%;
|
--sidebar-ring: 0 0% 3.9%;
|
||||||
}
|
}
|
||||||
.dark {
|
.dark {
|
||||||
--background: 0 0% 8%;
|
--background: 0 0% 4%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 89%;
|
||||||
--card: 0 0% 3.9%;
|
--card: 0 0% 14.9%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 0 0% 98%;
|
||||||
--popover: 0 0% 3.9%;
|
--popover: 0 0% 14.9%;
|
||||||
--popover-foreground: 0 0% 98%;
|
--popover-foreground: 0 0% 98%;
|
||||||
--primary: 0 0% 98%;
|
--primary: 180 95% 40%;
|
||||||
--primary-foreground: 0 0% 9%;
|
--primary-foreground: 0 0% 9%;
|
||||||
--secondary: 0 0% 14.9%;
|
--secondary: 0 0% 14.9%;
|
||||||
--secondary-foreground: 0 0% 98%;
|
--secondary-foreground: 0 0% 98%;
|
||||||
@ -55,7 +55,7 @@
|
|||||||
--accent-foreground: 0 0% 98%;
|
--accent-foreground: 0 0% 98%;
|
||||||
--destructive: 0 100% 60%;
|
--destructive: 0 100% 60%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 0 0% 14.9%;
|
--border: 0 0% 17%;
|
||||||
--input: 0 0% 14.9%;
|
--input: 0 0% 14.9%;
|
||||||
--ring: 0 0% 83.1%;
|
--ring: 0 0% 83.1%;
|
||||||
--chart-1: 220 70% 50%;
|
--chart-1: 220 70% 50%;
|
||||||
@ -71,6 +71,8 @@
|
|||||||
--sidebar-accent-foreground: 0 0% 98%;
|
--sidebar-accent-foreground: 0 0% 98%;
|
||||||
--sidebar-border: 0 0% 14.9%;
|
--sidebar-border: 0 0% 14.9%;
|
||||||
--sidebar-ring: 0 0% 83.1%;
|
--sidebar-ring: 0 0% 83.1%;
|
||||||
|
--panel-background: 0 0% 11%;
|
||||||
|
--panel-accent: 0 0% 15%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,5 +82,7 @@
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@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 { ThemeProvider } from "next-themes";
|
||||||
import { Analytics } from "@vercel/analytics/react";
|
import { Analytics } from "@vercel/analytics/react";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Toaster } from "../components/ui/sonner";
|
import { Toaster } from "../components/ui/sonner";
|
||||||
import { TooltipProvider } from "../components/ui/tooltip";
|
import { TooltipProvider } from "../components/ui/tooltip";
|
||||||
|
import { DevelopmentDebug } from "../components/development-debug";
|
||||||
|
import { StorageProvider } from "../components/storage-provider";
|
||||||
|
import { baseMetaData } from "./metadata";
|
||||||
|
import { defaultFont } from "../lib/font-config";
|
||||||
|
|
||||||
const inter = Inter({
|
export const metadata = baseMetaData;
|
||||||
subsets: ["latin"],
|
|
||||||
variable: "--font-inter",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "OpenCut",
|
|
||||||
description:
|
|
||||||
"A simple but powerful video editor that gets the job done. In your browser.",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
@ -25,21 +18,23 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${inter.variable} font-sans antialiased`}>
|
<body className={`${defaultFont.className} font-sans antialiased`}>
|
||||||
<ThemeProvider attribute="class" forcedTheme="dark" enableSystem>
|
<ThemeProvider attribute="class" forcedTheme="dark">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
{children}
|
<StorageProvider>{children}</StorageProvider>
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
<DevelopmentDebug />
|
||||||
<Script
|
<Script
|
||||||
src="https://app.databuddy.cc/databuddy.js"
|
src="https://cdn.databuddy.cc/databuddy.js"
|
||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
async
|
async
|
||||||
data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
|
data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
|
||||||
data-track-attributes={true}
|
data-track-attributes={false}
|
||||||
data-track-errors={true}
|
data-track-errors={true}
|
||||||
data-track-outgoing-links={true}
|
data-track-outgoing-links={false}
|
||||||
data-track-web-vitals={true}
|
data-track-web-vitals={false}
|
||||||
|
data-track-sessions={false}
|
||||||
/>
|
/>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ThemeProvider>
|
</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,17 +1,19 @@
|
|||||||
import { Hero } from "@/components/landing/hero";
|
import { Hero } from "@/components/landing/hero";
|
||||||
import { Header } from "@/components/header";
|
import { Header } from "@/components/header";
|
||||||
import { getWaitlistCount } from "@/lib/waitlist";
|
import { Footer } from "@/components/footer";
|
||||||
|
import { getWaitlistCount } from "@/lib/waitlist";
|
||||||
// Force dynamic rendering so waitlist count updates in real-time
|
|
||||||
export const dynamic = "force-dynamic";
|
// Force dynamic rendering so waitlist count updates in real-time
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
export default async function Home() {
|
|
||||||
const signupCount = await getWaitlistCount();
|
export default async function Home() {
|
||||||
|
const signupCount = await getWaitlistCount();
|
||||||
return (
|
|
||||||
<div>
|
return (
|
||||||
<Header />
|
<div>
|
||||||
<Hero signupCount={signupCount} />
|
<Header />
|
||||||
</div>
|
<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 Link from "next/link";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ChevronLeft, Download } from "lucide-react";
|
import { ChevronLeft, Download } from "lucide-react";
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
import { HeaderBase } from "./header-base";
|
import { HeaderBase } from "./header-base";
|
||||||
|
import { formatTimeCode } from "@/lib/time";
|
||||||
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
|
|
||||||
export function EditorHeader() {
|
export function EditorHeader() {
|
||||||
const { activeProject } = useProjectStore();
|
|
||||||
const { getTotalDuration } = useTimelineStore();
|
const { getTotalDuration } = useTimelineStore();
|
||||||
|
const { activeProject } = useProjectStore();
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
// TODO: Implement export functionality
|
// TODO: Implement export functionality
|
||||||
console.log("Export project");
|
console.log("Export project");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format duration from seconds to MM:SS format
|
|
||||||
const formatDuration = (seconds: number): string => {
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const remainingSeconds = Math.floor(seconds % 60);
|
|
||||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const leftContent = (
|
const leftContent = (
|
||||||
<Link
|
<div className="flex items-center gap-2">
|
||||||
href="/"
|
<Link
|
||||||
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
|
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 || "Loading..."}</span>
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Link>
|
<span className="text-sm">{activeProject?.name}</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const centerContent = (
|
const centerContent = (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span>{formatDuration(getTotalDuration())}</span>
|
<span>
|
||||||
|
{formatTimeCode(
|
||||||
|
getTotalDuration(),
|
||||||
|
"HH:MM:SS:FF",
|
||||||
|
activeProject?.fps || 30
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const rightContent = (
|
const rightContent = (
|
||||||
<nav className="flex items-center gap-2">
|
<nav className="flex items-center gap-2">
|
||||||
<Button size="sm" onClick={handleExport}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
onClick={handleExport}
|
||||||
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
<span className="text-sm">Export</span>
|
<span className="text-sm">Export</span>
|
||||||
</Button>
|
</Button>
|
||||||
@ -53,7 +60,7 @@ export function EditorHeader() {
|
|||||||
leftContent={leftContent}
|
leftContent={leftContent}
|
||||||
centerContent={centerContent}
|
centerContent={centerContent}
|
||||||
rightContent={rightContent}
|
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,301 +1,311 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "../ui/button";
|
import { useDragDrop } from "@/hooks/use-drag-drop";
|
||||||
import { AspectRatio } from "../ui/aspect-ratio";
|
import { processMediaFiles } from "@/lib/media-processing";
|
||||||
import { DragOverlay } from "../ui/drag-overlay";
|
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||||
import { useMediaStore } from "@/stores/media-store";
|
import { Image, Music, Plus, Upload, Video } from "lucide-react";
|
||||||
import { processMediaFiles } from "@/lib/media-processing";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react";
|
import { toast } from "sonner";
|
||||||
import { useDragDrop } from "@/hooks/use-drag-drop";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { DragOverlay } from "@/components/ui/drag-overlay";
|
||||||
import { toast } from "sonner";
|
import {
|
||||||
|
ContextMenu,
|
||||||
// MediaPanel lets users add, view, and drag media (images, videos, audio) into the project.
|
ContextMenuContent,
|
||||||
// You can upload files or drag them from your computer. Dragging from here to the timeline adds them to your video project.
|
ContextMenuItem,
|
||||||
|
ContextMenuTrigger,
|
||||||
export function MediaPanel() {
|
} from "@/components/ui/context-menu";
|
||||||
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
import { Input } from "@/components/ui/input";
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
import {
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
Select,
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
SelectContent,
|
||||||
const [mediaFilter, setMediaFilter] = useState("all");
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
const processFiles = async (files: FileList | File[]) => {
|
SelectValue,
|
||||||
// If no files, do nothing
|
} from "@/components/ui/select";
|
||||||
if (!files?.length) return;
|
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||||
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
setIsProcessing(true);
|
|
||||||
try {
|
export function MediaView() {
|
||||||
// Process files (extract metadata, generate thumbnails, etc.)
|
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
||||||
const items = await processMediaFiles(files);
|
const { activeProject } = useProjectStore();
|
||||||
// Add each processed media item to the store
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
items.forEach((item) => {
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
addMediaItem(item);
|
const [progress, setProgress] = useState(0);
|
||||||
});
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
} catch (error) {
|
const [mediaFilter, setMediaFilter] = useState("all");
|
||||||
// Show error if processing fails
|
|
||||||
console.error("File processing failed:", error);
|
const processFiles = async (files: FileList | File[]) => {
|
||||||
toast.error("Failed to process files");
|
if (!files || files.length === 0) return;
|
||||||
} finally {
|
if (!activeProject) {
|
||||||
setIsProcessing(false);
|
toast.error("No active project");
|
||||||
}
|
return;
|
||||||
};
|
}
|
||||||
|
|
||||||
const { isDragOver, dragProps } = useDragDrop({
|
setIsProcessing(true);
|
||||||
// When files are dropped, process them
|
setProgress(0);
|
||||||
onDrop: processFiles,
|
try {
|
||||||
});
|
// Process files (extract metadata, generate thumbnails, etc.)
|
||||||
|
const processedItems = await processMediaFiles(files, (p) =>
|
||||||
const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
|
setProgress(p)
|
||||||
|
);
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
// Add each processed media item to the store
|
||||||
// When files are selected via file picker, process them
|
for (const item of processedItems) {
|
||||||
if (e.target.files) processFiles(e.target.files);
|
await addMediaItem(activeProject.id, item);
|
||||||
e.target.value = ""; // Reset input
|
}
|
||||||
};
|
} catch (error) {
|
||||||
|
// Show error toast if processing fails
|
||||||
const handleRemove = (e: React.MouseEvent, id: string) => {
|
console.error("Error processing files:", error);
|
||||||
// Remove a media item from the store
|
toast.error("Failed to process files");
|
||||||
e.stopPropagation();
|
} finally {
|
||||||
removeMediaItem(id);
|
setIsProcessing(false);
|
||||||
};
|
setProgress(0);
|
||||||
|
}
|
||||||
const formatDuration = (duration: number) => {
|
};
|
||||||
// Format seconds as mm:ss
|
|
||||||
const min = Math.floor(duration / 60);
|
const { isDragOver, dragProps } = useDragDrop({
|
||||||
const sec = Math.floor(duration % 60);
|
// When files are dropped, process them
|
||||||
return `${min}:${sec.toString().padStart(2, "0")}`;
|
onDrop: processFiles,
|
||||||
};
|
});
|
||||||
|
|
||||||
const startDrag = (e: React.DragEvent, item: any) => {
|
const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
|
||||||
// When dragging a media item, set drag data for timeline to read
|
|
||||||
e.dataTransfer.setData(
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
"application/x-media-item",
|
// When files are selected via file picker, process them
|
||||||
JSON.stringify({
|
if (e.target.files) processFiles(e.target.files);
|
||||||
id: item.id,
|
e.target.value = ""; // Reset input
|
||||||
type: item.type,
|
};
|
||||||
name: item.name,
|
|
||||||
})
|
const handleRemove = async (e: React.MouseEvent, id: string) => {
|
||||||
);
|
// Remove a media item from the store
|
||||||
e.dataTransfer.effectAllowed = "copy";
|
e.stopPropagation();
|
||||||
};
|
|
||||||
|
if (!activeProject) {
|
||||||
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
|
toast.error("No active project");
|
||||||
|
return;
|
||||||
useEffect(() => {
|
}
|
||||||
const filtered = mediaItems.filter((item) => {
|
|
||||||
if (mediaFilter && mediaFilter !== 'all' && item.type !== mediaFilter) {
|
// Media store now handles cascade deletion automatically
|
||||||
return false;
|
await removeMediaItem(activeProject.id, id);
|
||||||
}
|
};
|
||||||
|
|
||||||
if (searchQuery && !item.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
const formatDuration = (duration: number) => {
|
||||||
return false;
|
// Format seconds as mm:ss
|
||||||
}
|
const min = Math.floor(duration / 60);
|
||||||
|
const sec = Math.floor(duration % 60);
|
||||||
return true;
|
return `${min}:${sec.toString().padStart(2, "0")}`;
|
||||||
});
|
};
|
||||||
|
|
||||||
setFilteredMediaItems(filtered);
|
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
|
||||||
}, [mediaItems, mediaFilter, searchQuery]);
|
|
||||||
|
useEffect(() => {
|
||||||
const renderPreview = (item: any) => {
|
const filtered = mediaItems.filter((item) => {
|
||||||
// Render a preview for each media type (image, video, audio, unknown)
|
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
|
||||||
// Each preview is draggable to the timeline
|
return false;
|
||||||
const baseDragProps = {
|
}
|
||||||
draggable: true,
|
|
||||||
onDragStart: (e: React.DragEvent) => startDrag(e, item),
|
if (
|
||||||
};
|
searchQuery &&
|
||||||
|
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
if (item.type === "image") {
|
) {
|
||||||
return (
|
return false;
|
||||||
<img
|
}
|
||||||
src={item.url}
|
|
||||||
alt={item.name}
|
return true;
|
||||||
className="w-full h-full object-cover rounded cursor-grab active:cursor-grabbing"
|
});
|
||||||
loading="lazy"
|
|
||||||
{...baseDragProps}
|
setFilteredMediaItems(filtered);
|
||||||
/>
|
}, [mediaItems, mediaFilter, searchQuery]);
|
||||||
);
|
|
||||||
}
|
const renderPreview = (item: MediaItem) => {
|
||||||
|
// Render a preview for each media type (image, video, audio, unknown)
|
||||||
if (item.type === "video") {
|
if (item.type === "image") {
|
||||||
if (item.thumbnailUrl) {
|
return (
|
||||||
return (
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
<div
|
<img
|
||||||
className="relative w-full h-full cursor-grab active:cursor-grabbing"
|
src={item.url}
|
||||||
{...baseDragProps}
|
alt={item.name}
|
||||||
>
|
className="max-w-full max-h-full object-contain"
|
||||||
<img
|
loading="lazy"
|
||||||
src={item.thumbnailUrl}
|
/>
|
||||||
alt={item.name}
|
</div>
|
||||||
className="w-full h-full object-cover rounded"
|
);
|
||||||
loading="lazy"
|
}
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
|
if (item.type === "video") {
|
||||||
<Video className="h-6 w-6 text-white drop-shadow-md" />
|
if (item.thumbnailUrl) {
|
||||||
</div>
|
return (
|
||||||
{item.duration && (
|
<div className="relative w-full h-full">
|
||||||
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
|
<img
|
||||||
{formatDuration(item.duration)}
|
src={item.thumbnailUrl}
|
||||||
</div>
|
alt={item.name}
|
||||||
)}
|
className="w-full h-full object-cover rounded"
|
||||||
</div>
|
loading="lazy"
|
||||||
);
|
/>
|
||||||
}
|
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
|
||||||
return (
|
<Video className="h-6 w-6 text-white drop-shadow-md" />
|
||||||
<div
|
</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"
|
{item.duration && (
|
||||||
{...baseDragProps}
|
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
|
||||||
>
|
{formatDuration(item.duration)}
|
||||||
<Video className="h-6 w-6 mb-1" />
|
</div>
|
||||||
<span className="text-xs">Video</span>
|
)}
|
||||||
{item.duration && (
|
</div>
|
||||||
<span className="text-xs opacity-70">
|
);
|
||||||
{formatDuration(item.duration)}
|
}
|
||||||
</span>
|
return (
|
||||||
)}
|
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
|
||||||
</div>
|
<Video className="h-6 w-6 mb-1" />
|
||||||
);
|
<span className="text-xs">Video</span>
|
||||||
}
|
{item.duration && (
|
||||||
|
<span className="text-xs opacity-70">
|
||||||
if (item.type === "audio") {
|
{formatDuration(item.duration)}
|
||||||
return (
|
</span>
|
||||||
<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"
|
</div>
|
||||||
{...baseDragProps}
|
);
|
||||||
>
|
}
|
||||||
<Music className="h-6 w-6 mb-1" />
|
|
||||||
<span className="text-xs">Audio</span>
|
if (item.type === "audio") {
|
||||||
{item.duration && (
|
return (
|
||||||
<span className="text-xs opacity-70">
|
<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">
|
||||||
{formatDuration(item.duration)}
|
<Music className="h-6 w-6 mb-1" />
|
||||||
</span>
|
<span className="text-xs">Audio</span>
|
||||||
)}
|
{item.duration && (
|
||||||
</div>
|
<span className="text-xs opacity-70">
|
||||||
);
|
{formatDuration(item.duration)}
|
||||||
}
|
</span>
|
||||||
|
)}
|
||||||
return (
|
</div>
|
||||||
<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}
|
|
||||||
>
|
return (
|
||||||
<Image className="h-6 w-6" />
|
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
|
||||||
<span className="text-xs mt-1">Unknown</span>
|
<Image className="h-6 w-6" />
|
||||||
</div>
|
<span className="text-xs mt-1">Unknown</span>
|
||||||
);
|
</div>
|
||||||
};
|
);
|
||||||
|
};
|
||||||
return (
|
|
||||||
<>
|
return (
|
||||||
{/* Hidden file input for uploading media */}
|
<>
|
||||||
<input
|
{/* Hidden file input for uploading media */}
|
||||||
ref={fileInputRef}
|
<input
|
||||||
type="file"
|
ref={fileInputRef}
|
||||||
accept="image/*,video/*,audio/*"
|
type="file"
|
||||||
multiple
|
accept="image/*,video/*,audio/*"
|
||||||
className="hidden"
|
multiple
|
||||||
onChange={handleFileChange}
|
className="hidden"
|
||||||
/>
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
<div
|
|
||||||
className={`h-full flex flex-col transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
|
<div
|
||||||
{...dragProps}
|
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} />
|
{/* Show overlay when dragging files over the panel */}
|
||||||
|
<DragOverlay isVisible={isDragOver} />
|
||||||
<div className="p-2 border-b">
|
|
||||||
{/* Button to add/upload media */}
|
<div className="p-3 pb-2">
|
||||||
<div className="flex gap-2">
|
{/* Button to add/upload media */}
|
||||||
{/* Search and filter controls */}
|
<div className="flex gap-2">
|
||||||
<select
|
{/* Search and filter controls */}
|
||||||
value={mediaFilter}
|
<Select value={mediaFilter} onValueChange={setMediaFilter}>
|
||||||
onChange={(e) => setMediaFilter(e.target.value)}
|
<SelectTrigger className="w-[80px] h-full text-xs">
|
||||||
className="px-2 py-1 text-xs border rounded bg-background"
|
<SelectValue />
|
||||||
>
|
</SelectTrigger>
|
||||||
<option value="all">All</option>
|
<SelectContent className="">
|
||||||
<option value="video">Video</option>
|
<SelectItem value="all">All</SelectItem>
|
||||||
<option value="audio">Audio</option>
|
<SelectItem value="video">Video</SelectItem>
|
||||||
<option value="image">Image</option>
|
<SelectItem value="audio">Audio</SelectItem>
|
||||||
</select>
|
<SelectItem value="image">Image</SelectItem>
|
||||||
<input
|
</SelectContent>
|
||||||
type="text"
|
</Select>
|
||||||
placeholder="Search media..."
|
<Input
|
||||||
className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background"
|
type="text"
|
||||||
value={searchQuery}
|
placeholder="Search media..."
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
className="min-w-[60px] flex-1 h-full text-xs"
|
||||||
/>
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
{/* Add media button */}
|
/>
|
||||||
<Button
|
|
||||||
variant="outline"
|
{/* Add media button */}
|
||||||
size="sm"
|
<Button
|
||||||
onClick={handleFileSelect}
|
variant="outline"
|
||||||
disabled={isProcessing}
|
size="sm"
|
||||||
className="flex-none min-w-[80px] whitespace-nowrap"
|
onClick={handleFileSelect}
|
||||||
>
|
disabled={isProcessing}
|
||||||
{isProcessing ? (
|
className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
|
||||||
<>
|
>
|
||||||
<Upload className="h-4 w-4 mr-2 animate-spin" />
|
{isProcessing ? (
|
||||||
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
|
<>
|
||||||
</>
|
<Plus className="h-4 w-4" />
|
||||||
)}
|
<span className="hidden sm:inline ml-2" aria-label="Add file">
|
||||||
</Button>
|
Add
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
</Button>
|
||||||
{/* Show message if no media, otherwise show media grid */}
|
</div>
|
||||||
{filteredMediaItems.length === 0 ? (
|
</div>
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center h-full">
|
|
||||||
<div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
|
<div className="flex-1 overflow-y-auto p-3 pt-0">
|
||||||
<Image className="h-8 w-8 text-muted-foreground" />
|
{/* Show message if no media, otherwise show media grid */}
|
||||||
</div>
|
{filteredMediaItems.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-8 text-center h-full">
|
||||||
No media in project
|
<div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
|
||||||
</p>
|
<Image className="h-8 w-8 text-muted-foreground" />
|
||||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
</div>
|
||||||
Drag files here or use the button above
|
<p className="text-sm text-muted-foreground">
|
||||||
</p>
|
No media in project
|
||||||
</div>
|
</p>
|
||||||
) : (
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
Drag files here or use the button above
|
||||||
{/* Render each media item as a draggable button */}
|
</p>
|
||||||
{filteredMediaItems.map((item) => (
|
</div>
|
||||||
<div key={item.id} className="relative group">
|
) : (
|
||||||
<Button
|
<div
|
||||||
variant="outline"
|
className="grid gap-2"
|
||||||
className="flex flex-col gap-2 p-2 h-auto w-full relative"
|
style={{
|
||||||
>
|
gridTemplateColumns: "repeat(auto-fill, 160px)",
|
||||||
<AspectRatio ratio={item.aspectRatio}>
|
}}
|
||||||
{renderPreview(item)}
|
>
|
||||||
</AspectRatio>
|
{/* Render each media item as a draggable button */}
|
||||||
<span className="text-xs truncate px-1">{item.name}</span>
|
{filteredMediaItems.map((item) => (
|
||||||
</Button>
|
<ContextMenu key={item.id}>
|
||||||
|
<ContextMenuTrigger>
|
||||||
{/* Show remove button on hover */}
|
<DraggableMediaItem
|
||||||
<div className="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
name={item.name}
|
||||||
<Button
|
preview={renderPreview(item)}
|
||||||
variant="destructive"
|
dragData={{
|
||||||
size="icon"
|
id: item.id,
|
||||||
className="h-6 w-6"
|
type: item.type,
|
||||||
onClick={(e) => handleRemove(e, item.id)}
|
name: item.name,
|
||||||
>
|
}}
|
||||||
<Trash2 className="h-3 w-3" />
|
showPlusOnDrag={false}
|
||||||
</Button>
|
rounded={false}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</ContextMenuTrigger>
|
||||||
))}
|
<ContextMenuContent>
|
||||||
</div>
|
<ContextMenuItem>Export clips</ContextMenuItem>
|
||||||
)}
|
<ContextMenuItem
|
||||||
</div>
|
variant="destructive"
|
||||||
</div>
|
onClick={(e) => handleRemove(e, item.id)}
|
||||||
</>
|
>
|
||||||
);
|
Delete
|
||||||
}
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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,213 +1,497 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTimelineStore } from "@/stores/timeline-store";
|
import { useTimelineStore } from "@/stores/timeline-store";
|
||||||
import { useMediaStore } from "@/stores/media-store";
|
import { TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||||
import { usePlaybackStore } from "@/stores/playback-store";
|
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||||
import { VideoPlayer } from "@/components/ui/video-player";
|
import { usePlaybackStore } from "@/stores/playback-store";
|
||||||
import { Button } from "@/components/ui/button";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
import { Play, Pause } from "lucide-react";
|
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
|
||||||
import { useState, useRef } from "react";
|
import { VideoPlayer } from "@/components/ui/video-player";
|
||||||
|
import { AudioPlayer } from "@/components/ui/audio-player";
|
||||||
// Debug flag - set to false to hide active clips info
|
import { Button } from "@/components/ui/button";
|
||||||
const SHOW_DEBUG_INFO = process.env.NODE_ENV === 'development';
|
import {
|
||||||
|
DropdownMenu,
|
||||||
export function PreviewPanel() {
|
DropdownMenuContent,
|
||||||
const { tracks } = useTimelineStore();
|
DropdownMenuItem,
|
||||||
const { mediaItems } = useMediaStore();
|
DropdownMenuTrigger,
|
||||||
const { isPlaying, toggle, currentTime } = usePlaybackStore();
|
DropdownMenuSeparator,
|
||||||
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
|
} from "@/components/ui/dropdown-menu";
|
||||||
const [showDebug, setShowDebug] = useState(SHOW_DEBUG_INFO);
|
import { Play, Pause, Expand } from "lucide-react";
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
// Get active clips at current time
|
import { formatTimeCode } from "@/lib/time";
|
||||||
const getActiveClips = () => {
|
import { FONT_CLASS_MAP } from "@/lib/font-config";
|
||||||
const activeClips: Array<{
|
import { BackgroundSettings } from "../background-settings";
|
||||||
clip: any;
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
track: any;
|
|
||||||
mediaItem: any;
|
interface ActiveElement {
|
||||||
}> = [];
|
element: TimelineElement;
|
||||||
|
track: TimelineTrack;
|
||||||
tracks.forEach((track) => {
|
mediaItem: MediaItem | null;
|
||||||
track.clips.forEach((clip) => {
|
}
|
||||||
const clipStart = clip.startTime;
|
|
||||||
const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
|
export function PreviewPanel() {
|
||||||
|
const { tracks } = useTimelineStore();
|
||||||
if (currentTime >= clipStart && currentTime < clipEnd) {
|
const { mediaItems } = useMediaStore();
|
||||||
const mediaItem = clip.mediaId === "test"
|
const { currentTime } = usePlaybackStore();
|
||||||
? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
|
const { canvasSize } = useEditorStore();
|
||||||
: mediaItems.find((item) => item.id === clip.mediaId);
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
if (mediaItem || clip.mediaId === "test") {
|
const [previewDimensions, setPreviewDimensions] = useState({
|
||||||
activeClips.push({ clip, track, mediaItem });
|
width: 0,
|
||||||
}
|
height: 0,
|
||||||
}
|
});
|
||||||
});
|
const { activeProject } = useProjectStore();
|
||||||
});
|
|
||||||
|
// Calculate optimal preview size that fits in container while maintaining aspect ratio
|
||||||
return activeClips;
|
useEffect(() => {
|
||||||
};
|
const updatePreviewSize = () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
const activeClips = getActiveClips();
|
|
||||||
const aspectRatio = canvasSize.width / canvasSize.height;
|
const container = containerRef.current.getBoundingClientRect();
|
||||||
|
const computedStyle = getComputedStyle(containerRef.current);
|
||||||
// Render a clip
|
|
||||||
const renderClip = (clipData: any, index: number) => {
|
// Get padding values
|
||||||
const { clip, mediaItem } = clipData;
|
const paddingTop = parseFloat(computedStyle.paddingTop);
|
||||||
|
const paddingBottom = parseFloat(computedStyle.paddingBottom);
|
||||||
// Test clips
|
const paddingLeft = parseFloat(computedStyle.paddingLeft);
|
||||||
if (!mediaItem || clip.mediaId === "test") {
|
const paddingRight = parseFloat(computedStyle.paddingRight);
|
||||||
return (
|
|
||||||
<div
|
// Get gap value (gap-4 = 1rem = 16px)
|
||||||
key={clip.id}
|
const gap = parseFloat(computedStyle.gap) || 16;
|
||||||
className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
|
|
||||||
>
|
// Get toolbar height if it exists
|
||||||
<div className="text-center">
|
const toolbar = containerRef.current.querySelector("[data-toolbar]");
|
||||||
<div className="text-2xl mb-2">🎬</div>
|
const toolbarHeight = toolbar
|
||||||
<p className="text-xs text-white">{clip.name}</p>
|
? toolbar.getBoundingClientRect().height
|
||||||
</div>
|
: 0;
|
||||||
</div>
|
|
||||||
);
|
// Calculate available space after accounting for padding, gap, and toolbar
|
||||||
}
|
const availableWidth = container.width - paddingLeft - paddingRight;
|
||||||
|
const availableHeight =
|
||||||
// Video clips
|
container.height -
|
||||||
if (mediaItem.type === "video") {
|
paddingTop -
|
||||||
return (
|
paddingBottom -
|
||||||
<div key={clip.id} className="absolute inset-0">
|
toolbarHeight -
|
||||||
<VideoPlayer
|
(toolbarHeight > 0 ? gap : 0);
|
||||||
src={mediaItem.url}
|
|
||||||
poster={mediaItem.thumbnailUrl}
|
const targetRatio = canvasSize.width / canvasSize.height;
|
||||||
clipStartTime={clip.startTime}
|
const containerRatio = availableWidth / availableHeight;
|
||||||
trimStart={clip.trimStart}
|
|
||||||
trimEnd={clip.trimEnd}
|
let width, height;
|
||||||
clipDuration={clip.duration}
|
|
||||||
/>
|
if (containerRatio > targetRatio) {
|
||||||
</div>
|
// Container is wider - constrain by height
|
||||||
);
|
height = availableHeight;
|
||||||
}
|
width = height * targetRatio;
|
||||||
|
} else {
|
||||||
// Image clips
|
// Container is taller - constrain by width
|
||||||
if (mediaItem.type === "image") {
|
width = availableWidth;
|
||||||
return (
|
height = width / targetRatio;
|
||||||
<div key={clip.id} className="absolute inset-0">
|
}
|
||||||
<img
|
|
||||||
src={mediaItem.url}
|
setPreviewDimensions({ width, height });
|
||||||
alt={mediaItem.name}
|
};
|
||||||
className="w-full h-full object-cover"
|
|
||||||
draggable={false}
|
updatePreviewSize();
|
||||||
/>
|
|
||||||
</div>
|
const resizeObserver = new ResizeObserver(updatePreviewSize);
|
||||||
);
|
if (containerRef.current) {
|
||||||
}
|
resizeObserver.observe(containerRef.current);
|
||||||
|
}
|
||||||
// Audio clips (visual representation)
|
|
||||||
if (mediaItem.type === "audio") {
|
return () => resizeObserver.disconnect();
|
||||||
return (
|
}, [canvasSize.width, canvasSize.height]);
|
||||||
<div
|
|
||||||
key={clip.id}
|
// Get active elements at current time
|
||||||
className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
|
const getActiveElements = (): ActiveElement[] => {
|
||||||
>
|
const activeElements: ActiveElement[] = [];
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl mb-2">🎵</div>
|
tracks.forEach((track) => {
|
||||||
<p className="text-xs text-white">{mediaItem.name}</p>
|
track.elements.forEach((element) => {
|
||||||
</div>
|
const elementStart = element.startTime;
|
||||||
</div>
|
const elementEnd =
|
||||||
);
|
element.startTime +
|
||||||
}
|
(element.duration - element.trimStart - element.trimEnd);
|
||||||
|
|
||||||
return null;
|
if (currentTime >= elementStart && currentTime < elementEnd) {
|
||||||
};
|
let mediaItem = null;
|
||||||
|
|
||||||
// Canvas presets
|
// Only get media item for media elements
|
||||||
const canvasPresets = [
|
if (element.type === "media") {
|
||||||
{ name: "16:9 HD", width: 1920, height: 1080 },
|
mediaItem =
|
||||||
{ name: "16:9 4K", width: 3840, height: 2160 },
|
element.mediaId === "test"
|
||||||
{ name: "9:16 Mobile", width: 1080, height: 1920 },
|
? null // Test elements don't have a real media item
|
||||||
{ name: "1:1 Square", width: 1080, height: 1080 },
|
: mediaItems.find((item) => item.id === element.mediaId) ||
|
||||||
{ name: "4:3 Standard", width: 1440, height: 1080 },
|
null;
|
||||||
];
|
}
|
||||||
|
|
||||||
return (
|
activeElements.push({ element, track, mediaItem });
|
||||||
<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
|
return activeElements;
|
||||||
value={`${canvasSize.width}x${canvasSize.height}`}
|
};
|
||||||
onChange={(e) => {
|
|
||||||
const preset = canvasPresets.find(p => `${p.width}x${p.height}` === e.target.value);
|
const activeElements = getActiveElements();
|
||||||
if (preset) setCanvasSize({ width: preset.width, height: preset.height });
|
|
||||||
}}
|
// Check if there are any elements in the timeline at all
|
||||||
className="bg-background border rounded px-2 py-1 text-xs"
|
const hasAnyElements = tracks.some((track) => track.elements.length > 0);
|
||||||
>
|
|
||||||
{canvasPresets.map(preset => (
|
// Get media elements for blur background (video/image only)
|
||||||
<option key={preset.name} value={`${preset.width}x${preset.height}`}>
|
const getBlurBackgroundElements = (): ActiveElement[] => {
|
||||||
{preset.name} ({preset.width}×{preset.height})
|
return activeElements.filter(
|
||||||
</option>
|
({ element, mediaItem }) =>
|
||||||
))}
|
element.type === "media" &&
|
||||||
</select>
|
mediaItem &&
|
||||||
|
(mediaItem.type === "video" || mediaItem.type === "image") &&
|
||||||
{/* Debug Toggle - Only show in development */}
|
element.mediaId !== "test" // Exclude test elements
|
||||||
{SHOW_DEBUG_INFO && (
|
);
|
||||||
<Button
|
};
|
||||||
variant="text"
|
|
||||||
size="sm"
|
const blurBackgroundElements = getBlurBackgroundElements();
|
||||||
onClick={() => setShowDebug(!showDebug)}
|
|
||||||
className="text-xs"
|
// Render blur background layer
|
||||||
>
|
const renderBlurBackground = () => {
|
||||||
Debug {showDebug ? 'ON' : 'OFF'}
|
if (
|
||||||
</Button>
|
!activeProject?.backgroundType ||
|
||||||
)}
|
activeProject.backgroundType !== "blur" ||
|
||||||
|
blurBackgroundElements.length === 0
|
||||||
<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" />}
|
return null;
|
||||||
{isPlaying ? "Pause" : "Play"}
|
}
|
||||||
</Button>
|
|
||||||
</div>
|
// Use the first media element for background (could be enhanced to use primary/focused element)
|
||||||
|
const backgroundElement = blurBackgroundElements[0];
|
||||||
{/* Preview Area */}
|
const { element, mediaItem } = backgroundElement;
|
||||||
<div className="flex-1 flex items-center justify-center p-2 sm:p-4 bg-gray-900 min-h-0 min-w-0">
|
|
||||||
<div
|
if (!mediaItem) return null;
|
||||||
ref={previewRef}
|
|
||||||
className="relative overflow-hidden rounded-sm max-w-full max-h-full bg-black border border-gray-600"
|
const blurIntensity = activeProject.blurIntensity || 8;
|
||||||
style={{
|
|
||||||
aspectRatio: aspectRatio.toString(),
|
if (mediaItem.type === "video") {
|
||||||
width: "100%",
|
return (
|
||||||
height: "100%",
|
<div
|
||||||
}}
|
key={`blur-${element.id}`}
|
||||||
>
|
className="absolute inset-0 overflow-hidden"
|
||||||
{activeClips.length === 0 ? (
|
style={{
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-white/50">
|
filter: `blur(${blurIntensity}px)`,
|
||||||
{tracks.length === 0 ? "Drop media to start editing" : "No clips at current time"}
|
transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts
|
||||||
</div>
|
transformOrigin: "center",
|
||||||
) : (
|
}}
|
||||||
activeClips.map((clipData, index) => renderClip(clipData, index))
|
>
|
||||||
)}
|
<VideoPlayer
|
||||||
</div>
|
src={mediaItem.url!}
|
||||||
</div>
|
poster={mediaItem.thumbnailUrl}
|
||||||
|
clipStartTime={element.startTime}
|
||||||
{/* Debug Info Panel - Conditionally rendered */}
|
trimStart={element.trimStart}
|
||||||
{showDebug && (
|
trimEnd={element.trimEnd}
|
||||||
<div className="border-t bg-background p-2 flex-shrink-0">
|
clipDuration={element.duration}
|
||||||
<div className="text-xs font-medium mb-1">Debug: Active Clips ({activeClips.length})</div>
|
className="w-full h-full object-cover"
|
||||||
<div className="flex gap-2 overflow-x-auto">
|
/>
|
||||||
{activeClips.map((clipData, index) => (
|
</div>
|
||||||
<div
|
);
|
||||||
key={clipData.clip.id}
|
}
|
||||||
className="flex items-center gap-1 px-2 py-1 bg-muted rounded text-xs whitespace-nowrap"
|
|
||||||
>
|
if (mediaItem.type === "image") {
|
||||||
<span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4">
|
return (
|
||||||
{index + 1}
|
<div
|
||||||
</span>
|
key={`blur-${element.id}`}
|
||||||
<span>{clipData.clip.name}</span>
|
className="absolute inset-0 overflow-hidden"
|
||||||
<span className="text-muted-foreground">({clipData.mediaItem?.type || 'test'})</span>
|
style={{
|
||||||
</div>
|
filter: `blur(${blurIntensity}px)`,
|
||||||
))}
|
transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts
|
||||||
{activeClips.length === 0 && (
|
transformOrigin: "center",
|
||||||
<span className="text-muted-foreground">No active clips</span>
|
}}
|
||||||
)}
|
>
|
||||||
</div>
|
<img
|
||||||
</div>
|
src={mediaItem.url!}
|
||||||
)}
|
alt={mediaItem.name}
|
||||||
</div>
|
className="w-full h-full object-cover"
|
||||||
);
|
draggable={false}
|
||||||
}
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render an element
|
||||||
|
const renderElement = (elementData: ActiveElement, index: number) => {
|
||||||
|
const { element, mediaItem } = elementData;
|
||||||
|
|
||||||
|
// Text elements
|
||||||
|
if (element.type === "text") {
|
||||||
|
const fontClassName =
|
||||||
|
FONT_CLASS_MAP[element.fontFamily as keyof typeof FONT_CLASS_MAP] || "";
|
||||||
|
|
||||||
|
const scaleRatio = previewDimensions.width / canvasSize.width;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={element.id}
|
||||||
|
className="absolute flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
left: `${50 + (element.x / canvasSize.width) * 100}%`,
|
||||||
|
top: `${50 + (element.y / canvasSize.height) * 100}%`,
|
||||||
|
transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`,
|
||||||
|
opacity: element.opacity,
|
||||||
|
zIndex: 100 + index, // Text elements on top
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={fontClassName}
|
||||||
|
style={{
|
||||||
|
fontSize: `${element.fontSize}px`,
|
||||||
|
color: element.color,
|
||||||
|
backgroundColor: element.backgroundColor,
|
||||||
|
textAlign: element.textAlign,
|
||||||
|
fontWeight: element.fontWeight,
|
||||||
|
fontStyle: element.fontStyle,
|
||||||
|
textDecoration: element.textDecoration,
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: "2px",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
// Fallback for system fonts that don't have classes
|
||||||
|
...(fontClassName === "" && { fontFamily: element.fontFamily }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{element.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media elements
|
||||||
|
if (element.type === "media") {
|
||||||
|
// Test elements
|
||||||
|
if (!mediaItem || element.mediaId === "test") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={element.id}
|
||||||
|
className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<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;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderBlurBackground()}
|
||||||
|
{activeElements.length === 0 ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
||||||
|
No elements at current time
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
activeElements.map((elementData, index) =>
|
||||||
|
renderElement(elementData, index)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{/* Show message when blur is selected but no media available */}
|
||||||
|
{activeProject?.backgroundType === "blur" &&
|
||||||
|
blurBackgroundElements.length === 0 &&
|
||||||
|
activeElements.length > 0 && (
|
||||||
|
<div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded">
|
||||||
|
Add a video or image to use blur background
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div 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 (
|
return (
|
||||||
<header
|
<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>}
|
{leftContent && <div className="flex items-center">{leftContent}</div>}
|
||||||
{centerContent && (
|
{centerContent && (
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { HeaderBase } from "./header-base";
|
import { HeaderBase } from "./header-base";
|
||||||
import { useSession } from "@opencut/auth/client";
|
import { useSession } from "@opencut/auth/client";
|
||||||
import { getStars } from "@/lib/fetchGhStars";
|
import { getStars } from "@/lib/fetch-github-stars";
|
||||||
import { Star } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
@ -29,26 +28,43 @@ export function Header() {
|
|||||||
|
|
||||||
const leftContent = (
|
const leftContent = (
|
||||||
<Link href="/" className="flex items-center gap-3">
|
<Link href="/" className="flex items-center gap-3">
|
||||||
<Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
|
<Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
|
||||||
<span className="font-medium tracking-tight">OpenCut</span>
|
<span className="text-xl font-medium hidden md:block">OpenCut</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
||||||
const rightContent = (
|
const rightContent = (
|
||||||
<nav className="flex items-center">
|
<nav className="flex items-center gap-3">
|
||||||
<Link href="/contributors">
|
<Link href="/contributors">
|
||||||
<Button variant="text" className="text-sm">
|
<Button variant="text" className="text-sm p-0">
|
||||||
Contributors
|
Contributors
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
|
{process.env.NODE_ENV === "development" ? (
|
||||||
<Button size="sm" className="text-sm ml-4">
|
<Link href="/projects">
|
||||||
GitHub
|
<Button size="sm" className="text-sm ml-4">
|
||||||
<ArrowRight className="h-4 w-4" />
|
Projects
|
||||||
</Button>
|
<ArrowRight className="h-4 w-4" />
|
||||||
</Link>
|
</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>
|
</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"
|
viewBox="0 -3.5 256 256"
|
||||||
preserveAspectRatio="xMinYMin meet"
|
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="M127.505 0C57.095 0 0 57.085 0 127.505c0 56.336 36.534 104.13 87.196 120.99 6.372 1.18 8.712-2.766 8.712-6.134 0-3.04-.119-13.085-.173-23.739-35.473 7.713-42.958-15.044-42.958-15.044-5.8-14.738-14.157-18.656-14.157-18.656-11.568-7.914.872-7.752.872-7.752 12.804.9 19.546 13.14 19.546 13.14 11.372 19.493 29.828 13.857 37.104 10.6 1.144-8.242 4.449-13.866 8.095-17.05-28.32-3.225-58.092-14.158-58.092-63.014 0-13.92 4.981-25.295 13.138-34.224-1.324-3.212-5.688-16.18 1.235-33.743 0 0 10.707-3.427 35.073 13.07 10.17-2.826 21.078-4.242 31.914-4.29 10.836.048 21.752 1.464 31.942 4.29 24.337-16.497 35.029-13.07 35.029-13.07 6.94 17.563 2.574 30.531 1.25 33.743 8.175 8.929 13.122 20.303 13.122 34.224 0 48.972-29.828 59.756-58.22 62.912 4.573 3.957 8.648 11.717 8.648 23.612 0 17.06-.148 30.791-.148 34.991 0 3.393 2.295 7.369 8.759 6.117 50.634-16.879 87.122-64.656 87.122-120.973C255.009 57.085 197.922 0 127.505 0" />
|
||||||
|
|
||||||
<path d="M47.755 181.634c-.28.633-1.278.823-2.185.389-.925-.416-1.445-1.28-1.145-1.916.275-.652 1.273-.834 2.196-.396.927.415 1.455 1.287 1.134 1.923M54.027 187.23c-.608.564-1.797.302-2.604-.589-.834-.889-.99-2.077-.373-2.65.627-.563 1.78-.3 2.616.59.834.899.996 2.08.36 2.65M58.33 194.39c-.782.543-2.06.034-2.849-1.1-.781-1.133-.781-2.493.017-3.038.792-.545 2.05-.055 2.85 1.07.78 1.153.78 2.513-.019 3.069M65.606 202.683c-.699.77-2.187.564-3.277-.488-1.114-1.028-1.425-2.487-.724-3.258.707-.772 2.204-.555 3.302.488 1.107 1.026 1.445 2.496.7 3.258M75.01 205.483c-.307.998-1.741 1.452-3.185 1.028-1.442-.437-2.386-1.607-2.095-2.616.3-1.005 1.74-1.478 3.195-1.024 1.44.435 2.386 1.596 2.086 2.612M85.714 206.67c.036 1.052-1.189 1.924-2.705 1.943-1.525.033-2.758-.818-2.774-1.852 0-1.062 1.197-1.926 2.721-1.951 1.516-.03 2.758.815 2.758 1.86M96.228 206.267c.182 1.026-.872 2.08-2.377 2.36-1.48.27-2.85-.363-3.039-1.38-.184-1.052.89-2.105 2.367-2.378 1.508-.262 2.857.355 3.049 1.398" />
|
<path d="M47.755 181.634c-.28.633-1.278.823-2.185.389-.925-.416-1.445-1.28-1.145-1.916.275-.652 1.273-.834 2.196-.396.927.415 1.455 1.287 1.134 1.923M54.027 187.23c-.608.564-1.797.302-2.604-.589-.834-.889-.99-2.077-.373-2.65.627-.563 1.78-.3 2.616.59.834.899.996 2.08.36 2.65M58.33 194.39c-.782.543-2.06.034-2.849-1.1-.781-1.133-.781-2.493.017-3.038.792-.545 2.05-.055 2.85 1.07.78 1.153.78 2.513-.019 3.069M65.606 202.683c-.699.77-2.187.564-3.277-.488-1.114-1.028-1.425-2.487-.724-3.258.707-.772 2.204-.555 3.302.488 1.107 1.026 1.445 2.496.7 3.258M75.01 205.483c-.307.998-1.741 1.452-3.185 1.028-1.442-.437-2.386-1.607-2.095-2.616.3-1.005 1.74-1.478 3.195-1.024 1.44.435 2.386 1.596 2.086 2.612M85.714 206.67c.036 1.052-1.189 1.924-2.705 1.943-1.525.033-2.758-.818-2.774-1.852 0-1.062 1.197-1.926 2.721-1.951 1.516-.03 2.758.815 2.758 1.86M96.228 206.267c.182 1.026-.872 2.08-2.377 2.36-1.48.27-2.85-.363-3.039-1.38-.184-1.052.89-2.105 2.367-2.378 1.508-.262 2.857.355 3.049 1.398" />
|
||||||
@ -37,3 +37,64 @@ export function GithubIcon({ className }: { className?: string }) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BackgroundIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="353"
|
||||||
|
height="353"
|
||||||
|
viewBox="0 0 353 353"
|
||||||
|
fill="none"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<g clipPath="url(#clip0_1_3)">
|
||||||
|
<rect
|
||||||
|
x="-241.816"
|
||||||
|
y="233.387"
|
||||||
|
width="592.187"
|
||||||
|
height="17.765"
|
||||||
|
transform="rotate(-37 -241.816 233.387)"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="-189.907"
|
||||||
|
y="306.804"
|
||||||
|
width="592.187"
|
||||||
|
height="17.765"
|
||||||
|
transform="rotate(-37 -189.907 306.804)"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="-146.928"
|
||||||
|
y="389.501"
|
||||||
|
width="592.187"
|
||||||
|
height="17.765"
|
||||||
|
transform="rotate(-37 -146.928 389.501)"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="-103.144"
|
||||||
|
y="477.904"
|
||||||
|
width="592.187"
|
||||||
|
height="17.765"
|
||||||
|
transform="rotate(-37 -103.144 477.904)"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="-57.169"
|
||||||
|
y="570.714"
|
||||||
|
width="592.187"
|
||||||
|
height="17.765"
|
||||||
|
transform="rotate(-37 -57.169 570.714)"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_1_3">
|
||||||
|
<rect width="353" height="353" fill="white" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
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>
|
||||||
|
);
|
||||||
|
};
|
@ -1,175 +1,157 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import Link from "next/link";
|
import { useState } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { getStars } from "@/lib/fetchGhStars";
|
import Image from "next/image";
|
||||||
|
import { Handlebars } from "./handlebars";
|
||||||
interface HeroProps {
|
|
||||||
signupCount: number;
|
interface HeroProps {
|
||||||
}
|
signupCount: number;
|
||||||
|
}
|
||||||
export function Hero({ signupCount }: HeroProps) {
|
|
||||||
const [star, setStar] = useState<string>();
|
export function Hero({ signupCount }: HeroProps) {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
const fetchStars = async () => {
|
e.preventDefault();
|
||||||
try {
|
|
||||||
const data = await getStars();
|
if (!email.trim()) {
|
||||||
setStar(data);
|
toast({
|
||||||
} catch (err) {
|
title: "Email required",
|
||||||
console.error("Failed to fetch GitHub stars", err);
|
description: "Please enter your email address.",
|
||||||
}
|
variant: "destructive",
|
||||||
};
|
});
|
||||||
|
return;
|
||||||
fetchStars();
|
}
|
||||||
}, []);
|
|
||||||
|
setIsSubmitting(true);
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
try {
|
||||||
|
const response = await fetch("/api/waitlist", {
|
||||||
if (!email.trim()) {
|
method: "POST",
|
||||||
toast({
|
headers: {
|
||||||
title: "Email required",
|
"Content-Type": "application/json",
|
||||||
description: "Please enter your email address.",
|
},
|
||||||
variant: "destructive",
|
body: JSON.stringify({ email: email.trim() }),
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
const data = (await response.json()) as { error: string };
|
||||||
|
|
||||||
setIsSubmitting(true);
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
try {
|
title: "Welcome to the waitlist! 🎉",
|
||||||
const response = await fetch("/api/waitlist", {
|
description: "You'll be notified when we launch.",
|
||||||
method: "POST",
|
});
|
||||||
headers: {
|
setEmail("");
|
||||||
"Content-Type": "application/json",
|
} else {
|
||||||
},
|
toast({
|
||||||
body: JSON.stringify({ email: email.trim() }),
|
title: "Oops!",
|
||||||
});
|
description:
|
||||||
|
(data as { error: string }).error ||
|
||||||
const data = await response.json();
|
"Something went wrong. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
if (response.ok) {
|
});
|
||||||
toast({
|
}
|
||||||
title: "Welcome to the waitlist! 🎉",
|
} catch (error) {
|
||||||
description: "You'll be notified when we launch.",
|
toast({
|
||||||
});
|
title: "Network error",
|
||||||
setEmail("");
|
description: "Please check your connection and try again.",
|
||||||
} else {
|
variant: "destructive",
|
||||||
toast({
|
});
|
||||||
title: "Oops!",
|
} finally {
|
||||||
description: data.error || "Something went wrong. Please try again.",
|
setIsSubmitting(false);
|
||||||
variant: "destructive",
|
}
|
||||||
});
|
};
|
||||||
}
|
|
||||||
} catch (error) {
|
return (
|
||||||
toast({
|
<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">
|
||||||
title: "Network error",
|
<Image
|
||||||
description: "Please check your connection and try again.",
|
className="absolute top-0 left-0 -z-50 size-full object-cover"
|
||||||
variant: "destructive",
|
src="/landing-page-bg.png"
|
||||||
});
|
height={1903.5}
|
||||||
} finally {
|
width={1269}
|
||||||
setIsSubmitting(false);
|
alt="landing-page.bg"
|
||||||
}
|
/>
|
||||||
};
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
return (
|
animate={{ opacity: 1 }}
|
||||||
<div className="min-h-[calc(100vh-4rem)] flex flex-col justify-between items-center text-center px-4">
|
transition={{ duration: 1 }}
|
||||||
<motion.div
|
className="max-w-3xl mx-auto w-full flex-1 flex flex-col justify-center"
|
||||||
initial={{ opacity: 0 }}
|
>
|
||||||
animate={{ opacity: 1 }}
|
<motion.div
|
||||||
transition={{ duration: 1 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
className="max-w-3xl mx-auto w-full flex-1 flex flex-col justify-center"
|
animate={{ opacity: 1, y: 0 }}
|
||||||
>
|
transition={{ delay: 0.2, duration: 0.8 }}
|
||||||
<motion.div
|
className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<h1>The Open Source</h1>
|
||||||
transition={{ delay: 0.2, duration: 0.8 }}
|
<Handlebars>Video Editor</Handlebars>
|
||||||
className="inline-block"
|
</motion.div>
|
||||||
>
|
|
||||||
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter">
|
<motion.p
|
||||||
The open source
|
className="mt-10 text-base sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
|
||||||
</h1>
|
initial={{ opacity: 0 }}
|
||||||
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter mt-2">
|
animate={{ opacity: 1 }}
|
||||||
video editor
|
transition={{ delay: 0.4, duration: 0.8 }}
|
||||||
</h1>
|
>
|
||||||
</motion.div>
|
A simple but powerful video editor that gets the job done. Works on
|
||||||
|
any platform.
|
||||||
<motion.p
|
</motion.p>
|
||||||
className="mt-10 text-lg sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
|
|
||||||
initial={{ opacity: 0 }}
|
<motion.div
|
||||||
animate={{ opacity: 1 }}
|
className="mt-12 flex gap-8 justify-center"
|
||||||
transition={{ delay: 0.4, duration: 0.8 }}
|
initial={{ opacity: 0 }}
|
||||||
>
|
animate={{ opacity: 1 }}
|
||||||
A simple but powerful video editor that gets the job done. Works on
|
transition={{ delay: 0.6, duration: 0.8 }}
|
||||||
any platform.
|
>
|
||||||
</motion.p>
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
<motion.div
|
className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"
|
||||||
className="mt-12 flex gap-8 justify-center"
|
>
|
||||||
initial={{ opacity: 0 }}
|
<div className="relative w-full">
|
||||||
animate={{ opacity: 1 }}
|
<Input
|
||||||
transition={{ delay: 0.6, duration: 0.8 }}
|
type="email"
|
||||||
>
|
placeholder="Enter your email"
|
||||||
<form onSubmit={handleSubmit} className="flex gap-3 w-full max-w-lg">
|
className="h-11 text-base flex-1"
|
||||||
<Input
|
value={email}
|
||||||
type="email"
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="Enter your email"
|
disabled={isSubmitting}
|
||||||
className="h-11 text-base flex-1"
|
required
|
||||||
value={email}
|
/>
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
</div>
|
||||||
disabled={isSubmitting}
|
<Button
|
||||||
required
|
type="submit"
|
||||||
/>
|
size="lg"
|
||||||
<Button
|
className="px-6 h-11 text-base !bg-foreground"
|
||||||
type="submit"
|
disabled={isSubmitting}
|
||||||
size="lg"
|
>
|
||||||
className="px-6 h-11 text-base"
|
<span className="relative z-10">
|
||||||
disabled={isSubmitting}
|
{isSubmitting ? "Joining..." : "Join waitlist"}
|
||||||
>
|
</span>
|
||||||
<span className="relative z-10">
|
<ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" />
|
||||||
{isSubmitting ? "Joining..." : "Join waitlist"}
|
</Button>
|
||||||
</span>
|
</form>
|
||||||
<ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" />
|
</motion.div>
|
||||||
</Button>
|
|
||||||
</form>
|
{signupCount > 0 && (
|
||||||
</motion.div>
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
{signupCount > 0 && (
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<motion.div
|
transition={{ delay: 0.8, duration: 0.6 }}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
className="mt-8 inline-flex items-center gap-2 text-sm text-muted-foreground justify-center"
|
||||||
animate={{ opacity: 1, y: 0 }}
|
>
|
||||||
transition={{ delay: 0.8, duration: 0.6 }}
|
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||||
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"
|
<span>{signupCount.toLocaleString()} people already joined</span>
|
||||||
>
|
</motion.div>
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
)}
|
||||||
<span>{signupCount.toLocaleString()} people already joined</span>
|
</motion.div>
|
||||||
</motion.div>
|
</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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
|
"bg-foreground text-background shadow hover:bg-foreground/90",
|
||||||
|
primary:
|
||||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
@ -22,7 +24,7 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2",
|
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",
|
lg: "h-10 rounded-md px-8",
|
||||||
icon: "h-7 w-7",
|
icon: "h-7 w-7",
|
||||||
},
|
},
|
||||||
|
@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl border bg-card text-card-foreground shadow",
|
"rounded-xl border bg-card text-card-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
|
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
|
||||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
@ -18,23 +19,40 @@ const ContextMenuSub = ContextMenuPrimitive.Sub;
|
|||||||
|
|
||||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const contextMenuItemVariants = cva(
|
||||||
|
"relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "focus:opacity-65 focus:text-accent-foreground",
|
||||||
|
destructive: "text-destructive focus:text-destructive/80",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const ContextMenuSubTrigger = React.forwardRef<
|
const ContextMenuSubTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
|
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||||
}
|
}
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
>(({ className, inset, children, variant = "default", ...props }, ref) => (
|
||||||
<ContextMenuPrimitive.SubTrigger
|
<ContextMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
inset && "pl-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
<ChevronRight className="ml-auto" />
|
||||||
</ContextMenuPrimitive.SubTrigger>
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
));
|
));
|
||||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||||
@ -62,7 +80,8 @@ const ContextMenuContent = React.forwardRef<
|
|||||||
<ContextMenuPrimitive.Content
|
<ContextMenuPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border 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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -75,12 +94,13 @@ const ContextMenuItem = React.forwardRef<
|
|||||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
|
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, variant = "default", ...props }, ref) => (
|
||||||
<ContextMenuPrimitive.Item
|
<ContextMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
inset && "pl-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@ -91,14 +111,13 @@ ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
|||||||
|
|
||||||
const ContextMenuCheckboxItem = React.forwardRef<
|
const ContextMenuCheckboxItem = React.forwardRef<
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {
|
||||||
>(({ className, children, checked, ...props }, ref) => (
|
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||||
|
}
|
||||||
|
>(({ className, children, checked, variant = "default", ...props }, ref) => (
|
||||||
<ContextMenuPrimitive.CheckboxItem
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)}
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
checked={checked}
|
checked={checked}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@ -115,19 +134,18 @@ ContextMenuCheckboxItem.displayName =
|
|||||||
|
|
||||||
const ContextMenuRadioItem = React.forwardRef<
|
const ContextMenuRadioItem = React.forwardRef<
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & {
|
||||||
>(({ className, children, ...props }, ref) => (
|
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||||
|
}
|
||||||
|
>(({ className, children, variant = "default", ...props }, ref) => (
|
||||||
<ContextMenuPrimitive.RadioItem
|
<ContextMenuPrimitive.RadioItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)}
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<ContextMenuPrimitive.ItemIndicator>
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
<Circle className="h-4 w-4 fill-current" />
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
</ContextMenuPrimitive.ItemIndicator>
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
@ -144,7 +162,7 @@ const ContextMenuLabel = React.forwardRef<
|
|||||||
<ContextMenuPrimitive.Label
|
<ContextMenuPrimitive.Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@ -159,7 +177,7 @@ const ContextMenuSeparator = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ContextMenuPrimitive.Separator
|
<ContextMenuPrimitive.Separator
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
className={cn("-mx-1 my-1 h-px bg-foreground/10", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@ -171,10 +189,7 @@ const ContextMenuShortcut = ({
|
|||||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...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 DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const dropdownMenuItemVariants = cva(
|
||||||
|
"relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "focus:opacity-65 focus:text-accent-foreground",
|
||||||
|
destructive: "text-destructive focus:text-destructive/80",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const DropdownMenuSubTrigger = React.forwardRef<
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
|
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||||
}
|
}
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
>(({ className, inset, children, variant = "default", ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
inset && "pl-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@ -65,8 +82,12 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.Content
|
<DropdownMenuPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
|
onCloseAutoFocus={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border 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",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@ -76,22 +97,6 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
));
|
));
|
||||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
const dropdownMenuItemVariants = cva(
|
|
||||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "focus:bg-accent focus:text-accent-foreground",
|
|
||||||
destructive:
|
|
||||||
"text-destructive focus:bg-destructive focus:text-destructive-foreground",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const DropdownMenuItem = React.forwardRef<
|
const DropdownMenuItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
@ -113,12 +118,15 @@ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
|||||||
|
|
||||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||||
>(({ className, children, checked, ...props }, ref) => (
|
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||||
|
}
|
||||||
|
>(({ className, children, checked, variant = "default", ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@ -137,12 +145,15 @@ DropdownMenuCheckboxItem.displayName =
|
|||||||
|
|
||||||
const DropdownMenuRadioItem = React.forwardRef<
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||||
>(({ className, children, ...props }, ref) => (
|
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||||
|
}
|
||||||
|
>(({ className, children, variant = "default", ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -181,7 +192,7 @@ const DropdownMenuSeparator = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
className={cn("-mx-1 my-1 h-px bg-foreground/10", className)}
|
||||||
{...props}
|
{...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 { useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { BackgroundType } from "@/types/editor";
|
||||||
|
|
||||||
interface ImageTimelineTreatmentProps {
|
interface ImageTimelineTreatmentProps {
|
||||||
src: string;
|
src: string;
|
||||||
alt: string;
|
alt: string;
|
||||||
targetAspectRatio?: number; // Default to 16:9 for video
|
targetAspectRatio?: number; // Default to 16:9 for video
|
||||||
className?: string;
|
className?: string;
|
||||||
backgroundType?: "blur" | "mirror" | "color";
|
backgroundType?: BackgroundType;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,14 +11,7 @@ interface InputProps extends React.ComponentProps<"input"> {
|
|||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
(
|
(
|
||||||
{
|
{ className, type, showPassword, onShowPasswordChange, value, ...props },
|
||||||
className,
|
|
||||||
type,
|
|
||||||
showPassword,
|
|
||||||
onShowPasswordChange,
|
|
||||||
value,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const isPassword = type === "password";
|
const isPassword = type === "password";
|
||||||
@ -26,7 +19,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
const inputType = isPassword && showPassword ? "text" : type;
|
const inputType = isPassword && showPassword ? "text" : type;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full">
|
<div className={showPassword ? "relative w-full" : ""}>
|
||||||
<input
|
<input
|
||||||
type={inputType}
|
type={inputType}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { GripVertical } from "lucide-react";
|
|
||||||
import * as ResizablePrimitive from "react-resizable-panels";
|
import * as ResizablePrimitive from "react-resizable-panels";
|
||||||
|
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
@ -29,17 +28,11 @@ const ResizableHandle = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<ResizablePrimitive.PanelResizeHandle
|
<ResizablePrimitive.PanelResizeHandle
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
/>
|
||||||
{withHandle && (
|
|
||||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
|
||||||
<GripVertical className="h-2.5 w-2.5" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ResizablePrimitive.PanelResizeHandle>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Select as SelectPrimitive } from "radix-ui";
|
import { Select as SelectPrimitive } from "radix-ui";
|
||||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
@ -12,6 +13,21 @@ const SelectGroup = SelectPrimitive.Group;
|
|||||||
|
|
||||||
const SelectValue = SelectPrimitive.Value;
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const selectItemVariants = cva(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "focus:opacity-65 focus:text-accent-foreground",
|
||||||
|
destructive: "text-destructive focus:text-destructive/80",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const SelectTrigger = React.forwardRef<
|
const SelectTrigger = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
@ -81,6 +97,10 @@ const SelectContent = React.forwardRef<
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
|
onCloseAutoFocus={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SelectScrollUpButton />
|
<SelectScrollUpButton />
|
||||||
@ -113,14 +133,13 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
|||||||
|
|
||||||
const SelectItem = React.forwardRef<
|
const SelectItem = React.forwardRef<
|
||||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||||
>(({ className, children, ...props }, ref) => (
|
variant?: VariantProps<typeof selectItemVariants>["variant"];
|
||||||
|
}
|
||||||
|
>(({ className, children, variant = "default", ...props }, ref) => (
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(selectItemVariants({ variant }), className)}
|
||||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
@ -139,7 +158,7 @@ const SelectSeparator = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.Separator
|
<SelectPrimitive.Separator
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
className={cn("-mx-1 my-1 h-px bg-foreground/10", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|