Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ba2893610 | ||
|
|
6399f1c902 | ||
|
|
e5c8df5555 | ||
|
|
11b270ffd3 | ||
|
|
1b0dd48f3d | ||
|
|
b10c8536fa | ||
|
|
bd05f64511 | ||
|
|
fe2762904c | ||
|
|
8c1ea7e680 | ||
|
|
ddf0353d92 | ||
|
|
5ef6e2bc97 | ||
|
|
7ea4b31626 | ||
|
|
e21c467489 | ||
|
|
3446fdde87 | ||
|
|
b360e8d985 | ||
|
|
b87a62372d | ||
|
|
fb51cc2597 | ||
|
|
ad771a0b56 | ||
|
|
5782df7350 | ||
|
|
a2a9bef1d8 | ||
|
|
1b3d42b2a9 | ||
|
|
935ccea85e | ||
|
|
11e2e1a49d | ||
|
|
aeb9cfc6b8 | ||
|
|
4c2d8c52ab | ||
|
|
e74e60d5bf | ||
|
|
15c57239f4 | ||
|
|
263f14ef2a | ||
|
|
4896dce268 | ||
|
|
07e068b658 | ||
|
|
efabec8bcb | ||
|
|
c7c394b2ef | ||
|
|
cac4fcaab8 | ||
|
|
ab12258432 | ||
|
|
ba3da43728 | ||
|
|
baaf773b80 | ||
|
|
d7c9a2391c | ||
|
|
afa8f426fb | ||
|
|
3c4d539a08 | ||
|
|
be1ae20d90 | ||
|
|
ede08dac41 | ||
|
|
fe020be6ce | ||
|
|
eab8ec3ba8 | ||
|
|
33d8caf65c | ||
|
|
24e13f692a | ||
|
|
ac962d023c | ||
|
|
5266aed5eb | ||
|
|
42ae8a7530 | ||
|
|
0d95f032cc | ||
|
|
a9b7043829 | ||
|
|
6ce8434615 | ||
|
|
d98638bfe6 | ||
|
|
0e1311bfe0 | ||
|
|
0b12fdfa0a | ||
|
|
d5bc63357b | ||
|
|
d440280f97 | ||
|
|
63e07dc9ff | ||
|
|
e3e26a207f | ||
|
|
ffd82087c7 | ||
|
|
53fe61f684 | ||
|
|
8b1c4de01b | ||
|
|
85b6123510 | ||
|
|
f8ccb673bf | ||
|
|
74e7d427aa | ||
|
|
d040ca53cb | ||
|
|
c4e6a9a45a | ||
|
|
b19db0bbea | ||
|
|
dc5416da07 | ||
|
|
23001f8442 | ||
|
|
f9bfc30959 | ||
|
|
c3caefc001 | ||
|
|
8e4661951d | ||
|
|
cdae10ca0a | ||
|
|
33531fb3bf | ||
|
|
4137747aba | ||
|
|
c2ef774525 | ||
|
|
e4748ecb09 | ||
|
|
d328e009d4 | ||
|
|
f3bffae7ea | ||
|
|
7eda7975fc | ||
|
|
d04aa8d1c1 | ||
|
|
1afc51eb9d | ||
|
|
01b11226a3 | ||
|
|
4b80c14982 | ||
|
|
c391eb8616 | ||
|
|
e7bad87217 | ||
|
|
f5d221feec | ||
|
|
c34b402297 | ||
|
|
480e83fa79 | ||
|
|
7f609cb86f | ||
|
|
f492ac9aad | ||
|
|
499f06cb52 | ||
|
|
e487ca254e | ||
|
|
f1b216848d | ||
|
|
b564f7c239 | ||
|
|
648f0b8529 | ||
|
|
0b919d4df8 | ||
|
|
56e6edcc5a | ||
|
|
0217303532 | ||
|
|
4170ee4f5b | ||
|
|
4e5e9756c8 | ||
|
|
b97a30824e | ||
|
|
e9ec13999a | ||
|
|
ce47e88e34 | ||
|
|
502725b98e | ||
|
|
103e4d5d3f | ||
|
|
bb9befc626 | ||
|
|
fe5b208a18 | ||
|
|
22bf42d397 | ||
|
|
fef9d9a3fb | ||
|
|
f3307a64d1 | ||
|
|
4d028487e6 | ||
|
|
f1fed51502 | ||
|
|
6691e74db9 | ||
|
|
f21b704323 | ||
|
|
faf5a121ac | ||
|
|
f94b1a86d6 | ||
|
|
b18a48b586 | ||
|
|
d768da9216 | ||
|
|
021c3fb7d0 | ||
|
|
e577c9659f | ||
|
|
b1efc90157 | ||
|
|
4dd1ea635b | ||
|
|
5a7a089f0e | ||
|
|
9bf5ce399c | ||
|
|
8efc696dcd | ||
|
|
9af8905c0c | ||
|
|
7d4cbca119 | ||
|
|
51857f1709 | ||
|
|
167f366c6d | ||
|
|
1505c1b12f | ||
|
|
98b2103286 | ||
|
|
9fa9f406fe | ||
|
|
0d2894feda | ||
|
|
36df8f2afe | ||
|
|
9f3ba9905d | ||
|
|
6ff4bcd093 | ||
|
|
a3c84e4a19 | ||
|
|
36516b874f | ||
|
|
387404661a | ||
|
|
7d1f99c20c | ||
|
|
651cfbfccb | ||
|
|
accad442f4 | ||
|
|
39ac2cbeb6 | ||
|
|
38035fe5d0 | ||
|
|
4851c38e74 | ||
|
|
2b618b189c | ||
|
|
10f5691d9e | ||
|
|
87dd275e57 | ||
|
|
0988a936ad | ||
|
|
6e3a9ec7ec | ||
|
|
04a1036770 | ||
|
|
caf6fef3ad | ||
|
|
da973b2e1e | ||
|
|
4e7b088647 | ||
|
|
5f58662ec7 | ||
|
|
e419eba29b | ||
|
|
218625b61a | ||
|
|
c1c4b114eb | ||
|
|
fcfe612cd6 | ||
|
|
e7344d9120 | ||
|
|
c3de532709 | ||
|
|
9d5707d5fd | ||
|
|
7cbbbe1630 | ||
|
|
3b93fd6b68 | ||
|
|
e0a5d4860a | ||
|
|
20f73bf705 | ||
|
|
0c086e3c24 | ||
|
|
f6e2d5a60f | ||
|
|
955f6331f2 | ||
|
|
5483db83de | ||
|
|
3371a68896 | ||
|
|
fbd14290c6 | ||
|
|
1389ce469a | ||
|
|
e317fb547d | ||
|
|
8432f517cc | ||
|
|
94f1c720bb | ||
|
|
453c422f94 | ||
|
|
700366f61e | ||
|
|
9530058b97 | ||
|
|
18d2186101 | ||
|
|
2974fd89a9 | ||
|
|
1e01364740 | ||
|
|
0554a031c2 | ||
|
|
f83f647299 | ||
|
|
fe1c0edb8f | ||
|
|
8bc26539eb | ||
|
|
5a76196221 | ||
|
|
527d588897 | ||
|
|
49d467cd83 | ||
|
|
e57272043f | ||
|
|
9ae7cf0691 | ||
|
|
025b0100d3 | ||
|
|
6281652239 | ||
|
|
5b10e84542 | ||
|
|
96f7516c94 | ||
|
|
a7e23ec714 | ||
|
|
63d5b2f110 | ||
|
|
446745a33c | ||
|
|
e96d97885b | ||
|
|
ccca2e8d0f | ||
|
|
2982293b54 | ||
|
|
0cb26ef088 | ||
|
|
2aa1eac7da | ||
|
|
27731a270d | ||
|
|
7c6b2e4f49 | ||
|
|
79f6c680f3 | ||
|
|
ece9c78b16 | ||
|
|
eb49500592 | ||
|
|
5178732c4c | ||
|
|
f76169ada1 | ||
|
|
acd1e2e216 | ||
|
|
d5c52557df | ||
|
|
83d65bdd15 | ||
|
|
6df492b661 | ||
|
|
13db27c173 | ||
|
|
d3204f6cd7 | ||
|
|
faf317cd20 | ||
|
|
7cc3a907de | ||
|
|
8bfc91e62e | ||
|
|
5fc06257f2 | ||
|
|
52f003f25a | ||
|
|
b266678fe4 | ||
|
|
3a8d57921e | ||
|
|
fd2aa00074 | ||
|
|
7a725ecd09 | ||
|
|
a32ffb601a | ||
|
|
dc0e4783fb | ||
|
|
fdeddbe3ed | ||
|
|
7732f0f158 | ||
|
|
1f95bb98d6 | ||
|
|
b1ccf3695e | ||
|
|
a8a5a68cae | ||
|
|
7d2f323f4d | ||
|
|
d16f2cc3c1 | ||
|
|
ebad05f17b | ||
|
|
ce851e52ce | ||
|
|
10b6cc98b8 | ||
|
|
2432bcf54b | ||
|
|
f5ed3e196a | ||
|
|
1fdf2a0515 | ||
|
|
5e86ed9efc | ||
|
|
cb877769fb | ||
|
|
0e83b6c15e | ||
|
|
2aa7493189 | ||
|
|
d7c764f968 | ||
|
|
bfa5d8f062 | ||
|
|
9d5fb821c7 | ||
|
|
0f05d25e8a | ||
|
|
e3cb491a29 | ||
|
|
2cc25b70d5 | ||
|
|
9cc77b4499 | ||
|
|
59f4098f75 | ||
|
|
1106aeeb60 | ||
|
|
af632d8cc5 | ||
|
|
e7485340cf | ||
|
|
2b57f3026c | ||
|
|
4de81796a3 | ||
|
|
55978c30fa | ||
|
|
7fb1181034 | ||
|
|
d53321ec36 | ||
|
|
7798334300 | ||
|
|
2c941b1585 | ||
|
|
c628b5a8ee | ||
|
|
1e64bc4abd | ||
|
|
0309f9cf08 | ||
|
|
ffb254c339 | ||
|
|
416511084a | ||
|
|
acd42a29f6 | ||
|
|
64b84ca041 | ||
|
|
87215b31e9 | ||
|
|
d8bb13eb78 | ||
|
|
b54c5443a0 | ||
|
|
89691312b8 | ||
|
|
73ba7d6577 | ||
|
|
238f8e85bb | ||
|
|
b0e9901730 | ||
|
|
3b4b7e2009 | ||
|
|
3037a7ecbf | ||
|
|
ad7ace3fd2 | ||
|
|
03b3c952c7 | ||
|
|
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 | ||
|
|
3e45be5c47 | ||
|
|
d9d54df431 | ||
|
|
5e74906e19 | ||
|
|
c5d96a0ded | ||
|
|
b5d04d591f | ||
|
|
e26c3bccdb | ||
|
|
74408541fd | ||
|
|
65afd3f18b |
@@ -4,20 +4,66 @@ Thank you for your interest in contributing to OpenCut! This document provides g
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en/) (v18 or later)
|
||||
- [Bun](https://bun.sh/docs/installation)
|
||||
(for `npm` alternative)
|
||||
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
|
||||
> **Note:** Docker is optional, but it's essential for running the local database and Redis services. If you're planning to contribute to frontend features, you can skip the Docker setup. If you have followed the steps below in [Setup](#setup), you're all set to go!
|
||||
|
||||
### Setup
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork locally
|
||||
3. Navigate to the web app directory: `cd apps/web`
|
||||
4. Install dependencies: `bun install`
|
||||
5. Start the development server: `bun run dev`
|
||||
4. 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
|
||||
```
|
||||
|
||||
5. Install dependencies: `bun install`
|
||||
6. 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**.
|
||||
|
||||
## What to Focus On
|
||||
|
||||
**🎯 Good Areas to Contribute:**
|
||||
|
||||
- Timeline functionality and UI improvements
|
||||
- Project management features
|
||||
- Performance optimizations
|
||||
- Bug fixes in existing functionality
|
||||
- UI/UX improvements
|
||||
- Documentation and testing
|
||||
|
||||
**⚠️ Areas to Avoid:**
|
||||
|
||||
- Preview panel enhancements (text fonts, stickers, effects)
|
||||
- Export functionality improvements
|
||||
- Preview rendering optimizations
|
||||
|
||||
**Why?** We're currently planning a major refactor of the preview system. The current preview renders DOM elements (HTML), but we're moving to a binary rendering approach similar to CapCut. This new system will ensure consistency between preview and export, and provide much better performance and quality.
|
||||
|
||||
The current HTML-based preview is essentially a prototype - the binary approach will be the "real deal." To avoid wasted effort, please focus on other areas of the application until this refactor is complete.
|
||||
|
||||
If you're unsure whether your idea falls into the preview category, feel free to ask us [directly in discord](https://discord.gg/zmR9N35cjK) or create a GitHub issue!
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- Bun (latest version)
|
||||
- Docker (for local database)
|
||||
|
||||
### Local Development
|
||||
|
||||
1. Start the database and Redis services:
|
||||
@@ -56,7 +102,7 @@ Thank you for your interest in contributing to OpenCut! This document provides g
|
||||
|
||||
# Generate a secure secret for Better Auth
|
||||
BETTER_AUTH_SECRET="your-generated-secret-here"
|
||||
BETTER_AUTH_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000"
|
||||
|
||||
# Redis (matches docker-compose.yaml)
|
||||
UPSTASH_REDIS_REST_URL="http://localhost:8079"
|
||||
@@ -81,14 +127,6 @@ Thank you for your interest in contributing to OpenCut! This document provides g
|
||||
# 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`
|
||||
6. Start the development server: `bun run dev`
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ Add screenshots to help explain your changes.
|
||||
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have added screenshots if ui has been changed
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
|
||||
@@ -23,6 +23,10 @@ jobs:
|
||||
|
||||
env:
|
||||
DATABASE_URL: "postgresql://opencut:opencutthegoat@localhost:5432/opencut"
|
||||
BETTER_AUTH_SECRET: "supersecret"
|
||||
NEXT_PUBLIC_BETTER_AUTH_URL: "http://localhost:3000"
|
||||
UPSTASH_REDIS_REST_URL: "https://your-upstash-redis-url"
|
||||
UPSTASH_REDIS_REST_TOKEN: "your-upstash-redis-token"
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -31,13 +35,13 @@ jobs:
|
||||
- name: Install Bun
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
|
||||
with:
|
||||
bun-version: 1.2.17
|
||||
bun-version: 1.2.18
|
||||
|
||||
- name: Cache Bun modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-1.2.17-${{ hashFiles('apps/web/bun.lock') }}
|
||||
key: ${{ runner.os }}-bun-1.2.18-${{ hashFiles('apps/web/bun.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/web
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
|
||||
# debug
|
||||
/apps/web/npm-debug.log*
|
||||
/apps/web/yarn-debug.log*
|
||||
/apps/web/yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
/apps/web/.env*
|
||||
@@ -18,7 +16,6 @@
|
||||
|
||||
# typescript
|
||||
/apps/web/next-env.d.ts
|
||||
/apps/web/yarn.lock
|
||||
|
||||
# asdf version management
|
||||
.tool-versions
|
||||
@@ -33,3 +30,6 @@ node_modules
|
||||
|
||||
.cursor/
|
||||
bun.lockb
|
||||
|
||||
# mac
|
||||
.DS_Store
|
||||
@@ -23,6 +23,7 @@
|
||||
- Real-time preview
|
||||
- No watermarks or subscriptions
|
||||
- Analytics provided by [Databuddy](https://www.databuddy.cc?utm_source=opencut), 100% Anonymized & Non-invasive.
|
||||
- Blog powered by [Marble](https://marblecms.com?utm_source=opencut), Headless CMS.
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -39,86 +40,117 @@
|
||||
|
||||
Before you begin, ensure you have the following installed on your system:
|
||||
|
||||
- [Node.js](https://nodejs.org/en/) (v18 or later)
|
||||
- [Bun](https://bun.sh/docs/installation)
|
||||
(for `npm` alternative)
|
||||
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
|
||||
- [Node.js](https://nodejs.org/en/) (for `npm` alternative)
|
||||
|
||||
> **Note:** Docker is optional, but it's essential for running the local database and Redis services. If you're planning to run the frontend or want to contribute to frontend features, you can skip the Docker setup. If you have followed the steps below in [Setup](#setup), you're all set to go!
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Clone the repository**
|
||||
1. Fork the repository
|
||||
2. Clone your fork locally
|
||||
3. Navigate to the web app directory: `cd apps/web`
|
||||
4. Copy `.env.example` to `.env.local`:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/OpenCut-app/OpenCut.git
|
||||
cd OpenCut
|
||||
```
|
||||
```bash
|
||||
# Unix/Linux/Mac
|
||||
cp .env.example .env.local
|
||||
|
||||
2. **Start backend services**
|
||||
From the project root, start the PostgreSQL and Redis services:
|
||||
# Windows Command Prompt
|
||||
copy .env.example .env.local
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
# Windows PowerShell
|
||||
Copy-Item .env.example .env.local
|
||||
```
|
||||
|
||||
3. **Set up environment variables**
|
||||
Navigate into the web app's directory and create a `.env` file from the example:
|
||||
5. Install dependencies: `bun install`
|
||||
6. Start the development server: `bun dev`
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
## Development Setup
|
||||
|
||||
### Local Development
|
||||
|
||||
# Unix/Linux/Mac
|
||||
cp .env.example .env.local
|
||||
1. Start the database and Redis services:
|
||||
|
||||
# Windows Command Prompt
|
||||
copy .env.example .env.local
|
||||
```bash
|
||||
# From project root
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
# Windows PowerShell
|
||||
Copy-Item .env.example .env.local
|
||||
```
|
||||
2. Navigate to the web app directory:
|
||||
|
||||
_The default values in the `.env` file should work for local development._
|
||||
```bash
|
||||
cd apps/web
|
||||
```
|
||||
|
||||
4. **Install dependencies**
|
||||
Install the project dependencies using `bun` (recommended) or `npm`.
|
||||
3. Copy `.env.example` to `.env.local`:
|
||||
|
||||
```bash
|
||||
# With bun
|
||||
bun install
|
||||
```bash
|
||||
# Unix/Linux/Mac
|
||||
cp .env.example .env.local
|
||||
|
||||
# Or with npm
|
||||
npm install
|
||||
```
|
||||
# Windows Command Prompt
|
||||
copy .env.example .env.local
|
||||
|
||||
5. **Run database migrations**
|
||||
Apply the database schema to your local database:
|
||||
# Windows PowerShell
|
||||
Copy-Item .env.example .env.local
|
||||
```
|
||||
|
||||
```bash
|
||||
# With bun
|
||||
bun run db:push:local
|
||||
4. Configure required environment variables in `.env.local`:
|
||||
|
||||
# Or with npm
|
||||
npm run db:push:local
|
||||
```
|
||||
**Required Variables:**
|
||||
|
||||
6. **Start the development server**
|
||||
```bash
|
||||
# Database (matches docker-compose.yaml)
|
||||
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
|
||||
|
||||
```bash
|
||||
# With bun
|
||||
bun run dev
|
||||
# Generate a secure secret for Better Auth
|
||||
BETTER_AUTH_SECRET="your-generated-secret-here"
|
||||
BETTER_AUTH_URL="http://localhost:3000"
|
||||
|
||||
# Or with npm
|
||||
npm run dev
|
||||
```
|
||||
# Redis (matches docker-compose.yaml)
|
||||
UPSTASH_REDIS_REST_URL="http://localhost:8079"
|
||||
UPSTASH_REDIS_REST_TOKEN="example_token"
|
||||
|
||||
# Marble Blog
|
||||
MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key
|
||||
NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
5. Run database migrations: `bun run db:migrate` from (inside apps/web)
|
||||
6. Start the development server: `bun run dev` from (inside apps/web)
|
||||
|
||||
The application will be available at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
## Contributing
|
||||
|
||||
**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.
|
||||
We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
|
||||
|
||||
## Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
|
||||
**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel.
|
||||
|
||||
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
|
||||
**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach.
|
||||
|
||||
See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance.
|
||||
|
||||
**Quick start for contributors:**
|
||||
|
||||
@@ -130,6 +162,10 @@ We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTI
|
||||
|
||||
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
|
||||
|
||||
<a href="https://vercel.com/oss">
|
||||
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
|
||||
</a>
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FOpenCut-app%2FOpenCut&project-name=opencut&repository-name=opencut)
|
||||
|
||||
## License
|
||||
@@ -138,5 +174,4 @@ Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss
|
||||
|
||||
---
|
||||
|
||||
[]
|
||||
|
||||

|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
|
||||
|
||||
# Better Auth
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000
|
||||
BETTER_AUTH_SECRET=your-secret-key-here
|
||||
|
||||
#Google Clients
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Development Environment
|
||||
NODE_ENV=development
|
||||
|
||||
# Redis
|
||||
UPSTASH_REDIS_REST_URL=http://localhost:8079
|
||||
UPSTASH_REDIS_REST_TOKEN=example_token
|
||||
|
||||
# Marble Blog
|
||||
MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key
|
||||
NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
|
||||
@@ -21,6 +21,12 @@ COPY packages/auth/ packages/auth/
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
# Set build-time environment variables for validation
|
||||
ENV DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
|
||||
ENV BETTER_AUTH_SECRET="build-time-secret"
|
||||
ENV UPSTASH_REDIS_REST_URL="http://localhost:8079"
|
||||
ENV UPSTASH_REDIS_REST_TOKEN="example_token"
|
||||
ENV NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000"
|
||||
|
||||
WORKDIR /app/apps/web
|
||||
RUN bun run build
|
||||
|
||||
@@ -8,15 +8,11 @@ if (process.env.NODE_ENV === "production") {
|
||||
dotenv.config({ path: ".env.local" });
|
||||
}
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL is not set");
|
||||
}
|
||||
|
||||
export default {
|
||||
schema: "../../packages/db/src/schema.ts",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL,
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
out: "./migrations",
|
||||
strict: process.env.NODE_ENV === "production",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ALTER COLUMN "email_verified" SET DEFAULT false;
|
||||
@@ -0,0 +1,359 @@
|
||||
{
|
||||
"id": "1b9148ed-497b-4e53-8cf6-f556c8fe7f7f",
|
||||
"prevId": "b7d920ca-6dd0-430f-8ee6-1d38fdf3e80f",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.accounts": {
|
||||
"name": "accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"accounts_user_id_users_id_fk": {
|
||||
"name": "accounts_user_id_users_id_fk",
|
||||
"tableFrom": "accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"sessions_token_unique": {
|
||||
"name": "sessions_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.verifications": {
|
||||
"name": "verifications",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.waitlist": {
|
||||
"name": "waitlist",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"waitlist_email_unique": {
|
||||
"name": "waitlist_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@
|
||||
"when": 1750689835736,
|
||||
"tag": "0001_tricky_jackpot",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1752569400809,
|
||||
"tag": "0002_cuddly_pretty_boy",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
import { withBotId } from "botid/next/config";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
compiler: {
|
||||
@@ -17,8 +18,20 @@ const nextConfig: NextConfig = {
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.marblecms.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "lh3.googleusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "avatars.githubusercontent.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default withBotId(nextConfig);
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
"name": "opencut",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1.2.17",
|
||||
"packageManager": "bun@1.2.18",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
@@ -21,10 +21,14 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@opencut/auth": "workspace:*",
|
||||
"@opencut/db": "workspace:*",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@t3-oss/env-core": "^0.13.8",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"@upstash/ratelimit": "^2.0.5",
|
||||
"@upstash/redis": "^1.35.0",
|
||||
"@vercel/analytics": "^1.4.1",
|
||||
"better-auth": "^1.2.7",
|
||||
"botid": "^1.4.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
@@ -36,7 +40,7 @@
|
||||
"input-otp": "^1.4.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"motion": "^12.18.1",
|
||||
"next": "^15.3.4",
|
||||
"next": "^15.4.1",
|
||||
"next-themes": "^0.4.4",
|
||||
"pg": "^8.16.2",
|
||||
"radix-ui": "^1.4.2",
|
||||
@@ -45,27 +49,35 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.54.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-phone-number-input": "^3.4.11",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "^2.14.1",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-parse": "^9.0.1",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"sonner": "^1.7.1",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unified": "^11.0.5",
|
||||
"vaul": "^1.1.1",
|
||||
"zod": "^3.25.67",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.15.4",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/bun": "latest",
|
||||
"@types/pg": "^8.15.4",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"cross-env": "^7.0.3",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 467 B |
|
Before Width: | Height: | Size: 741 B After Width: | Height: | Size: 564 B |
|
Before Width: | Height: | Size: 768 B After Width: | Height: | Size: 222 B |
|
Before Width: | Height: | Size: 802 B After Width: | Height: | Size: 253 B |
|
Before Width: | Height: | Size: 826 B After Width: | Height: | Size: 274 B |
|
Before Width: | Height: | Size: 906 B After Width: | Height: | Size: 334 B |
|
Before Width: | Height: | Size: 985 B After Width: | Height: | Size: 399 B |
|
Before Width: | Height: | Size: 998 B After Width: | Height: | Size: 419 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 467 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 546 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 724 B |
|
Before Width: | Height: | Size: 809 B After Width: | Height: | Size: 257 B |
|
Before Width: | Height: | Size: 843 B After Width: | Height: | Size: 287 B |
|
Before Width: | Height: | Size: 826 B After Width: | Height: | Size: 274 B |
|
Before Width: | Height: | Size: 820 B After Width: | Height: | Size: 263 B |
|
Before Width: | Height: | Size: 670 B After Width: | Height: | Size: 131 B |
|
Before Width: | Height: | Size: 747 B After Width: | Height: | Size: 206 B |
|
Before Width: | Height: | Size: 906 B After Width: | Height: | Size: 334 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 467 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 539 B |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 814 B After Width: | Height: | Size: 259 B |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1021 B |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 56 KiB |
@@ -119,7 +119,11 @@ const LoginPage = () => {
|
||||
className="w-full h-11"
|
||||
size="lg"
|
||||
>
|
||||
{isEmailLoading ? <Loader2 className="animate-spin" /> : "Sign in"}
|
||||
{isEmailLoading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,6 +141,6 @@ const LoginPage = () => {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default memo(LoginPage);
|
||||
|
||||
@@ -157,6 +157,6 @@ const SignUpPage = () => {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default memo(SignUpPage);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { auth } from "@opencut/auth/server";
|
||||
import { auth } from "@opencut/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth);
|
||||
@@ -1,54 +1,76 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, eq } from "@opencut/db";
|
||||
import { waitlist } from "@opencut/db/schema";
|
||||
import { db, eq, waitlist } from "@opencut/db";
|
||||
import { checkBotId } from "botid/server";
|
||||
import { nanoid } from "nanoid";
|
||||
import { waitlistRateLimit } from "@/lib/rate-limit";
|
||||
import { z } from "zod";
|
||||
import { env } from "@/env";
|
||||
import { cookies } from "next/headers";
|
||||
import crypto from "crypto";
|
||||
|
||||
const waitlistSchema = z.object({
|
||||
email: z.string().email("Invalid email format").min(1, "Email is required"),
|
||||
});
|
||||
|
||||
const CSRF_TOKEN_NAME = "waitlist-csrf";
|
||||
const TOKEN_EXPIRY = 60 * 60 * 1000;
|
||||
|
||||
async function validateCSRFToken(request: NextRequest): Promise<boolean> {
|
||||
const clientToken = request.headers.get("x-csrf-token");
|
||||
if (!clientToken) return false;
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const cookieValue = cookieStore.get(CSRF_TOKEN_NAME)?.value;
|
||||
if (!cookieValue) return false;
|
||||
|
||||
const [token, timestamp, signature] = cookieValue.split(":");
|
||||
if (!token || !timestamp || !signature) return false;
|
||||
|
||||
if (clientToken !== token) return false;
|
||||
|
||||
const now = Date.now();
|
||||
const tokenTime = parseInt(timestamp);
|
||||
if (now - tokenTime > TOKEN_EXPIRY) return false;
|
||||
|
||||
const expectedSignature = crypto.createHmac("sha256", env.BETTER_AUTH_SECRET).update(`${token}:${timestamp}`).digest("hex");
|
||||
|
||||
return signature === expectedSignature;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Rate limit check
|
||||
const verification = await checkBotId();
|
||||
|
||||
if (verification.isBot) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const identifier = request.headers.get("x-forwarded-for") ?? "127.0.0.1";
|
||||
const { success } = await waitlistRateLimit.limit(identifier);
|
||||
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests. Please try again later." },
|
||||
{ status: 429 }
|
||||
);
|
||||
return NextResponse.json({ error: "Too many requests. Please try again later." }, { status: 429 });
|
||||
}
|
||||
const isValidToken = await validateCSRFToken(request);
|
||||
if (!isValidToken) {
|
||||
return NextResponse.json({ error: "Invalid security token" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email } = waitlistSchema.parse(body);
|
||||
|
||||
// Check if email already exists
|
||||
const existingEmail = await db
|
||||
.select()
|
||||
.from(waitlist)
|
||||
.where(eq(waitlist.email, email.toLowerCase()))
|
||||
.limit(1);
|
||||
const existingEmail = await db.select().from(waitlist).where(eq(waitlist.email, email.toLowerCase())).limit(1);
|
||||
|
||||
if (existingEmail.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email already registered" },
|
||||
{ status: 409 }
|
||||
);
|
||||
return NextResponse.json({ error: "Email already registered" }, { status: 409 });
|
||||
}
|
||||
|
||||
// Add to waitlist
|
||||
await db.insert(waitlist).values({
|
||||
id: nanoid(),
|
||||
email: email.toLowerCase(),
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Successfully joined waitlist!" },
|
||||
{ status: 201 }
|
||||
);
|
||||
return NextResponse.json({ message: "Successfully joined waitlist!" }, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const firstError = error.errors[0];
|
||||
@@ -56,9 +78,6 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
console.error("Waitlist signup error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import crypto from "crypto";
|
||||
import { env } from "@/env";
|
||||
|
||||
const CSRF_TOKEN_NAME = "waitlist-csrf";
|
||||
const TOKEN_EXPIRY = 60 * 60 * 1000;
|
||||
const allowedHosts = env.NODE_ENV === "development" ? ["localhost:3000", "127.0.0.1:3000"] : ["opencut.app", "www.opencut.app"];
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const referer = request.headers.get("referer");
|
||||
const host = request.headers.get("host");
|
||||
|
||||
if (referer) {
|
||||
const refererUrl = new URL(referer);
|
||||
|
||||
if (!allowedHosts.some((allowed) => refererUrl.host === allowed || refererUrl.host.endsWith(allowed))) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
} else if (host) {
|
||||
if (!allowedHosts.some((allowed) => host === allowed || host.endsWith(allowed))) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!env.BETTER_AUTH_SECRET) {
|
||||
throw new Error("BETTER_AUTH_SECRET must be configured");
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const timestamp = Date.now();
|
||||
const signature = crypto.createHmac("sha256", env.BETTER_AUTH_SECRET).update(`${token}:${timestamp}`).digest("hex");
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(CSRF_TOKEN_NAME, `${token}:${timestamp}:${signature}`, {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === "production",
|
||||
sameSite: "strict",
|
||||
maxAge: TOKEN_EXPIRY / 1000,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return NextResponse.json({ token });
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Header } from "@/components/header";
|
||||
import Prose from "@/components/ui/prose";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog-query";
|
||||
import { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: PageProps): Promise<Metadata> {
|
||||
const slug = (await params).slug;
|
||||
|
||||
const data = await getSinglePost(slug);
|
||||
|
||||
if (!data || !data.post) return {};
|
||||
|
||||
return {
|
||||
title: data.post.title,
|
||||
description: data.post.description,
|
||||
twitter: {
|
||||
title: `${data.post.title}`,
|
||||
description: `${data.post.description}`,
|
||||
card: "summary_large_image",
|
||||
images: [
|
||||
{
|
||||
url: data.post.coverImage,
|
||||
width: "1200",
|
||||
height: "630",
|
||||
alt: data.post.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
openGraph: {
|
||||
type: "article",
|
||||
images: [
|
||||
{
|
||||
url: data.post.coverImage,
|
||||
width: "1200",
|
||||
height: "630",
|
||||
alt: data.post.title,
|
||||
},
|
||||
],
|
||||
title: data.post.title,
|
||||
description: data.post.description,
|
||||
publishedTime: new Date(data.post.publishedAt).toISOString(),
|
||||
authors: [
|
||||
...data.post.authors.map((author: { name: string }) => author.name),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const data = await getPosts();
|
||||
if (!data || !data.posts.length) return [];
|
||||
|
||||
return data.posts.map((post) => ({
|
||||
slug: post.slug,
|
||||
}));
|
||||
}
|
||||
|
||||
async function Page({ params }: PageProps) {
|
||||
const slug = (await params).slug;
|
||||
const data = await getSinglePost(slug);
|
||||
if (!data || !data.post) return notFound();
|
||||
|
||||
const html = await processHtmlContent(data.post.content);
|
||||
|
||||
const formattedDate = new Date(data.post.publishedAt).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
|
||||
<main className="relative">
|
||||
<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 max-w-3xl mx-auto px-4 py-16">
|
||||
<div className="text-center mb-6">
|
||||
{data.post.coverImage && (
|
||||
<div className="relative aspect-video rounded-lg overflow-hidden mb-6">
|
||||
<Image
|
||||
src={data.post.coverImage}
|
||||
alt={data.post.title}
|
||||
loading="eager"
|
||||
fill
|
||||
className="object-cover rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<time dateTime={data.post.publishedAt.toString()}>
|
||||
{formattedDate}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-4xl font-bold tracking-tight mb-6">
|
||||
{data.post.title}
|
||||
</h1>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{data.post.authors[0] && (
|
||||
<>
|
||||
<Image
|
||||
src={data.post.authors[0].image}
|
||||
alt={data.post.authors[0].name}
|
||||
width={36}
|
||||
height={36}
|
||||
loading="eager"
|
||||
className="aspect-square shrink-0 size-8 rounded-full"
|
||||
/>
|
||||
<p className="text-muted-foreground">
|
||||
{data.post.authors[0].name}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<section className="mt-14">
|
||||
<Prose html={html} />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Metadata } from "next";
|
||||
import { Header } from "@/components/header";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import Link from "next/link";
|
||||
import { getPosts } from "@/lib/blog-query";
|
||||
import Image from "next/image";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog - OpenCut",
|
||||
description:
|
||||
"Read the latest news and updates about OpenCut, the free and open-source video editor.",
|
||||
openGraph: {
|
||||
title: "Blog - OpenCut",
|
||||
description:
|
||||
"Read the latest news and updates about OpenCut, the free and open-source video editor.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default async function BlogPage() {
|
||||
const data = await getPosts();
|
||||
if (!data || !data.posts) return <div>No posts yet</div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
|
||||
<main className="relative">
|
||||
<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 max-w-3xl mx-auto px-4 py-16">
|
||||
<div className="text-center mb-20">
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
||||
Blog
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
|
||||
Read the latest news and updates about OpenCut, the free and
|
||||
open-source video editor.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{data.posts.map((post) => (
|
||||
<Link key={post.id} href={`/blog/${post.slug}`}>
|
||||
<Card className="h-full hover:shadow-lg transition-shadow overflow-hidden">
|
||||
{post.coverImage && (
|
||||
<div className="relative aspect-video">
|
||||
<Image
|
||||
src={post.coverImage}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardContent className="p-6">
|
||||
{post.authors && post.authors.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{post.authors.map((author, index) => (
|
||||
<div
|
||||
key={author.id}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Avatar className="w-6 h-6">
|
||||
<AvatarImage
|
||||
src={author.image}
|
||||
alt={author.name}
|
||||
/>
|
||||
<AvatarFallback className="text-xs">
|
||||
{author.name.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{author.name}
|
||||
</span>
|
||||
{index < post.authors.length - 1 && (
|
||||
<span className="text-muted-foreground">•</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-xl font-semibold mb-2">{post.title}</h2>
|
||||
<p className="text-muted-foreground">{post.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ interface Contributor {
|
||||
async function getContributors(): Promise<Contributor[]> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/OpenCut-app/OpenCut/contributors",
|
||||
"https://api.github.com/repos/OpenCut-app/OpenCut/contributors?per_page=100",
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
@@ -16,6 +16,7 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { EditorProvider } from "@/components/editor-provider";
|
||||
import { usePlaybackControls } from "@/hooks/use-playback-controls";
|
||||
import { Onboarding } from "@/components/onboarding";
|
||||
|
||||
export default function Editor() {
|
||||
const {
|
||||
@@ -33,25 +34,37 @@ export default function Editor() {
|
||||
|
||||
const { activeProject, loadProject, createNewProject } = useProjectStore();
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const projectId = params.project_id as string;
|
||||
const handledProjectIds = useRef<Set<string>>(new Set());
|
||||
|
||||
usePlaybackControls();
|
||||
|
||||
useEffect(() => {
|
||||
const initializeProject = async () => {
|
||||
if (projectId && (!activeProject || activeProject.id !== projectId)) {
|
||||
try {
|
||||
await loadProject(projectId);
|
||||
} catch (error) {
|
||||
console.error("Failed to load project:", error);
|
||||
// If project doesn't exist, create a new one
|
||||
await createNewProject("Untitled Project");
|
||||
}
|
||||
const initProject = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
if (activeProject?.id === projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handledProjectIds.current.has(projectId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await loadProject(projectId);
|
||||
} catch (error) {
|
||||
handledProjectIds.current.add(projectId);
|
||||
|
||||
const newProjectId = await createNewProject("Untitled Project");
|
||||
router.replace(`/editor/${newProjectId}`);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
initializeProject();
|
||||
}, [projectId, activeProject, loadProject, createNewProject]);
|
||||
initProject();
|
||||
}, [projectId, activeProject?.id, loadProject, createNewProject, router]);
|
||||
|
||||
return (
|
||||
<EditorProvider>
|
||||
@@ -60,7 +73,7 @@ export default function Editor() {
|
||||
<div className="flex-1 min-h-0 min-w-0">
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="h-full w-full gap-1"
|
||||
className="h-full w-full gap-[0.18rem]"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={mainContent}
|
||||
@@ -72,7 +85,7 @@ export default function Editor() {
|
||||
{/* Main content area */}
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full w-full gap-1 px-2"
|
||||
className="h-full w-full gap-[0.19rem] px-2"
|
||||
>
|
||||
{/* Tools Panel */}
|
||||
<ResizablePanel
|
||||
@@ -125,6 +138,7 @@ export default function Editor() {
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
<Onboarding />
|
||||
</div>
|
||||
</EditorProvider>
|
||||
);
|
||||
|
||||
@@ -82,5 +82,7 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Prevent back/forward swipe */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,21 @@ import Script from "next/script";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { TooltipProvider } from "../components/ui/tooltip";
|
||||
import { DevelopmentDebug } from "../components/development-debug";
|
||||
import { StorageProvider } from "../components/storage-provider";
|
||||
import { baseMetaData } from "./metadata";
|
||||
import { defaultFont } from "../lib/font-config";
|
||||
import { BotIdClient } from "botid/client";
|
||||
import { env } from "@/env";
|
||||
|
||||
export const metadata = baseMetaData;
|
||||
|
||||
const protectedRoutes = [
|
||||
{
|
||||
path: "/api/waitlist",
|
||||
method: "POST",
|
||||
},
|
||||
];
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@@ -18,22 +26,26 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<BotIdClient protect={protectedRoutes} />
|
||||
</head>
|
||||
<body className={`${defaultFont.className} font-sans antialiased`}>
|
||||
<ThemeProvider attribute="class" forcedTheme="dark" enableSystem>
|
||||
<ThemeProvider attribute="class" forcedTheme="dark">
|
||||
<TooltipProvider>
|
||||
<StorageProvider>{children}</StorageProvider>
|
||||
<Analytics />
|
||||
<Toaster />
|
||||
<DevelopmentDebug />
|
||||
<Script
|
||||
src="https://cdn.databuddy.cc/databuddy.js"
|
||||
strategy="afterInteractive"
|
||||
async
|
||||
data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
|
||||
data-track-attributes={true}
|
||||
data-disabled={env.NODE_ENV === "development"}
|
||||
data-track-attributes={false}
|
||||
data-track-errors={true}
|
||||
data-track-outgoing-links={true}
|
||||
data-track-web-vitals={true}
|
||||
data-track-outgoing-links={false}
|
||||
data-track-web-vitals={false}
|
||||
data-track-sessions={false}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -1,66 +1,91 @@
|
||||
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";
|
||||
const description =
|
||||
"A simple but powerful video editor that gets the job done. In your browser.";
|
||||
const openGraphImageUrl = "https://opencut.app/open-graph/default.jpg";
|
||||
const twitterImageUrl = "/open-graph/default.jpg";
|
||||
|
||||
export const baseMetaData: Metadata = {
|
||||
metadataBase: new URL("https://opencut.app"),
|
||||
title: title,
|
||||
description: description,
|
||||
openGraph: {
|
||||
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"
|
||||
}
|
||||
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],
|
||||
},
|
||||
pinterest: {
|
||||
richPin: false,
|
||||
},
|
||||
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,18 +1,12 @@
|
||||
import { Hero } from "@/components/landing/hero";
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { getWaitlistCount } from "@/lib/waitlist";
|
||||
|
||||
// Force dynamic rendering so waitlist count updates in real-time
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Home() {
|
||||
const signupCount = await getWaitlistCount();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<Hero signupCount={signupCount} />
|
||||
<Hero />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { Metadata } from "next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { GithubIcon } from "@/components/icons";
|
||||
import Link from "next/link";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { Header } from "@/components/header";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy - OpenCut",
|
||||
description:
|
||||
"Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.",
|
||||
openGraph: {
|
||||
title: "Privacy Policy - OpenCut",
|
||||
description:
|
||||
"Learn how OpenCut handles your data and privacy. Our commitment to protecting your information while you edit videos.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="relative">
|
||||
<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-4xl mx-auto">
|
||||
<div className="text-center mb-10">
|
||||
<Link
|
||||
href="https://github.com/OpenCut-app/OpenCut"
|
||||
target="_blank"
|
||||
>
|
||||
<Badge variant="secondary" className="gap-2 mb-6">
|
||||
<GithubIcon className="h-3 w-3" />
|
||||
Open Source
|
||||
</Badge>
|
||||
</Link>
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
||||
Privacy Policy
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
|
||||
Learn how we handle your data and privacy. Contact us if you
|
||||
have any questions.
|
||||
</p>
|
||||
</div>
|
||||
<Card className="bg-background/80 backdrop-blur-sm border-2 border-muted/30">
|
||||
<CardContent className="p-8 text-base leading-relaxed space-y-8">
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
Your Videos Stay Private
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
<strong>
|
||||
OpenCut processes all videos locally on your device.
|
||||
</strong>{" "}
|
||||
We never upload, store, or have access to your video files.
|
||||
Your content remains completely private and under your
|
||||
control at all times.
|
||||
</p>
|
||||
<p>
|
||||
All video editing, rendering, and processing happens in your
|
||||
browser using WebAssembly and local storage. No video data
|
||||
is transmitted to our servers.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
Account Information
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
When you create an account, we only collect:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 space-y-2">
|
||||
<li>Email address (for account access)</li>
|
||||
<li>
|
||||
Profile information from Google OAuth (if you choose to
|
||||
sign in with Google)
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mb-4">
|
||||
<strong>
|
||||
We do NOT store your projects on our servers.
|
||||
</strong>{" "}
|
||||
All project data, including names, thumbnails, and creation
|
||||
dates, is stored locally in your browser using IndexedDB.
|
||||
</p>
|
||||
<p>
|
||||
We use{" "}
|
||||
<a
|
||||
href="https://www.better-auth.com"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Better Auth
|
||||
</a>{" "}
|
||||
for secure authentication and follow industry-standard
|
||||
security practices.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">Analytics</h2>
|
||||
<p className="mb-4">
|
||||
We use{" "}
|
||||
<a
|
||||
href="https://www.databuddy.cc"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Databuddy
|
||||
</a>{" "}
|
||||
for completely anonymized and non-invasive analytics to
|
||||
understand how people use OpenCut.
|
||||
</p>
|
||||
<p>
|
||||
This helps us improve the editor, but we never collect
|
||||
personal information, track individual users, or store any
|
||||
data that could identify you.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
Local Storage & Cookies
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
We use browser local storage and IndexedDB to:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 space-y-2">
|
||||
<li>Save your projects locally on your device</li>
|
||||
<li>Remember your editor preferences and settings</li>
|
||||
<li>Keep you logged in across browser sessions</li>
|
||||
</ul>
|
||||
<p>
|
||||
All data stays on your device and can be cleared at any time
|
||||
through your browser settings.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
Third-Party Services
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
OpenCut integrates with these services:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 space-y-2">
|
||||
<li>
|
||||
<strong>Google OAuth:</strong> For optional Google sign-in
|
||||
(governed by Google's privacy policy)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Vercel:</strong> For hosting and content delivery
|
||||
</li>
|
||||
<li>
|
||||
<strong>Databuddy:</strong> For anonymized analytics
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">Your Rights</h2>
|
||||
<p className="mb-4">
|
||||
You have complete control over your data:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 space-y-2">
|
||||
<li>
|
||||
Delete your account and all associated data at any time
|
||||
</li>
|
||||
<li>Export your project data</li>
|
||||
<li>Clear local storage to remove all saved projects</li>
|
||||
<li>Contact us with any privacy concerns</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
Open Source Transparency
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
OpenCut is completely open source. You can review our code,
|
||||
see exactly how we handle data, and even self-host the
|
||||
application if you prefer.
|
||||
</p>
|
||||
<p>
|
||||
View our source code on{" "}
|
||||
<a
|
||||
href="https://github.com/OpenCut-app/OpenCut"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">Contact Us</h2>
|
||||
<p className="mb-4">
|
||||
Questions about this privacy policy or how we handle your
|
||||
data?
|
||||
</p>
|
||||
<p>
|
||||
Open an issue on our{" "}
|
||||
<a
|
||||
href="https://github.com/OpenCut-app/OpenCut/issues"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
GitHub repository
|
||||
</a>
|
||||
, email us at{" "}
|
||||
<a
|
||||
href="mailto:oss@opencut.app"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
oss@opencut.app
|
||||
</a>
|
||||
, or reach out on{" "}
|
||||
<a
|
||||
href="https://x.com/opencutapp"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
X (Twitter)
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-8 pt-8 border-t border-muted/20">
|
||||
Last updated: July 14, 2025
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
ChevronLeft,
|
||||
Plus,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
Loader2,
|
||||
X,
|
||||
Trash2,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { TProject } from "@/types/project";
|
||||
import Image from "next/image";
|
||||
@@ -28,6 +30,13 @@ 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";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const {
|
||||
@@ -36,6 +45,7 @@ export default function ProjectsPage() {
|
||||
isLoading,
|
||||
isInitialized,
|
||||
deleteProject,
|
||||
getFilteredAndSortedProjects,
|
||||
} = useProjectStore();
|
||||
const router = useRouter();
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
@@ -43,6 +53,8 @@ export default function ProjectsPage() {
|
||||
new Set()
|
||||
);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortOption, setSortOption] = useState("createdAt-desc");
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
const projectId = await createNewProject("New Project");
|
||||
@@ -62,7 +74,7 @@ export default function ProjectsPage() {
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedProjects(new Set(savedProjects.map((p) => p.id)));
|
||||
setSelectedProjects(new Set(sortedProjects.map((p) => p.id)));
|
||||
} else {
|
||||
setSelectedProjects(new Set());
|
||||
}
|
||||
@@ -74,18 +86,21 @@ export default function ProjectsPage() {
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
for (const projectId of selectedProjects) {
|
||||
await deleteProject(projectId);
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from(selectedProjects).map((projectId) => deleteProject(projectId))
|
||||
);
|
||||
setSelectedProjects(new Set());
|
||||
setIsSelectionMode(false);
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
};
|
||||
|
||||
const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption);
|
||||
|
||||
const allSelected =
|
||||
savedProjects.length > 0 && selectedProjects.size === savedProjects.length;
|
||||
sortedProjects.length > 0 &&
|
||||
selectedProjects.size === sortedProjects.length;
|
||||
const someSelected =
|
||||
selectedProjects.size > 0 && selectedProjects.size < savedProjects.length;
|
||||
selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
@@ -172,28 +187,39 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelectionMode && savedProjects.length > 0 && (
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div className="flex-1 max-w-72">
|
||||
<Input
|
||||
placeholder="Search projects..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={sortOption} onValueChange={setSortOption}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="createdAt-desc">Newest to Oldest</SelectItem>
|
||||
<SelectItem value="createdAt-asc">Oldest to Newest</SelectItem>
|
||||
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
|
||||
<SelectItem value="name-desc">Name (Z-A)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isSelectionMode && sortedProjects.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}
|
||||
checked={someSelected ? "indeterminate" : allSelected}
|
||||
onCheckedChange={(value) => handleSelectAll(!!value)}
|
||||
/>
|
||||
<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)
|
||||
({selectedProjects.size} of {sortedProjects.length} selected)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,9 +231,14 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
) : savedProjects.length === 0 ? (
|
||||
<NoProjects onCreateProject={handleCreateProject} />
|
||||
) : sortedProjects.length === 0 ? (
|
||||
<NoResults
|
||||
searchQuery={searchQuery}
|
||||
onClearSearch={() => setSearchQuery("")}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{savedProjects.map((project) => (
|
||||
{sortedProjects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
@@ -512,6 +543,7 @@ function ProjectCard({
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
onConfirm={handleDeleteProject}
|
||||
projectName={project.name}
|
||||
/>
|
||||
<RenameProjectDialog
|
||||
isOpen={isRenameDialogOpen}
|
||||
@@ -550,3 +582,26 @@ function NoProjects({ onCreateProject }: { onCreateProject: () => void }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoResults({
|
||||
searchQuery,
|
||||
onClearSearch,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
onClearSearch: () => 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">
|
||||
<Search className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">No results found</h3>
|
||||
<p className="text-muted-foreground mb-6 max-w-md">
|
||||
Your search for "{searchQuery}" did not return any results.
|
||||
</p>
|
||||
<Button onClick={onClearSearch} variant="outline">
|
||||
Clear Search
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { Metadata } from "next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { GithubIcon } from "@/components/icons";
|
||||
import Link from "next/link";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { Header } from "@/components/header";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const roadmapItems: {
|
||||
title: string;
|
||||
description: string;
|
||||
status: {
|
||||
text: string;
|
||||
type: "complete" | "pending" | "default" | "info";
|
||||
};
|
||||
}[] = [
|
||||
{
|
||||
title: "Start",
|
||||
description:
|
||||
"This is where it all started. Repository created, initial project structure, and the vision for a free, open-source video editor. [Check out the first tweet](https://x.com/mazeincoding/status/1936706642512388188) to see where it started.",
|
||||
status: {
|
||||
text: "Completed",
|
||||
type: "complete",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Core UI",
|
||||
description:
|
||||
"Built the foundation - main layout, header, sidebar, timeline container, and basic component structure. Not all functionality yet, but the UI framework that everything else builds on.",
|
||||
status: {
|
||||
text: "Completed",
|
||||
type: "complete",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Basic Functionality",
|
||||
description:
|
||||
"The heart of any video editor. Timeline zoom in/out, making clips longer/shorter, dragging elements around, selection, playhead scrubbing. **This part has to be fucking perfect** because it's what users interact with 99% of the time.",
|
||||
status: {
|
||||
text: "In Progress",
|
||||
type: "pending",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Export/Preview Logic",
|
||||
description:
|
||||
"The foundation that enables everything else. Real-time preview, video rendering, export functionality. Once this works, we can add effects, filters, transitions - basically everything that makes a video editor powerful.",
|
||||
status: {
|
||||
text: "In Progress",
|
||||
type: "pending",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Text",
|
||||
description:
|
||||
"After media, text is the next most important thing. Font selection with custom font imports, text stroke, colors. All the text essential text properties.",
|
||||
status: {
|
||||
text: "Not Started",
|
||||
type: "default",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Effects",
|
||||
description:
|
||||
"Adding visual effects to both text and media. Blur, brightness, contrast, saturation, filters, and all the creative tools that make videos pop. This is where the magic happens.",
|
||||
status: {
|
||||
text: "Not Started",
|
||||
type: "default",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Transitions",
|
||||
description:
|
||||
"Smooth transitions between clips. Fade in/out, slide, zoom, dissolve, and custom transition effects.",
|
||||
status: {
|
||||
text: "Not Started",
|
||||
type: "default",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Refine from Here",
|
||||
description:
|
||||
"Once we nail the above, we have a **solid foundation** to build anything. Advanced features, performance optimizations, mobile support, desktop app.",
|
||||
status: {
|
||||
text: "Future",
|
||||
type: "info",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Roadmap - OpenCut",
|
||||
description:
|
||||
"See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
|
||||
openGraph: {
|
||||
title: "OpenCut Roadmap - What's Coming Next",
|
||||
description:
|
||||
"See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "/open-graph/roadmap.jpg",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "OpenCut Roadmap",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "OpenCut Roadmap - What's Coming Next",
|
||||
description:
|
||||
"See what's coming next for OpenCut - the free, open-source video editor that respects your privacy.",
|
||||
images: ["/open-graph/roadmap.jpg"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function RoadmapPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="relative">
|
||||
<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-4xl mx-auto">
|
||||
<div className="text-center mb-10">
|
||||
<Link
|
||||
href="https://github.com/OpenCut-app/OpenCut"
|
||||
target="_blank"
|
||||
>
|
||||
<Badge variant="secondary" className="gap-2 mb-6">
|
||||
<GithubIcon className="h-3 w-3" />
|
||||
Open Source
|
||||
</Badge>
|
||||
</Link>
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
||||
Roadmap
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
|
||||
What's coming next for OpenCut (last updated: July 14, 2025)
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{roadmapItems.map((item, index) => (
|
||||
<div key={index} className="relative">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg font-medium text-muted-foreground select-none leading-[1.5]">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<div className="flex-1 pt-[2px]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="font-medium text-lg">{item.title}</h3>
|
||||
<Badge
|
||||
className={cn("shadow-none", {
|
||||
"!bg-green-500 text-white":
|
||||
item.status.type === "complete",
|
||||
"!bg-yellow-500 text-white":
|
||||
item.status.type === "pending",
|
||||
"!bg-blue-500 text-white":
|
||||
item.status.type === "info",
|
||||
"!bg-foreground/10 text-accent-foreground":
|
||||
item.status.type === "default",
|
||||
})}
|
||||
>
|
||||
{item.status.text}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-foreground/70 leading-relaxed">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
a: ({ className, children, ...props }) => (
|
||||
<a
|
||||
className={cn(
|
||||
"text-primary hover:underline",
|
||||
className
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-foreground">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{item.description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 pt-8 border-t border-muted/20">
|
||||
<div className="text-center space-y-4">
|
||||
<h3 className="text-xl font-semibold">Want to Help?</h3>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
OpenCut is open source and built by the community. Every
|
||||
contribution, no matter how small, helps us build the best
|
||||
free video editor possible.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center mt-6">
|
||||
<Link
|
||||
href="https://github.com/OpenCut-app/OpenCut/blob/main/.github/CONTRIBUTING.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-sm px-4 py-2 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<GithubIcon className="h-4 w-4 mr-2" />
|
||||
Start Contributing
|
||||
</Badge>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/OpenCut-app/OpenCut/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-sm px-4 py-2 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
Report Issues
|
||||
</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { Metadata } from "next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { GithubIcon } from "@/components/icons";
|
||||
import Link from "next/link";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { Header } from "@/components/header";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Terms of Service - OpenCut",
|
||||
description:
|
||||
"OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.",
|
||||
openGraph: {
|
||||
title: "Terms of Service - OpenCut",
|
||||
description:
|
||||
"OpenCut's Terms of Service. Fair, transparent terms for our free and open-source video editor.",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="relative">
|
||||
<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-4xl mx-auto">
|
||||
<div className="text-center mb-10">
|
||||
<Link
|
||||
href="https://github.com/OpenCut-app/OpenCut"
|
||||
target="_blank"
|
||||
>
|
||||
<Badge variant="secondary" className="gap-2 mb-6">
|
||||
<GithubIcon className="h-3 w-3" />
|
||||
Open Source
|
||||
</Badge>
|
||||
</Link>
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
|
||||
Terms of Service
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
|
||||
Fair and transparent terms for our free, open-source video
|
||||
editor.
|
||||
</p>
|
||||
</div>
|
||||
<Card className="bg-background/80 backdrop-blur-sm border-2 border-muted/30">
|
||||
<CardContent className="p-8 text-base leading-relaxed space-y-8">
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
Welcome to OpenCut
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
OpenCut is a free, open-source video editor that runs in
|
||||
your browser. By using our service, you agree to these
|
||||
terms. We've designed these terms to be fair and protect
|
||||
both you and our project.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Key principle:</strong> Your content stays on your
|
||||
device. We never claim ownership of your videos or projects.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
Your Content, Your Rights
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
<strong>You own everything you create.</strong> OpenCut
|
||||
processes your videos locally on your device, so we never
|
||||
have access to your content. We make no claims to ownership,
|
||||
licensing, or rights over your videos, projects, or any
|
||||
content you create using OpenCut.
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 space-y-2">
|
||||
<li>
|
||||
Your videos remain completely private and under your
|
||||
control
|
||||
</li>
|
||||
<li>
|
||||
You retain all intellectual property rights to your
|
||||
content
|
||||
</li>
|
||||
<li>
|
||||
You can export and use your content however you choose
|
||||
</li>
|
||||
<li>
|
||||
No watermarks, no licensing restrictions from OpenCut
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
How You Can Use OpenCut
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
OpenCut is free for personal and commercial use. You can:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 space-y-2">
|
||||
<li>
|
||||
Create videos for personal, educational, or commercial
|
||||
purposes
|
||||
</li>
|
||||
<li>Use OpenCut for client work and paid projects</li>
|
||||
<li>Share and distribute videos created with OpenCut</li>
|
||||
<li>
|
||||
Modify and distribute the OpenCut software (under MIT
|
||||
license)
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>What we ask:</strong> Don't use OpenCut for illegal
|
||||
activities, harassment, or creating harmful content. Be
|
||||
respectful of others and follow applicable laws.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
Account and Service
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
To use certain features, you may create an account:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 space-y-2">
|
||||
<li>Provide accurate information when signing up</li>
|
||||
<li>
|
||||
Keep your account secure and don't share credentials
|
||||
</li>
|
||||
<li>You're responsible for activity under your account</li>
|
||||
<li>You can delete your account at any time</li>
|
||||
</ul>
|
||||
<p>
|
||||
OpenCut is provided "as is" without warranties. While we
|
||||
strive for reliability, we can't guarantee uninterrupted
|
||||
service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
Open Source Benefits
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
Because OpenCut is open source, you have additional rights:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 space-y-2">
|
||||
<li>
|
||||
Review our code to see exactly how we handle your data
|
||||
</li>
|
||||
<li>Self-host OpenCut on your own servers</li>
|
||||
<li>Modify the software to suit your needs</li>
|
||||
<li>Contribute improvements back to the community</li>
|
||||
</ul>
|
||||
<p>
|
||||
View our source code and license on{" "}
|
||||
<a
|
||||
href="https://github.com/OpenCut-app/OpenCut"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
Third-Party Content
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
When using OpenCut, make sure you have the right to use any
|
||||
content you import:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 space-y-2">
|
||||
<li>
|
||||
Only upload content you own or have permission to use
|
||||
</li>
|
||||
<li>
|
||||
Respect copyright, trademarks, and other intellectual
|
||||
property
|
||||
</li>
|
||||
<li>
|
||||
Don't use copyrighted music, images, or videos without
|
||||
permission
|
||||
</li>
|
||||
<li>
|
||||
You're responsible for any claims related to your content
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
Limitations and Liability
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
OpenCut is provided free of charge. To the extent permitted
|
||||
by law:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 space-y-2">
|
||||
<li>We're not liable for any loss of data or content</li>
|
||||
<li>
|
||||
Projects are stored in your browser and may be lost if you
|
||||
clear browser data
|
||||
</li>
|
||||
<li>We're not responsible for how you use the service</li>
|
||||
<li>
|
||||
Our liability is limited to the maximum extent allowed by
|
||||
law
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Since your content stays on your device, we have no way to
|
||||
recover lost projects. Consider exporting important videos
|
||||
when finished editing.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
Service Changes
|
||||
</h2>
|
||||
<p className="mb-4">We may update OpenCut and these terms:</p>
|
||||
<ul className="list-disc pl-6 mb-4 space-y-2">
|
||||
<li>
|
||||
We'll notify you of significant changes to these terms
|
||||
</li>
|
||||
<li>Continued use means you accept any updates</li>
|
||||
<li>
|
||||
You can always self-host an older version if you prefer
|
||||
</li>
|
||||
<li>
|
||||
Major changes will be discussed with the community on
|
||||
GitHub
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">Termination</h2>
|
||||
<p className="mb-4">
|
||||
You can stop using OpenCut at any time:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-4 space-y-2">
|
||||
<li>Delete your account through your profile settings</li>
|
||||
<li>Clear your browser data to remove local projects</li>
|
||||
<li>
|
||||
Your content remains yours even if you stop using OpenCut
|
||||
</li>
|
||||
<li>
|
||||
We may suspend accounts for violations of these terms
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
Contact and Disputes
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
Questions about these terms or need to report an issue?
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Contact us through our{" "}
|
||||
<a
|
||||
href="https://github.com/OpenCut-app/OpenCut/issues"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
GitHub repository
|
||||
</a>
|
||||
, email us at{" "}
|
||||
<a
|
||||
href="mailto:oss@opencut.app"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
oss@opencut.app
|
||||
</a>
|
||||
, or reach out on{" "}
|
||||
<a
|
||||
href="https://x.com/opencutapp"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
X (Twitter)
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
These terms are governed by applicable law in your
|
||||
jurisdiction. We prefer to resolve disputes through friendly
|
||||
discussion in our open-source community.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-8 pt-8 border-t border-muted/20">
|
||||
Last updated: July 14, 2025
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,9 +40,9 @@ export function BackgroundSettings() {
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="!size-5 border border-muted-foreground"
|
||||
className="!size-4 border border-muted-foreground"
|
||||
>
|
||||
<BackgroundIcon className="!size-4" />
|
||||
<BackgroundIcon className="!size-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0">
|
||||
|
||||
@@ -7,15 +7,18 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DeleteProjectDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
projectName,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
projectName?: string;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
@@ -26,7 +29,19 @@ export function DeleteProjectDialog({
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Project</DialogTitle>
|
||||
<DialogTitle>
|
||||
{projectName ? (
|
||||
<>
|
||||
{"Delete '"}
|
||||
<span className="inline-block max-w-[300px] truncate align-bottom">
|
||||
{projectName}
|
||||
</span>
|
||||
{"'?"}
|
||||
</>
|
||||
) : (
|
||||
"Delete Project?"
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this project? This action cannot be
|
||||
undone.
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||
import { TimelineTrack } from "@/types/timeline";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import type { TimelineElement } from "@/types/timeline";
|
||||
|
||||
// Only show in development
|
||||
const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
|
||||
|
||||
interface ActiveElement {
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
mediaItem: MediaItem | null;
|
||||
}
|
||||
|
||||
export function DevelopmentDebug() {
|
||||
const { tracks } = useTimelineStore();
|
||||
const { mediaItems } = useMediaStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
const [showDebug, setShowDebug] = useState(false);
|
||||
|
||||
// Don't render anything in production
|
||||
if (!SHOW_DEBUG_INFO) return null;
|
||||
|
||||
// Get active elements at current time
|
||||
const getActiveElements = (): ActiveElement[] => {
|
||||
const activeElements: ActiveElement[] = [];
|
||||
|
||||
tracks.forEach((track) => {
|
||||
track.elements.forEach((element) => {
|
||||
const elementStart = element.startTime;
|
||||
const elementEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime >= elementStart && currentTime < elementEnd) {
|
||||
const mediaItem =
|
||||
element.type === "media"
|
||||
? mediaItems.find((item) => item.id === element.mediaId) || null
|
||||
: null; // Text elements don't have media items
|
||||
|
||||
activeElements.push({ element, track, mediaItem });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return activeElements;
|
||||
};
|
||||
|
||||
const activeElements = getActiveElements();
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{/* Toggle Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDebug(!showDebug)}
|
||||
className="text-xs backdrop-blur-md bg-background/80 border-border/50"
|
||||
>
|
||||
Debug {showDebug ? "ON" : "OFF"}
|
||||
</Button>
|
||||
|
||||
{/* Debug Info Panel */}
|
||||
{showDebug && (
|
||||
<div className="backdrop-blur-md bg-background/90 border border-border/50 rounded-lg p-3 max-w-sm">
|
||||
<div className="text-xs font-medium mb-2 text-foreground">
|
||||
Active Elements ({activeElements.length})
|
||||
</div>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{activeElements.map((elementData, index) => (
|
||||
<div
|
||||
key={elementData.element.id}
|
||||
className="flex items-center gap-2 px-2 py-1 bg-muted/60 rounded text-xs"
|
||||
>
|
||||
<span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4 flex-shrink-0">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate">{elementData.element.name}</div>
|
||||
<div className="text-muted-foreground text-[10px]">
|
||||
{elementData.element.type === "media"
|
||||
? elementData.mediaItem?.type || "media"
|
||||
: "text"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{activeElements.length === 0 && (
|
||||
<div className="text-muted-foreground text-xs py-2 text-center">
|
||||
No active elements
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 pt-2 border-t border-border/30 text-[10px] text-muted-foreground">
|
||||
Time: {currentTime.toFixed(2)}s
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { HeaderBase } from "./header-base";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help";
|
||||
|
||||
export function EditorHeader() {
|
||||
const { getTotalDuration } = useTimelineStore();
|
||||
@@ -31,13 +32,25 @@ export function EditorHeader() {
|
||||
|
||||
const centerContent = (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span>{formatTimeCode(getTotalDuration(), "HH:MM:SS:CS")}</span>
|
||||
<span>
|
||||
{formatTimeCode(
|
||||
getTotalDuration(),
|
||||
"HH:MM:SS:FF",
|
||||
activeProject?.fps || 30
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const rightContent = (
|
||||
<nav className="flex items-center gap-2">
|
||||
<Button size="sm" variant="primary" className="h-7 text-xs" onClick={handleExport}>
|
||||
<KeyboardShortcutsHelp />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-sm">Export</span>
|
||||
</Button>
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
import { useEffect } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import {
|
||||
useKeybindingsListener,
|
||||
useKeybindingDisabler,
|
||||
} from "@/hooks/use-keybindings";
|
||||
import { useEditorActions } from "@/hooks/use-editor-actions";
|
||||
|
||||
interface EditorProviderProps {
|
||||
children: React.ReactNode;
|
||||
@@ -10,6 +15,22 @@ interface EditorProviderProps {
|
||||
|
||||
export function EditorProvider({ children }: EditorProviderProps) {
|
||||
const { isInitializing, isPanelsReady, initializeApp } = useEditorStore();
|
||||
const { disableKeybindings, enableKeybindings } = useKeybindingDisabler();
|
||||
|
||||
// Set up action handlers
|
||||
useEditorActions();
|
||||
|
||||
// Set up keybinding listener
|
||||
useKeybindingsListener();
|
||||
|
||||
// Disable keybindings when initializing
|
||||
useEffect(() => {
|
||||
if (isInitializing || !isPanelsReady) {
|
||||
disableKeybindings();
|
||||
} else {
|
||||
enableKeybindings();
|
||||
}
|
||||
}, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]);
|
||||
|
||||
useEffect(() => {
|
||||
initializeApp();
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Upload, Plus, Image } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface MediaDragOverlayProps {
|
||||
isVisible: boolean;
|
||||
isProcessing?: boolean;
|
||||
progress?: number;
|
||||
onClick?: () => void;
|
||||
isEmptyState?: boolean;
|
||||
}
|
||||
|
||||
export function MediaDragOverlay({
|
||||
isVisible,
|
||||
isProcessing = false,
|
||||
progress = 0,
|
||||
onClick,
|
||||
isEmptyState = false,
|
||||
}: MediaDragOverlayProps) {
|
||||
if (!isVisible) return null;
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isProcessing || !onClick) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<Upload className="h-10 w-10 text-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground max-w-sm">
|
||||
{isProcessing
|
||||
? `Processing your files (${progress}%)`
|
||||
: "Drag and drop videos, photos, and audio files here"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isProcessing && (
|
||||
<div className="w-full max-w-xs">
|
||||
<div className="w-full bg-muted/50 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,15 +4,14 @@ import { TabBar } from "./tabbar";
|
||||
import { MediaView } from "./views/media";
|
||||
import { useMediaPanelStore, Tab } from "./store";
|
||||
import { TextView } from "./views/text";
|
||||
import { AudioView } from "./views/audio";
|
||||
|
||||
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>
|
||||
),
|
||||
audio: <AudioView />,
|
||||
text: <TextView />,
|
||||
stickers: (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
|
||||
@@ -69,7 +69,7 @@ export function TabBar() {
|
||||
/>
|
||||
<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"
|
||||
className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full"
|
||||
>
|
||||
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
|
||||
const tab = tabs[tabKey];
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useState } from "react";
|
||||
|
||||
export function AudioView() {
|
||||
const [search, setSearch] = useState("");
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-2 p-4">
|
||||
<Input
|
||||
placeholder="Search songs and artists"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-col gap-2"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,11 @@
|
||||
import { useDragDrop } from "@/hooks/use-drag-drop";
|
||||
import { processMediaFiles } from "@/lib/media-processing";
|
||||
import { useMediaStore, type MediaItem } from "@/stores/media-store";
|
||||
import { Image, Music, Plus, Upload, Video } from "lucide-react";
|
||||
import { Image, Loader2, Music, Plus, Video } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DragOverlay } from "@/components/ui/drag-overlay";
|
||||
import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
|
||||
export function MediaView() {
|
||||
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
|
||||
@@ -203,18 +204,14 @@ export function MediaView() {
|
||||
className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
|
||||
{...dragProps}
|
||||
>
|
||||
{/* Show overlay when dragging files over the panel */}
|
||||
<DragOverlay isVisible={isDragOver} />
|
||||
|
||||
<div className="p-3 pb-2">
|
||||
{/* Button to add/upload media */}
|
||||
{/* Search and filter controls */}
|
||||
<div className="flex gap-2">
|
||||
{/* Search and filter controls */}
|
||||
<Select value={mediaFilter} onValueChange={setMediaFilter}>
|
||||
<SelectTrigger className="w-[80px] h-full text-xs">
|
||||
<SelectTrigger className="w-[80px] h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="">
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="video">Video</SelectItem>
|
||||
<SelectItem value="audio">Audio</SelectItem>
|
||||
@@ -224,50 +221,35 @@ export function MediaView() {
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search media..."
|
||||
className="min-w-[60px] flex-1 h-full text-xs"
|
||||
className="min-w-[60px] flex-1 h-9 text-xs"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Add media button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
size="lg"
|
||||
onClick={handleFileSelect}
|
||||
disabled={isProcessing}
|
||||
className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
|
||||
className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center h-9"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Upload className="h-4 w-4 animate-spin" />
|
||||
<span className="hidden md:inline ml-2">{progress}%</span>
|
||||
</>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline ml-2" aria-label="Add file">
|
||||
Add
|
||||
</span>
|
||||
</>
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 pt-0">
|
||||
{/* Show message if no media, otherwise show media grid */}
|
||||
{filteredMediaItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center h-full">
|
||||
<div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
|
||||
<Image className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No media in project
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Drag files here or use the button above
|
||||
</p>
|
||||
</div>
|
||||
{isDragOver || filteredMediaItems.length === 0 ? (
|
||||
<MediaDragOverlay
|
||||
isVisible={true}
|
||||
isProcessing={isProcessing}
|
||||
progress={progress}
|
||||
onClick={handleFileSelect}
|
||||
isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
@@ -288,6 +270,11 @@ export function MediaView() {
|
||||
name: item.name,
|
||||
}}
|
||||
showPlusOnDrag={false}
|
||||
onAddToTimeline={(currentTime) =>
|
||||
useTimelineStore
|
||||
.getState()
|
||||
.addMediaAtTime(item, currentTime)
|
||||
}
|
||||
rounded={false}
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
@@ -1,4 +1,30 @@
|
||||
import { DraggableMediaItem } from "@/components/ui/draggable-item";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { type TextElement } from "@/types/timeline";
|
||||
|
||||
let textData: TextElement = {
|
||||
id: "default-text",
|
||||
type: "text",
|
||||
name: "Default text",
|
||||
content: "Default text",
|
||||
fontSize: 48,
|
||||
fontFamily: "Arial",
|
||||
color: "#ffffff",
|
||||
backgroundColor: "transparent",
|
||||
textAlign: "center" as const,
|
||||
fontWeight: "normal" as const,
|
||||
fontStyle: "normal" as const,
|
||||
textDecoration: "none" as const,
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
};
|
||||
|
||||
export function TextView() {
|
||||
return (
|
||||
@@ -11,12 +37,15 @@ export function TextView() {
|
||||
</div>
|
||||
}
|
||||
dragData={{
|
||||
id: "default-text",
|
||||
type: "text",
|
||||
name: "Default text",
|
||||
content: "Default text",
|
||||
id: textData.id,
|
||||
type: textData.type,
|
||||
name: textData.name,
|
||||
content: textData.content,
|
||||
}}
|
||||
aspectRatio={1}
|
||||
onAddToTimeline={(currentTime) =>
|
||||
useTimelineStore.getState().addTextAtTime(textData, currentTime)
|
||||
}
|
||||
showLabel={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
import { Play, Pause, Expand } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
@@ -347,7 +347,7 @@ export function PreviewPanel() {
|
||||
{hasAnyElements ? (
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="relative overflow-hidden rounded-sm border"
|
||||
className="relative overflow-hidden border"
|
||||
style={{
|
||||
width: previewDimensions.width,
|
||||
height: previewDimensions.height,
|
||||
@@ -390,6 +390,7 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
|
||||
const { isPlaying, toggle, currentTime } = usePlaybackStore();
|
||||
const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore();
|
||||
const { getTotalDuration } = useTimelineStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const {
|
||||
currentPreset,
|
||||
isOriginal,
|
||||
@@ -420,11 +421,19 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
|
||||
)}
|
||||
>
|
||||
<span className="text-primary tabular-nums">
|
||||
{formatTimeCode(currentTime, "HH:MM:SS:CS")}
|
||||
{formatTimeCode(
|
||||
currentTime,
|
||||
"HH:MM:SS:FF",
|
||||
activeProject?.fps || 30
|
||||
)}
|
||||
</span>
|
||||
<span className="opacity-50">/</span>
|
||||
<span className="tabular-nums">
|
||||
{formatTimeCode(getTotalDuration(), "HH:MM:SS:CS")}
|
||||
{formatTimeCode(
|
||||
getTotalDuration(),
|
||||
"HH:MM:SS:FF",
|
||||
activeProject?.fps || 30
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -447,7 +456,7 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="!bg-panel-accent text-foreground/85 text-[0.75rem] h-auto rounded-none border border-muted-foreground px-0.5 py-0 font-light"
|
||||
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()}
|
||||
@@ -475,6 +484,13 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
className="!size-4 text-muted-foreground"
|
||||
>
|
||||
<Expand className="!size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,13 +9,28 @@ 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 } = useProjectStore();
|
||||
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 */}
|
||||
@@ -26,7 +41,24 @@ export function PropertiesPanel() {
|
||||
label="Resolution:"
|
||||
value={`${canvasSize.width} × ${canvasSize.height}`}
|
||||
/>
|
||||
<PropertyItem label="Frame rate:" value="30.00fps" />
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { SnapPoint } from "@/hooks/use-timeline-snapping";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
interface SnapIndicatorProps {
|
||||
snapPoint: SnapPoint | null;
|
||||
zoomLevel: number;
|
||||
isVisible: boolean;
|
||||
tracks: TimelineTrack[];
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
trackLabelsRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function SnapIndicator({
|
||||
snapPoint,
|
||||
zoomLevel,
|
||||
isVisible,
|
||||
tracks,
|
||||
timelineRef,
|
||||
trackLabelsRef,
|
||||
}: SnapIndicatorProps) {
|
||||
if (!isVisible || !snapPoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 +
|
||||
snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-none z-[90]"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
height: `${totalHeight}px`,
|
||||
width: "2px",
|
||||
}}
|
||||
>
|
||||
<div className={`w-0.5 h-full bg-primary/40 opacity-80`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
|
||||
import {
|
||||
getTrackElementClasses,
|
||||
TIMELINE_CONSTANTS,
|
||||
getTrackHeight,
|
||||
} from "@/constants/timeline-constants";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -56,6 +57,7 @@ export function TimelineElement({
|
||||
updateElementTrim,
|
||||
updateElementDuration,
|
||||
removeElementFromTrack,
|
||||
removeElementFromTrackWithRipple,
|
||||
dragState,
|
||||
splitElement,
|
||||
splitAndKeepLeft,
|
||||
@@ -63,6 +65,7 @@ export function TimelineElement({
|
||||
separateAudio,
|
||||
addElementToTrack,
|
||||
replaceElementMedia,
|
||||
rippleEditingEnabled,
|
||||
} = useTimelineStore();
|
||||
const { currentTime } = usePlaybackStore();
|
||||
|
||||
@@ -95,94 +98,10 @@ export function TimelineElement({
|
||||
isBeingDragged && dragState.isDragging
|
||||
? dragState.currentTime
|
||||
: element.startTime;
|
||||
|
||||
// Element should always be positioned at startTime - trimStart only affects content, not position
|
||||
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 =
|
||||
@@ -212,7 +131,11 @@ export function TimelineElement({
|
||||
};
|
||||
|
||||
const handleElementDeleteContext = () => {
|
||||
removeElementFromTrack(track.id, element.id);
|
||||
if (rippleEditingEnabled) {
|
||||
removeElementFromTrackWithRipple(track.id, element.id);
|
||||
} else {
|
||||
removeElementFromTrack(track.id, element.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplaceClip = () => {
|
||||
@@ -267,35 +190,99 @@ export function TimelineElement({
|
||||
);
|
||||
}
|
||||
|
||||
const TILE_ASPECT_RATIO = 16 / 9;
|
||||
|
||||
if (mediaItem.type === "image") {
|
||||
// Calculate tile size based on 16:9 aspect ratio
|
||||
const trackHeight = getTrackHeight(track.type);
|
||||
const tileHeight = trackHeight - 8; // Account for padding
|
||||
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
|
||||
|
||||
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 className="bg-[#004D52] py-3 w-full h-full relative">
|
||||
{/* Background with tiled images */}
|
||||
<div
|
||||
className="absolute top-3 bottom-3 left-0 right-0"
|
||||
style={{
|
||||
backgroundImage: mediaItem.url
|
||||
? `url(${mediaItem.url})`
|
||||
: "none",
|
||||
backgroundRepeat: "repeat-x",
|
||||
backgroundSize: `${tileWidth}px ${tileHeight}px`,
|
||||
backgroundPosition: "left center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
aria-label={`Tiled background of ${mediaItem.name}`}
|
||||
/>
|
||||
{/* Overlay with vertical borders */}
|
||||
<div
|
||||
className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
to right,
|
||||
transparent 0px,
|
||||
transparent ${tileWidth - 1}px,
|
||||
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
|
||||
rgba(255, 255, 255, 0.6) ${tileWidth}px
|
||||
)`,
|
||||
backgroundPosition: "left center",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const VIDEO_TILE_PADDING = 16;
|
||||
const OVERLAY_SPACE_MULTIPLIER = 1.5;
|
||||
|
||||
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
|
||||
const trackHeight = getTrackHeight(track.type);
|
||||
const tileHeight = trackHeight - VIDEO_TILE_PADDING;
|
||||
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
|
||||
|
||||
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 className="flex-1 h-full relative overflow-hidden">
|
||||
{/* Background with tiled thumbnails */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: mediaItem.thumbnailUrl
|
||||
? `url(${mediaItem.thumbnailUrl})`
|
||||
: "none",
|
||||
backgroundRepeat: "repeat-x",
|
||||
backgroundSize: `${tileWidth}px ${tileHeight}px`,
|
||||
backgroundPosition: "left center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
aria-label={`Tiled thumbnail of ${mediaItem.name}`}
|
||||
/>
|
||||
{/* Overlay with vertical borders */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
to right,
|
||||
transparent 0px,
|
||||
transparent ${tileWidth - 1}px,
|
||||
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
|
||||
rgba(255, 255, 255, 0.6) ${tileWidth}px
|
||||
)`,
|
||||
backgroundPosition: "left center",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-foreground/80 truncate flex-1">
|
||||
{element.name}
|
||||
</span>
|
||||
{elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate">
|
||||
{element.name}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]">
|
||||
{element.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ interface TimelinePlayheadProps {
|
||||
trackLabelsRef?: React.RefObject<HTMLDivElement>;
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
playheadRef?: React.RefObject<HTMLDivElement>;
|
||||
isSnappingToPlayhead?: boolean;
|
||||
}
|
||||
|
||||
export function TimelinePlayhead({
|
||||
@@ -34,6 +35,7 @@ export function TimelinePlayhead({
|
||||
trackLabelsRef,
|
||||
timelineRef,
|
||||
playheadRef: externalPlayheadRef,
|
||||
isSnappingToPlayhead = false,
|
||||
}: TimelinePlayheadProps) {
|
||||
const internalPlayheadRef = useRef<HTMLDivElement>(null);
|
||||
const playheadRef = externalPlayheadRef || internalPlayheadRef;
|
||||
@@ -64,7 +66,7 @@ export function TimelinePlayhead({
|
||||
return (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute pointer-events-auto z-[100]"
|
||||
className="absolute pointer-events-auto z-[150]"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
@@ -73,11 +75,15 @@ export function TimelinePlayhead({
|
||||
}}
|
||||
onMouseDown={handlePlayheadMouseDown}
|
||||
>
|
||||
{/* The red line spanning full height */}
|
||||
<div className="absolute left-0 w-0.5 bg-foreground cursor-col-resize h-full" />
|
||||
{/* The playhead line spanning full height */}
|
||||
<div
|
||||
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
|
||||
/>
|
||||
|
||||
{/* 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" />
|
||||
{/* Playhead 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 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { TrackType } from "@/types/timeline";
|
||||
import {
|
||||
ArrowLeftToLine,
|
||||
ArrowRightToLine,
|
||||
Copy,
|
||||
Pause,
|
||||
Play,
|
||||
Scissors,
|
||||
Snowflake,
|
||||
SplitSquareHorizontal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "../ui/tooltip";
|
||||
|
||||
interface TimelineToolbarProps {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
speed: number;
|
||||
tracks: any[];
|
||||
toggle: () => void;
|
||||
setSpeed: (speed: number) => void;
|
||||
addTrack: (type: TrackType) => string;
|
||||
addClipToTrack: (trackId: string, clip: any) => void;
|
||||
handleSplitSelected: () => void;
|
||||
handleDuplicateSelected: () => void;
|
||||
handleFreezeSelected: () => void;
|
||||
handleDeleteSelected: () => void;
|
||||
}
|
||||
|
||||
export function TimelineToolbar({
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
speed,
|
||||
tracks,
|
||||
toggle,
|
||||
setSpeed,
|
||||
addTrack,
|
||||
addClipToTrack,
|
||||
handleSplitSelected,
|
||||
handleDuplicateSelected,
|
||||
handleFreezeSelected,
|
||||
handleDeleteSelected,
|
||||
}: TimelineToolbarProps) {
|
||||
return (
|
||||
<div className="border-b flex items-center px-2 py-1 gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
{/* Play/Pause Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={toggle}
|
||||
className="mr-2"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isPlaying ? "Pause (Space)" : "Play (Space)"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
|
||||
{/* Time Display */}
|
||||
<div
|
||||
className="text-xs text-muted-foreground font-mono px-2"
|
||||
style={{ minWidth: "18ch", textAlign: "center" }}
|
||||
>
|
||||
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
|
||||
</div>
|
||||
|
||||
{/* Test Clip Button - for debugging */}
|
||||
{tracks.length === 0 && (
|
||||
<>
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const trackId = addTrack("media");
|
||||
addClipToTrack(trackId, {
|
||||
mediaId: "test",
|
||||
name: "Test Clip",
|
||||
duration: 5,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Add Test Clip
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add a test clip to try playback</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={handleSplitSelected}>
|
||||
<Scissors className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split clip (S)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon">
|
||||
<ArrowLeftToLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep left (A)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon">
|
||||
<ArrowRightToLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep right (D)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon">
|
||||
<SplitSquareHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Separate audio (E)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleDuplicateSelected}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Duplicate clip (Ctrl+D)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={handleFreezeSelected}>
|
||||
<Snowflake className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Freeze frame (F)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={handleDeleteSelected}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete clip (Delete)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="w-px h-6 bg-border mx-1" />
|
||||
|
||||
{/* Speed Control */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Select
|
||||
value={speed.toFixed(1)}
|
||||
onValueChange={(value) => setSpeed(parseFloat(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[90px] h-8">
|
||||
<SelectValue placeholder="1.0x" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0.5">0.5x</SelectItem>
|
||||
<SelectItem value="1.0">1.0x</SelectItem>
|
||||
<SelectItem value="1.5">1.5x</SelectItem>
|
||||
<SelectItem value="2.0">2.0x</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Playback Speed</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,20 +17,29 @@ import type {
|
||||
TimelineElement as TimelineElementType,
|
||||
DragData,
|
||||
} from "@/types/timeline";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import {
|
||||
snapTimeToFrame,
|
||||
TIMELINE_CONSTANTS,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping";
|
||||
|
||||
export function TimelineTrackContent({
|
||||
track,
|
||||
zoomLevel,
|
||||
onSnapPointChange,
|
||||
}: {
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}) {
|
||||
const { mediaItems } = useMediaStore();
|
||||
const {
|
||||
tracks,
|
||||
addTrack,
|
||||
moveElementToTrack,
|
||||
updateElementStartTime,
|
||||
updateElementStartTimeWithRipple,
|
||||
addElementToTrack,
|
||||
selectedElements,
|
||||
selectElement,
|
||||
@@ -40,8 +49,66 @@ export function TimelineTrackContent({
|
||||
endDrag: endDragAction,
|
||||
clearSelectedElements,
|
||||
insertTrackAt,
|
||||
snappingEnabled,
|
||||
rippleEditingEnabled,
|
||||
} = useTimelineStore();
|
||||
|
||||
const { currentTime } = usePlaybackStore();
|
||||
|
||||
// Initialize snapping hook
|
||||
const { snapElementPosition, snapElementEdge } = useTimelineSnapping({
|
||||
snapThreshold: 10,
|
||||
enableElementSnapping: snappingEnabled,
|
||||
enablePlayheadSnapping: snappingEnabled,
|
||||
});
|
||||
|
||||
// Helper function for drop snapping that tries both edges
|
||||
const getDropSnappedTime = (
|
||||
dropTime: number,
|
||||
elementDuration: number,
|
||||
excludeElementId?: string
|
||||
) => {
|
||||
if (!snappingEnabled) {
|
||||
// Use frame snapping if project has FPS, otherwise use decimal snapping
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
return snapTimeToFrame(dropTime, projectFps);
|
||||
}
|
||||
|
||||
// Try snapping both start and end edges for drops
|
||||
const startSnapResult = snapElementEdge(
|
||||
dropTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
true // snap to start edge
|
||||
);
|
||||
|
||||
const endSnapResult = snapElementEdge(
|
||||
dropTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
excludeElementId,
|
||||
false // snap to end edge
|
||||
);
|
||||
|
||||
// Choose the snap result with the smaller distance (closer snap)
|
||||
let bestSnapResult = startSnapResult;
|
||||
if (
|
||||
endSnapResult.snapPoint &&
|
||||
(!startSnapResult.snapPoint ||
|
||||
endSnapResult.snapDistance < startSnapResult.snapDistance)
|
||||
) {
|
||||
bestSnapResult = endSnapResult;
|
||||
}
|
||||
|
||||
return bestSnapResult.snappedTime;
|
||||
};
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const [isDropping, setIsDropping] = useState(false);
|
||||
const [dropPosition, setDropPosition] = useState<number | null>(null);
|
||||
@@ -80,9 +147,71 @@ export function TimelineTrackContent({
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
|
||||
);
|
||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||
const snappedTime = Math.round(adjustedTime * 10) / 10;
|
||||
|
||||
updateDragTime(snappedTime);
|
||||
// Apply snapping if enabled
|
||||
let finalTime = adjustedTime;
|
||||
let snapPoint = null;
|
||||
if (snappingEnabled) {
|
||||
// Find the element being dragged to get its duration
|
||||
let elementDuration = 5; // fallback duration
|
||||
if (dragState.elementId && dragState.trackId) {
|
||||
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
|
||||
const element = sourceTrack?.elements.find(
|
||||
(e) => e.id === dragState.elementId
|
||||
);
|
||||
if (element) {
|
||||
elementDuration =
|
||||
element.duration - element.trimStart - element.trimEnd;
|
||||
}
|
||||
}
|
||||
|
||||
// Try snapping both start and end edges
|
||||
const startSnapResult = snapElementEdge(
|
||||
adjustedTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
dragState.elementId || undefined,
|
||||
true // snap to start edge
|
||||
);
|
||||
|
||||
const endSnapResult = snapElementEdge(
|
||||
adjustedTime,
|
||||
elementDuration,
|
||||
tracks,
|
||||
currentTime,
|
||||
zoomLevel,
|
||||
dragState.elementId || undefined,
|
||||
false // snap to end edge
|
||||
);
|
||||
|
||||
// Choose the snap result with the smaller distance (closer snap)
|
||||
let bestSnapResult = startSnapResult;
|
||||
if (
|
||||
endSnapResult.snapPoint &&
|
||||
(!startSnapResult.snapPoint ||
|
||||
endSnapResult.snapDistance < startSnapResult.snapDistance)
|
||||
) {
|
||||
bestSnapResult = endSnapResult;
|
||||
}
|
||||
|
||||
finalTime = bestSnapResult.snappedTime;
|
||||
snapPoint = bestSnapResult.snapPoint;
|
||||
|
||||
// Notify parent component about snap point change
|
||||
onSnapPointChange?.(snapPoint);
|
||||
} else {
|
||||
// Use frame snapping if project has FPS, otherwise use decimal snapping
|
||||
const projectStore = useProjectStore.getState();
|
||||
const projectFps = projectStore.activeProject?.fps || 30;
|
||||
finalTime = snapTimeToFrame(adjustedTime, projectFps);
|
||||
|
||||
// Clear snap point when not snapping
|
||||
onSnapPointChange?.(null);
|
||||
}
|
||||
|
||||
updateDragTime(finalTime);
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
@@ -94,12 +223,22 @@ export function TimelineTrackContent({
|
||||
const timelineRect = timelineRef.current?.getBoundingClientRect();
|
||||
if (!timelineRect) {
|
||||
if (isTrackThatStartedDrag) {
|
||||
updateElementStartTime(
|
||||
track.id,
|
||||
dragState.elementId,
|
||||
dragState.currentTime
|
||||
);
|
||||
if (rippleEditingEnabled) {
|
||||
updateElementStartTimeWithRipple(
|
||||
track.id,
|
||||
dragState.elementId,
|
||||
dragState.currentTime
|
||||
);
|
||||
} else {
|
||||
updateElementStartTime(
|
||||
track.id,
|
||||
dragState.elementId,
|
||||
dragState.currentTime
|
||||
);
|
||||
}
|
||||
endDragAction();
|
||||
// Clear snap point when drag ends
|
||||
onSnapPointChange?.(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -143,7 +282,19 @@ export function TimelineTrackContent({
|
||||
|
||||
if (!hasOverlap) {
|
||||
if (dragState.trackId === track.id) {
|
||||
updateElementStartTime(track.id, dragState.elementId, finalTime);
|
||||
if (rippleEditingEnabled) {
|
||||
updateElementStartTimeWithRipple(
|
||||
track.id,
|
||||
dragState.elementId,
|
||||
finalTime
|
||||
);
|
||||
} else {
|
||||
updateElementStartTime(
|
||||
track.id,
|
||||
dragState.elementId,
|
||||
finalTime
|
||||
);
|
||||
}
|
||||
} else {
|
||||
moveElementToTrack(
|
||||
dragState.trackId,
|
||||
@@ -151,11 +302,19 @@ export function TimelineTrackContent({
|
||||
dragState.elementId
|
||||
);
|
||||
requestAnimationFrame(() => {
|
||||
updateElementStartTime(
|
||||
track.id,
|
||||
dragState.elementId!,
|
||||
finalTime
|
||||
);
|
||||
if (rippleEditingEnabled) {
|
||||
updateElementStartTimeWithRipple(
|
||||
track.id,
|
||||
dragState.elementId!,
|
||||
finalTime
|
||||
);
|
||||
} else {
|
||||
updateElementStartTime(
|
||||
track.id,
|
||||
dragState.elementId!,
|
||||
finalTime
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -189,13 +348,23 @@ export function TimelineTrackContent({
|
||||
});
|
||||
|
||||
if (!hasOverlap) {
|
||||
updateElementStartTime(track.id, dragState.elementId, finalTime);
|
||||
if (rippleEditingEnabled) {
|
||||
updateElementStartTimeWithRipple(
|
||||
track.id,
|
||||
dragState.elementId,
|
||||
finalTime
|
||||
);
|
||||
} else {
|
||||
updateElementStartTime(track.id, dragState.elementId, finalTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isTrackThatStartedDrag) {
|
||||
endDragAction();
|
||||
// Clear snap point when drag ends
|
||||
onSnapPointChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -221,6 +390,7 @@ export function TimelineTrackContent({
|
||||
endDragAction,
|
||||
selectedElements,
|
||||
selectElement,
|
||||
onSnapPointChange,
|
||||
]);
|
||||
|
||||
const handleElementMouseDown = (
|
||||
@@ -342,7 +512,10 @@ export function TimelineTrackContent({
|
||||
if (dragData.type === "text") {
|
||||
// Text elements have default duration of 5 seconds
|
||||
const newElementDuration = 5;
|
||||
const snappedTime = Math.round(dropTime * 10) / 10;
|
||||
const snappedTime = getDropSnappedTime(
|
||||
dropTime,
|
||||
newElementDuration
|
||||
);
|
||||
const newElementEnd = snappedTime + newElementDuration;
|
||||
|
||||
wouldOverlap = track.elements.some((existingElement) => {
|
||||
@@ -361,7 +534,10 @@ export function TimelineTrackContent({
|
||||
);
|
||||
if (mediaItem) {
|
||||
const newElementDuration = mediaItem.duration || 5;
|
||||
const snappedTime = Math.round(dropTime * 10) / 10;
|
||||
const snappedTime = getDropSnappedTime(
|
||||
dropTime,
|
||||
newElementDuration
|
||||
);
|
||||
const newElementEnd = snappedTime + newElementDuration;
|
||||
|
||||
wouldOverlap = track.elements.some((existingElement) => {
|
||||
@@ -401,7 +577,11 @@ export function TimelineTrackContent({
|
||||
movingElement.duration -
|
||||
movingElement.trimStart -
|
||||
movingElement.trimEnd;
|
||||
const snappedTime = Math.round(dropTime * 10) / 10;
|
||||
const snappedTime = getDropSnappedTime(
|
||||
dropTime,
|
||||
movingElementDuration,
|
||||
elementId
|
||||
);
|
||||
const movingElementEnd = snappedTime + movingElementDuration;
|
||||
|
||||
wouldOverlap = track.elements.some((existingElement) => {
|
||||
@@ -428,13 +608,15 @@ export function TimelineTrackContent({
|
||||
if (wouldOverlap) {
|
||||
e.dataTransfer.dropEffect = "none";
|
||||
setWouldOverlap(true);
|
||||
setDropPosition(Math.round(dropTime * 10) / 10);
|
||||
// Use default duration for position indicator
|
||||
setDropPosition(getDropSnappedTime(dropTime, 5));
|
||||
return;
|
||||
}
|
||||
|
||||
e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
|
||||
setWouldOverlap(false);
|
||||
setDropPosition(Math.round(dropTime * 10) / 10);
|
||||
// Use default duration for position indicator
|
||||
setDropPosition(getDropSnappedTime(dropTime, 5));
|
||||
};
|
||||
|
||||
const handleTrackDragEnter = (e: React.DragEvent) => {
|
||||
@@ -478,6 +660,16 @@ export function TimelineTrackContent({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Debug logging
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
message: "Drop event started in timeline track",
|
||||
dataTransferTypes: Array.from(e.dataTransfer.types),
|
||||
trackId: track.id,
|
||||
trackType: track.type,
|
||||
})
|
||||
);
|
||||
|
||||
// Reset all drag states
|
||||
dragCounterRef.current = 0;
|
||||
setIsDropping(false);
|
||||
@@ -502,7 +694,9 @@ export function TimelineTrackContent({
|
||||
const mouseY = e.clientY - rect.top; // Get Y position relative to this track
|
||||
const newStartTime =
|
||||
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
|
||||
const snappedTime = Math.round(newStartTime * 10) / 10;
|
||||
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);
|
||||
@@ -544,18 +738,20 @@ export function TimelineTrackContent({
|
||||
return;
|
||||
}
|
||||
|
||||
// Adjust position based on where user clicked on the element
|
||||
const adjustedStartTime = snappedTime - clickOffsetTime;
|
||||
const finalStartTime = Math.max(
|
||||
0,
|
||||
Math.round(adjustedStartTime * 10) / 10
|
||||
);
|
||||
|
||||
// Check for overlaps with existing elements (excluding the moving element itself)
|
||||
const movingElementDuration =
|
||||
movingElement.duration -
|
||||
movingElement.trimStart -
|
||||
movingElement.trimEnd;
|
||||
|
||||
// Adjust position based on where user clicked on the element
|
||||
const adjustedStartTime = newStartTime - clickOffsetTime;
|
||||
const snappedStartTime = getDropSnappedTime(
|
||||
adjustedStartTime,
|
||||
movingElementDuration,
|
||||
elementId
|
||||
);
|
||||
const finalStartTime = Math.max(0, snappedStartTime);
|
||||
const movingElementEnd = finalStartTime + movingElementDuration;
|
||||
|
||||
const hasOverlap = track.elements.some((existingElement) => {
|
||||
@@ -585,12 +781,28 @@ export function TimelineTrackContent({
|
||||
|
||||
if (fromTrackId === track.id) {
|
||||
// Moving within same track
|
||||
updateElementStartTime(track.id, elementId, finalStartTime);
|
||||
if (rippleEditingEnabled) {
|
||||
updateElementStartTimeWithRipple(
|
||||
track.id,
|
||||
elementId,
|
||||
finalStartTime
|
||||
);
|
||||
} else {
|
||||
updateElementStartTime(track.id, elementId, finalStartTime);
|
||||
}
|
||||
} else {
|
||||
// Moving to different track
|
||||
moveElementToTrack(fromTrackId, track.id, elementId);
|
||||
requestAnimationFrame(() => {
|
||||
updateElementStartTime(track.id, elementId, finalStartTime);
|
||||
if (rippleEditingEnabled) {
|
||||
updateElementStartTimeWithRipple(
|
||||
track.id,
|
||||
elementId,
|
||||
finalStartTime
|
||||
);
|
||||
} else {
|
||||
updateElementStartTime(track.id, elementId, finalStartTime);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (hasMediaItem) {
|
||||
@@ -641,7 +853,11 @@ export function TimelineTrackContent({
|
||||
|
||||
// Check for overlaps with existing elements in target track
|
||||
const newElementDuration = 5; // Default text duration
|
||||
const newElementEnd = snappedTime + newElementDuration;
|
||||
const textSnappedTime = getDropSnappedTime(
|
||||
newStartTime,
|
||||
newElementDuration
|
||||
);
|
||||
const newElementEnd = textSnappedTime + newElementDuration;
|
||||
|
||||
const hasOverlap = targetTrack.elements.some((existingElement) => {
|
||||
const existingStart = existingElement.startTime;
|
||||
@@ -652,7 +868,9 @@ export function TimelineTrackContent({
|
||||
existingElement.trimEnd);
|
||||
|
||||
// Check if elements overlap
|
||||
return snappedTime < existingEnd && newElementEnd > existingStart;
|
||||
return (
|
||||
textSnappedTime < existingEnd && newElementEnd > existingStart
|
||||
);
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
@@ -667,7 +885,7 @@ export function TimelineTrackContent({
|
||||
name: dragData.name || "Text",
|
||||
content: dragData.content || "Default Text",
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||
startTime: snappedTime,
|
||||
startTime: textSnappedTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
fontSize: 48,
|
||||
@@ -708,68 +926,28 @@ export function TimelineTrackContent({
|
||||
|
||||
// Handle position-aware track creation for media elements
|
||||
if (!isCompatible || dropPosition !== "on") {
|
||||
const needsNewTrack = !isCompatible || dropPosition !== "on";
|
||||
if (isVideoOrImage) {
|
||||
// For video/image, check if we need a main track or additional media track
|
||||
const mainTrack = getMainTrack(tracks);
|
||||
|
||||
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);
|
||||
if (!mainTrack) {
|
||||
// No main track exists, create it
|
||||
targetTrackId = addTrack("media");
|
||||
const updatedTracks = useTimelineStore.getState().tracks;
|
||||
const newTargetTrack = updatedTracks.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
|
||||
let insertIndex: number;
|
||||
|
||||
if (dropPosition === "above") {
|
||||
@@ -777,18 +955,14 @@ export function TimelineTrackContent({
|
||||
} 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
|
||||
}
|
||||
// Insert above main track
|
||||
const mainTrackIndex = tracks.findIndex(
|
||||
(t) => t.id === mainTrack.id
|
||||
);
|
||||
insertIndex = mainTrackIndex;
|
||||
}
|
||||
|
||||
targetTrackId = insertTrackAt("audio", insertIndex);
|
||||
targetTrackId = insertTrackAt("media", insertIndex);
|
||||
const updatedTracks = useTimelineStore.getState().tracks;
|
||||
const newTargetTrack = updatedTracks.find(
|
||||
(t) => t.id === targetTrackId
|
||||
@@ -796,6 +970,34 @@ export function TimelineTrackContent({
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -803,7 +1005,11 @@ export function TimelineTrackContent({
|
||||
|
||||
// Check for overlaps with existing elements in target track
|
||||
const newElementDuration = mediaItem.duration || 5;
|
||||
const newElementEnd = snappedTime + newElementDuration;
|
||||
const mediaSnappedTime = getDropSnappedTime(
|
||||
newStartTime,
|
||||
newElementDuration
|
||||
);
|
||||
const newElementEnd = mediaSnappedTime + newElementDuration;
|
||||
|
||||
const hasOverlap = targetTrack.elements.some((existingElement) => {
|
||||
const existingStart = existingElement.startTime;
|
||||
@@ -814,7 +1020,9 @@ export function TimelineTrackContent({
|
||||
existingElement.trimEnd);
|
||||
|
||||
// Check if elements overlap
|
||||
return snappedTime < existingEnd && newElementEnd > existingStart;
|
||||
return (
|
||||
mediaSnappedTime < existingEnd && newElementEnd > existingStart
|
||||
);
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
@@ -829,7 +1037,7 @@ export function TimelineTrackContent({
|
||||
mediaId: mediaItem.id,
|
||||
name: mediaItem.name,
|
||||
duration: mediaItem.duration || 5,
|
||||
startTime: snappedTime,
|
||||
startTime: mediaSnappedTime,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
Video,
|
||||
Music,
|
||||
TypeIcon,
|
||||
Lock,
|
||||
LockOpen,
|
||||
Link,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -36,13 +39,6 @@ import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
|
||||
import { processMediaFiles } from "@/lib/media-processing";
|
||||
import { toast } from "sonner";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { TimelineTrackContent } from "./timeline-track";
|
||||
import {
|
||||
TimelinePlayhead,
|
||||
@@ -50,23 +46,28 @@ import {
|
||||
} from "./timeline-playhead";
|
||||
import { SelectionBox } from "./selection-box";
|
||||
import { useSelectionBox } from "@/hooks/use-selection-box";
|
||||
import { SnapIndicator } from "./snap-indicator";
|
||||
import { SnapPoint } from "@/hooks/use-timeline-snapping";
|
||||
import type { DragData, TimelineTrack } from "@/types/timeline";
|
||||
import {
|
||||
getTrackHeight,
|
||||
getCumulativeHeightBefore,
|
||||
getTotalTracksHeight,
|
||||
TIMELINE_CONSTANTS,
|
||||
snapTimeToFrame,
|
||||
} from "@/constants/timeline-constants";
|
||||
|
||||
export function Timeline() {
|
||||
// Timeline shows all tracks (video, audio, effects) and their elements.
|
||||
// You can drag media here to add it to your project.
|
||||
// elements can be trimmed, deleted, and moved.
|
||||
|
||||
const {
|
||||
tracks,
|
||||
addTrack,
|
||||
addElementToTrack,
|
||||
removeElementFromTrack,
|
||||
removeElementFromTrackWithRipple,
|
||||
getTotalDuration,
|
||||
selectedElements,
|
||||
clearSelectedElements,
|
||||
@@ -76,21 +77,16 @@ export function Timeline() {
|
||||
splitAndKeepRight,
|
||||
toggleTrackMute,
|
||||
separateAudio,
|
||||
undo,
|
||||
redo,
|
||||
snappingEnabled,
|
||||
toggleSnapping,
|
||||
rippleEditingEnabled,
|
||||
toggleRippleEditing,
|
||||
dragState,
|
||||
} = useTimelineStore();
|
||||
const { mediaItems, addMediaItem } = useMediaStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
const {
|
||||
currentTime,
|
||||
duration,
|
||||
seek,
|
||||
setDuration,
|
||||
isPlaying,
|
||||
toggle,
|
||||
setSpeed,
|
||||
speed,
|
||||
} = usePlaybackStore();
|
||||
const { currentTime, duration, seek, setDuration, isPlaying, toggle } =
|
||||
usePlaybackStore();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
@@ -99,6 +95,14 @@ export function Timeline() {
|
||||
const rulerRef = useRef<HTMLDivElement>(null);
|
||||
const [isInTimeline, setIsInTimeline] = useState(false);
|
||||
|
||||
// Track mouse down/up for distinguishing clicks from drag/resize ends
|
||||
const mouseTrackingRef = useRef({
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
});
|
||||
|
||||
// Timeline zoom functionality
|
||||
const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({
|
||||
containerRef: timelineRef,
|
||||
@@ -119,12 +123,14 @@ export function Timeline() {
|
||||
const tracksScrollRef = useRef<HTMLDivElement>(null);
|
||||
const trackLabelsRef = useRef<HTMLDivElement>(null);
|
||||
const playheadRef = useRef<HTMLDivElement>(null);
|
||||
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
|
||||
const isUpdatingRef = useRef(false);
|
||||
const lastRulerSync = useRef(0);
|
||||
const lastTracksSync = useRef(0);
|
||||
const lastVerticalSync = useRef(0);
|
||||
|
||||
// Timeline playhead ruler handlers
|
||||
const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayheadRuler({
|
||||
const { handleRulerMouseDown } = useTimelinePlayheadRuler({
|
||||
currentTime,
|
||||
duration,
|
||||
zoomLevel,
|
||||
@@ -151,18 +157,78 @@ export function Timeline() {
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate snap indicator state
|
||||
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
|
||||
null
|
||||
);
|
||||
const showSnapIndicator =
|
||||
dragState.isDragging && snappingEnabled && currentSnapPoint !== null;
|
||||
|
||||
// Callback to handle snap point changes from TimelineTrackContent
|
||||
const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
|
||||
setCurrentSnapPoint(snapPoint);
|
||||
}, []);
|
||||
|
||||
// Track mouse down to distinguish real clicks from drag/resize ends
|
||||
const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
// Only track mouse down on timeline background areas (not elements)
|
||||
const target = e.target as HTMLElement;
|
||||
const isTimelineBackground =
|
||||
!target.closest(".timeline-element") &&
|
||||
!playheadRef.current?.contains(target) &&
|
||||
!target.closest("[data-track-labels]");
|
||||
|
||||
if (isTimelineBackground) {
|
||||
mouseTrackingRef.current = {
|
||||
isMouseDown: true,
|
||||
downX: e.clientX,
|
||||
downY: e.clientY,
|
||||
downTime: e.timeStamp,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Timeline content click to seek handler
|
||||
const handleTimelineContentClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
timelineClick: {
|
||||
isSelecting,
|
||||
justFinishedSelecting,
|
||||
willReturn: isSelecting || justFinishedSelecting,
|
||||
},
|
||||
})
|
||||
);
|
||||
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
|
||||
|
||||
// Reset mouse tracking
|
||||
mouseTrackingRef.current = {
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
};
|
||||
|
||||
// Only process as click if we tracked a mouse down on timeline background
|
||||
if (!isMouseDown) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ignoredClickWithoutMouseDown: true,
|
||||
timeStamp: e.timeStamp,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if mouse moved significantly (indicates drag, not click)
|
||||
const deltaX = Math.abs(e.clientX - downX);
|
||||
const deltaY = Math.abs(e.clientY - downY);
|
||||
const deltaTime = e.timeStamp - downTime;
|
||||
|
||||
if (deltaX > 5 || deltaY > 5 || deltaTime > 500) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ignoredDragNotClick: true,
|
||||
deltaX,
|
||||
deltaY,
|
||||
deltaTime,
|
||||
timeStamp: e.timeStamp,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't seek if this was a selection box operation
|
||||
if (isSelecting || justFinishedSelecting) {
|
||||
@@ -217,7 +283,7 @@ export function Timeline() {
|
||||
scrollLeft = tracksContent.scrollLeft;
|
||||
}
|
||||
|
||||
const time = Math.max(
|
||||
const rawTime = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
duration,
|
||||
@@ -226,6 +292,10 @@ export function Timeline() {
|
||||
)
|
||||
);
|
||||
|
||||
// Use frame snapping for timeline clicking
|
||||
const projectFps = activeProject?.fps || 30;
|
||||
const time = snapTimeToFrame(rawTime, projectFps);
|
||||
|
||||
seek(time);
|
||||
},
|
||||
[
|
||||
@@ -246,71 +316,6 @@ export function Timeline() {
|
||||
setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline
|
||||
}, [tracks, setDuration, getTotalDuration]);
|
||||
|
||||
// Keyboard event for deleting selected elements
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't trigger when typing in input fields or textareas
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only trigger when timeline is focused or mouse is over timeline
|
||||
if (
|
||||
!isInTimeline &&
|
||||
!timelineRef.current?.contains(document.activeElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(e.key === "Delete" || e.key === "Backspace") &&
|
||||
selectedElements.length > 0
|
||||
) {
|
||||
selectedElements.forEach(({ trackId, elementId }) => {
|
||||
removeElementFromTrack(trackId, elementId);
|
||||
});
|
||||
clearSelectedElements();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [
|
||||
selectedElements,
|
||||
removeElementFromTrack,
|
||||
clearSelectedElements,
|
||||
isInTimeline,
|
||||
]);
|
||||
|
||||
// Keyboard event for undo (Cmd+Z)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
undo();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [undo]);
|
||||
|
||||
// Keyboard event for redo (Cmd+Shift+Z or Cmd+Y)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
} else if ((e.metaKey || e.ctrlKey) && e.key === "y") {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [redo]);
|
||||
|
||||
// Old marquee system removed - using new SelectionBox component instead
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
@@ -365,50 +370,18 @@ export function Timeline() {
|
||||
|
||||
if (dragData.type === "text") {
|
||||
// Always create new text track to avoid overlaps
|
||||
const newTrackId = addTrack("text");
|
||||
|
||||
addElementToTrack(newTrackId, {
|
||||
type: "text",
|
||||
name: dragData.name || "Text",
|
||||
content: dragData.content || "Default Text",
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||
startTime: 0,
|
||||
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,
|
||||
});
|
||||
useTimelineStore.getState().addTextToNewTrack(dragData);
|
||||
} else {
|
||||
// Handle media items
|
||||
const mediaItem = mediaItems.find((item) => item.id === dragData.id);
|
||||
const mediaItem = mediaItems.find(
|
||||
(item: any) => item.id === dragData.id
|
||||
);
|
||||
if (!mediaItem) {
|
||||
toast.error("Media item not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const trackType = dragData.type === "audio" ? "audio" : "media";
|
||||
let targetTrack = tracks.find((t) => t.type === trackType);
|
||||
const newTrackId = targetTrack ? targetTrack.id : addTrack(trackType);
|
||||
|
||||
addElementToTrack(newTrackId, {
|
||||
type: "media",
|
||||
mediaId: mediaItem.id,
|
||||
name: mediaItem.name,
|
||||
duration: mediaItem.duration || 5,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
useTimelineStore.getState().addMediaToNewTrack(mediaItem);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing dropped item data:", error);
|
||||
@@ -436,18 +409,7 @@ export function Timeline() {
|
||||
item.name === processedItem.name && item.url === processedItem.url
|
||||
);
|
||||
if (addedItem) {
|
||||
const trackType =
|
||||
processedItem.type === "audio" ? "audio" : "media";
|
||||
const newTrackId = addTrack(trackType);
|
||||
addElementToTrack(newTrackId, {
|
||||
type: "media",
|
||||
mediaId: addedItem.id,
|
||||
name: addedItem.name,
|
||||
duration: addedItem.duration || 5,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
});
|
||||
useTimelineStore.getState().addMediaToNewTrack(addedItem);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -471,7 +433,6 @@ export function Timeline() {
|
||||
// Action handlers for toolbar
|
||||
const handleSplitSelected = () => {
|
||||
if (selectedElements.length === 0) {
|
||||
toast.error("No elements selected");
|
||||
return;
|
||||
}
|
||||
let splitCount = 0;
|
||||
@@ -497,7 +458,6 @@ export function Timeline() {
|
||||
|
||||
const handleDuplicateSelected = () => {
|
||||
if (selectedElements.length === 0) {
|
||||
toast.error("No elements selected");
|
||||
return;
|
||||
}
|
||||
const canDuplicate = selectedElements.length === 1;
|
||||
@@ -591,11 +551,14 @@ export function Timeline() {
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
if (selectedElements.length === 0) {
|
||||
toast.error("No elements selected");
|
||||
return;
|
||||
}
|
||||
selectedElements.forEach(({ trackId, elementId }) => {
|
||||
removeElementFromTrack(trackId, elementId);
|
||||
if (rippleEditingEnabled) {
|
||||
removeElementFromTrackWithRipple(trackId, elementId);
|
||||
} else {
|
||||
removeElementFromTrack(trackId, elementId);
|
||||
}
|
||||
});
|
||||
clearSelectedElements();
|
||||
};
|
||||
@@ -608,7 +571,13 @@ export function Timeline() {
|
||||
const tracksViewport = tracksScrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement;
|
||||
const trackLabelsViewport = trackLabelsScrollRef.current?.querySelector(
|
||||
"[data-radix-scroll-area-viewport]"
|
||||
) as HTMLElement;
|
||||
|
||||
if (!rulerViewport || !tracksViewport) return;
|
||||
|
||||
// Horizontal scroll synchronization between ruler and tracks
|
||||
const handleRulerScroll = () => {
|
||||
const now = Date.now();
|
||||
if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
|
||||
@@ -625,8 +594,48 @@ export function Timeline() {
|
||||
rulerViewport.scrollLeft = tracksViewport.scrollLeft;
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
|
||||
rulerViewport.addEventListener("scroll", handleRulerScroll);
|
||||
tracksViewport.addEventListener("scroll", handleTracksScroll);
|
||||
|
||||
// Vertical scroll synchronization between track labels and tracks content
|
||||
if (trackLabelsViewport) {
|
||||
const handleTrackLabelsScroll = () => {
|
||||
const now = Date.now();
|
||||
if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
|
||||
return;
|
||||
lastVerticalSync.current = now;
|
||||
isUpdatingRef.current = true;
|
||||
tracksViewport.scrollTop = trackLabelsViewport.scrollTop;
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
const handleTracksVerticalScroll = () => {
|
||||
const now = Date.now();
|
||||
if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
|
||||
return;
|
||||
lastVerticalSync.current = now;
|
||||
isUpdatingRef.current = true;
|
||||
trackLabelsViewport.scrollTop = tracksViewport.scrollTop;
|
||||
isUpdatingRef.current = false;
|
||||
};
|
||||
|
||||
trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
|
||||
tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
|
||||
|
||||
return () => {
|
||||
rulerViewport.removeEventListener("scroll", handleRulerScroll);
|
||||
tracksViewport.removeEventListener("scroll", handleTracksScroll);
|
||||
trackLabelsViewport.removeEventListener(
|
||||
"scroll",
|
||||
handleTrackLabelsScroll
|
||||
);
|
||||
tracksViewport.removeEventListener(
|
||||
"scroll",
|
||||
handleTracksVerticalScroll
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
rulerViewport.removeEventListener("scroll", handleRulerScroll);
|
||||
tracksViewport.removeEventListener("scroll", handleTracksScroll);
|
||||
@@ -641,157 +650,190 @@ export function Timeline() {
|
||||
onMouseLeave={() => setIsInTimeline(false)}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<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");
|
||||
addElementToTrack(trackId, {
|
||||
type: "media",
|
||||
mediaId: "test",
|
||||
name: "Test Clip",
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||
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 element (Ctrl+S)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleSplitAndKeepLeft}
|
||||
>
|
||||
<ArrowLeftToLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleSplitAndKeepRight}
|
||||
>
|
||||
<ArrowRightToLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={handleSeparateAudio}>
|
||||
<SplitSquareHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleDuplicateSelected}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Duplicate element (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 element (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 className="border-b flex items-center justify-between px-2 py-1">
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<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");
|
||||
addElementToTrack(trackId, {
|
||||
type: "media",
|
||||
mediaId: "test",
|
||||
name: "Test Clip",
|
||||
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
|
||||
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 element (Ctrl+S)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleSplitAndKeepLeft}
|
||||
>
|
||||
<ArrowLeftToLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleSplitAndKeepRight}
|
||||
>
|
||||
<ArrowRightToLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleSeparateAudio}
|
||||
>
|
||||
<SplitSquareHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={handleDuplicateSelected}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Duplicate element (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 element (Delete)</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="text" size="icon" onClick={toggleSnapping}>
|
||||
{snappingEnabled ? (
|
||||
<LockOpen className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Auto snapping</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={toggleRippleEditing}
|
||||
>
|
||||
<Link
|
||||
className={`h-4 w-4 ${rippleEditingEnabled ? "text-primary" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{rippleEditingEnabled
|
||||
? "Disable Ripple Editing"
|
||||
: "Enable Ripple Editing"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline Container */}
|
||||
@@ -811,6 +853,17 @@ export function Timeline() {
|
||||
trackLabelsRef={trackLabelsRef}
|
||||
timelineRef={timelineRef}
|
||||
playheadRef={playheadRef}
|
||||
isSnappingToPlayhead={
|
||||
showSnapIndicator && currentSnapPoint?.type === "playhead"
|
||||
}
|
||||
/>
|
||||
<SnapIndicator
|
||||
snapPoint={currentSnapPoint}
|
||||
zoomLevel={zoomLevel}
|
||||
tracks={tracks}
|
||||
timelineRef={timelineRef}
|
||||
trackLabelsRef={trackLabelsRef}
|
||||
isVisible={showSnapIndicator}
|
||||
/>
|
||||
{/* Timeline Header with Ruler */}
|
||||
<div className="flex bg-panel sticky top-0 z-10">
|
||||
@@ -917,27 +970,29 @@ export function Timeline() {
|
||||
{tracks.length > 0 && (
|
||||
<div
|
||||
ref={trackLabelsRef}
|
||||
className="w-48 flex-shrink-0 border-r bg-panel-accent overflow-y-auto"
|
||||
className="w-48 flex-shrink-0 border-r border-black overflow-y-auto"
|
||||
data-track-labels
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{tracks.map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5"
|
||||
style={{ height: `${getTrackHeight(track.type)}px` }}
|
||||
>
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<TrackIcon track={track} />
|
||||
<ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}>
|
||||
<div className="flex flex-col gap-1">
|
||||
{tracks.map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5"
|
||||
style={{ height: `${getTrackHeight(track.type)}px` }}
|
||||
>
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
<TrackIcon track={track} />
|
||||
</div>
|
||||
{track.muted && (
|
||||
<span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0">
|
||||
Muted
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{track.muted && (
|
||||
<span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0">
|
||||
Muted
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -945,7 +1000,10 @@ export function Timeline() {
|
||||
<div
|
||||
className="flex-1 relative overflow-hidden"
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleSelectionMouseDown}
|
||||
onMouseDown={(e) => {
|
||||
handleTimelineMouseDown(e);
|
||||
handleSelectionMouseDown(e);
|
||||
}}
|
||||
onClick={handleTimelineContentClick}
|
||||
ref={tracksContainerRef}
|
||||
>
|
||||
@@ -990,6 +1048,7 @@ export function Timeline() {
|
||||
<TimelineTrackContent
|
||||
track={track}
|
||||
zoomLevel={zoomLevel}
|
||||
onSnapPointChange={handleSnapPointChange}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RiGithubLine, RiTwitterXLine } from "react-icons/ri";
|
||||
import { RiDiscordFill, RiTwitterXLine } from "react-icons/ri";
|
||||
import { FaGithub } from "react-icons/fa6";
|
||||
import { getStars } from "@/lib/fetch-github-stars";
|
||||
import Image from "next/image";
|
||||
|
||||
@@ -34,22 +35,22 @@ export function Footer() {
|
||||
<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">
|
||||
<div className="flex justify-start 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">
|
||||
<p className="text-sm md:text-left 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">
|
||||
<div className="flex justify-start 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" />
|
||||
<FaGithub className="h-5 w-5" />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://x.com/OpenCutApp"
|
||||
@@ -59,13 +60,29 @@ export function Footer() {
|
||||
>
|
||||
<RiTwitterXLine className="h-5 w-5" />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://discord.com/invite/Mu3acKZvCp"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<RiDiscordFill className="h-5 w-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-12 justify-end items-start py-2">
|
||||
<div className="flex gap-12 justify-start 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="/roadmap"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Roadmap
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/privacy"
|
||||
@@ -113,7 +130,7 @@ export function Footer() {
|
||||
</div>
|
||||
|
||||
{/* Bottom Section */}
|
||||
<div className="pt-2 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div className="pt-2 flex flex-col md:flex-row justify-between items-start gap-4">
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>© 2025 OpenCut, All Rights Reserved</span>
|
||||
</div>
|
||||
|
||||
@@ -4,28 +4,9 @@ import Link from "next/link";
|
||||
import { Button } from "./ui/button";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { HeaderBase } from "./header-base";
|
||||
import { useSession } from "@opencut/auth/client";
|
||||
import { getStars } from "@/lib/fetch-github-stars";
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
export function Header() {
|
||||
const { data: session } = useSession();
|
||||
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();
|
||||
}, []);
|
||||
|
||||
const leftContent = (
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
|
||||
@@ -35,26 +16,22 @@ export function Header() {
|
||||
|
||||
const rightContent = (
|
||||
<nav className="flex items-center gap-3">
|
||||
<Link href="/blog">
|
||||
<Button variant="text" className="text-sm p-0">
|
||||
Blog
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/contributors">
|
||||
<Button variant="text" className="text-sm p-0">
|
||||
Contributors
|
||||
</Button>
|
||||
</Link>
|
||||
{process.env.NODE_ENV === "development" ? (
|
||||
<Link href="/projects">
|
||||
<Button size="sm" className="text-sm ml-4">
|
||||
Projects
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
|
||||
<Button size="sm" className="text-sm ml-4">
|
||||
GitHub {star}+
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/projects">
|
||||
<Button size="sm" className="text-sm ml-4">
|
||||
Projects
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</nav>
|
||||
);
|
||||
|
||||
|
||||
@@ -38,6 +38,14 @@ export function GithubIcon({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function VercelIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} width="20" height="18" viewBox="0 0 76 65" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackgroundIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "./ui/dialog";
|
||||
import { getPlatformSpecialKey } from "@/lib/utils";
|
||||
import { Keyboard } from "lucide-react";
|
||||
import {
|
||||
useKeyboardShortcutsHelp,
|
||||
KeyboardShortcut,
|
||||
} from "@/hooks/use-keyboard-shortcuts-help";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const modifier: {
|
||||
[key: string]: string;
|
||||
} = {
|
||||
Shift: "Shift",
|
||||
Alt: "Alt",
|
||||
ArrowLeft: "←",
|
||||
ArrowRight: "→",
|
||||
ArrowUp: "↑",
|
||||
ArrowDown: "↓",
|
||||
Space: "Space",
|
||||
};
|
||||
|
||||
function getKeyWithModifier(key: string) {
|
||||
if (key === "Ctrl") return getPlatformSpecialKey();
|
||||
return modifier[key] || key;
|
||||
}
|
||||
|
||||
const ShortcutItem = ({
|
||||
shortcut,
|
||||
recordingKey,
|
||||
onStartRecording,
|
||||
}: {
|
||||
shortcut: KeyboardShortcut;
|
||||
recordingKey: string | null;
|
||||
onStartRecording: (keyId: string, shortcut: KeyboardShortcut) => void;
|
||||
}) => {
|
||||
// Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J"
|
||||
const displayKeys = shortcut.keys.filter((key: string) => {
|
||||
if (
|
||||
key.includes("Cmd") &&
|
||||
shortcut.keys.includes(key.replace("Cmd", "Ctrl"))
|
||||
)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{shortcut.icon && (
|
||||
<div className="text-muted-foreground">{shortcut.icon}</div>
|
||||
)}
|
||||
<span className="text-sm">{shortcut.description}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{displayKeys.map((key: string, index: number) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
<div className="flex items-center">
|
||||
{key.split("+").map((keyPart: string, partIndex: number) => {
|
||||
const keyId = `${shortcut.id}-${index}-${partIndex}`;
|
||||
return (
|
||||
<EditableShortcutKey
|
||||
key={partIndex}
|
||||
keyId={keyId}
|
||||
originalKey={key}
|
||||
shortcut={shortcut}
|
||||
isRecording={recordingKey === keyId}
|
||||
onStartRecording={() => onStartRecording(keyId, shortcut)}
|
||||
>
|
||||
{getKeyWithModifier(keyPart)}
|
||||
</EditableShortcutKey>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{index < displayKeys.length - 1 && (
|
||||
<span className="text-xs text-muted-foreground">or</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EditableShortcutKey = ({
|
||||
children,
|
||||
keyId,
|
||||
originalKey,
|
||||
shortcut,
|
||||
isRecording,
|
||||
onStartRecording,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
keyId: string;
|
||||
originalKey: string;
|
||||
shortcut: KeyboardShortcut;
|
||||
isRecording: boolean;
|
||||
onStartRecording: () => void;
|
||||
}) => {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onStartRecording();
|
||||
};
|
||||
|
||||
return (
|
||||
<kbd
|
||||
className={`inline-flex font-sans text-xs rounded px-2 min-w-[1.5rem] min-h-[1.5rem] leading-none items-center justify-center shadow-sm border mr-1 cursor-pointer hover:bg-opacity-80 ${
|
||||
isRecording
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-white/10 bg-black/20"
|
||||
}`}
|
||||
onClick={handleClick}
|
||||
title={
|
||||
isRecording ? "Press any key combination..." : "Click to edit shortcut"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
};
|
||||
|
||||
export const KeyboardShortcutsHelp = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [recordingKey, setRecordingKey] = useState<string | null>(null);
|
||||
const [recordingShortcut, setRecordingShortcut] =
|
||||
useState<KeyboardShortcut | null>(null);
|
||||
|
||||
const {
|
||||
updateKeybinding,
|
||||
removeKeybinding,
|
||||
getKeybindingString,
|
||||
validateKeybinding,
|
||||
getKeybindingsForAction,
|
||||
} = useKeybindingsStore();
|
||||
|
||||
// Get shortcuts from centralized hook
|
||||
const { shortcuts } = useKeyboardShortcutsHelp();
|
||||
|
||||
const categories = Array.from(new Set(shortcuts.map((s) => s.category)));
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordingKey || !recordingShortcut) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const keyString = getKeybindingString(e);
|
||||
if (keyString) {
|
||||
// Auto-save the new keybinding
|
||||
const conflict = validateKeybinding(
|
||||
keyString,
|
||||
recordingShortcut.action
|
||||
);
|
||||
if (conflict) {
|
||||
toast.error(
|
||||
`Key "${keyString}" is already bound to "${conflict.existingAction}"`
|
||||
);
|
||||
setRecordingKey(null);
|
||||
setRecordingShortcut(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove old keybindings for this action
|
||||
const oldKeys = getKeybindingsForAction(recordingShortcut.action);
|
||||
oldKeys.forEach((key) => removeKeybinding(key));
|
||||
|
||||
// Add new keybinding
|
||||
updateKeybinding(keyString, recordingShortcut.action);
|
||||
|
||||
setRecordingKey(null);
|
||||
setRecordingShortcut(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
setRecordingKey(null);
|
||||
setRecordingShortcut(null);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
};
|
||||
}, [
|
||||
recordingKey,
|
||||
recordingShortcut,
|
||||
getKeybindingString,
|
||||
updateKeybinding,
|
||||
removeKeybinding,
|
||||
validateKeybinding,
|
||||
getKeybindingsForAction,
|
||||
]);
|
||||
|
||||
const handleStartRecording = (keyId: string, shortcut: KeyboardShortcut) => {
|
||||
setRecordingKey(keyId);
|
||||
setRecordingShortcut(shortcut);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="text" size="sm" className="gap-2">
|
||||
<Keyboard className="w-4 h-4" />
|
||||
Shortcuts
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Keyboard className="w-5 h-5" />
|
||||
Keyboard Shortcuts
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Speed up your video editing workflow with these keyboard shortcuts.
|
||||
Click any shortcut key to edit it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{categories.map((category) => (
|
||||
<div key={category} className="flex flex-col gap-1">
|
||||
<h3 className="text-xs text-muted-foreground uppercase tracking-wide font-medium">
|
||||
{category}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{shortcuts
|
||||
.filter((shortcut) => shortcut.category === category)
|
||||
.map((shortcut, index) => (
|
||||
<ShortcutItem
|
||||
key={index}
|
||||
shortcut={shortcut}
|
||||
recordingKey={recordingKey}
|
||||
onStartRecording={handleStartRecording}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -2,73 +2,15 @@
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { SponsorButton } from "../ui/sponsor-button";
|
||||
import { VercelIcon } from "../icons";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Handlebars } from "./handlebars";
|
||||
import Link from "next/link";
|
||||
|
||||
interface HeroProps {
|
||||
signupCount: number;
|
||||
}
|
||||
|
||||
export function Hero({ signupCount }: HeroProps) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email.trim()) {
|
||||
toast({
|
||||
title: "Email required",
|
||||
description: "Please enter your email address.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/waitlist", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email: email.trim() }),
|
||||
});
|
||||
|
||||
const data = (await response.json()) as { error: string };
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: "Welcome to the waitlist! 🎉",
|
||||
description: "You'll be notified when we launch.",
|
||||
});
|
||||
setEmail("");
|
||||
} else {
|
||||
toast({
|
||||
title: "Oops!",
|
||||
description:
|
||||
(data as { error: string }).error ||
|
||||
"Something went wrong. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Network error",
|
||||
description: "Please check your connection and try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4">
|
||||
<Image
|
||||
@@ -84,6 +26,18 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
transition={{ duration: 1 }}
|
||||
className="max-w-3xl mx-auto w-full flex-1 flex flex-col justify-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6, duration: 0.8 }}
|
||||
className="mb-8 flex justify-center"
|
||||
>
|
||||
<SponsorButton
|
||||
href="https://vercel.com/home?utm_source=opencut"
|
||||
logo={VercelIcon}
|
||||
companyName="Vercel"
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -91,14 +45,7 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
|
||||
>
|
||||
<h1>The Open Source</h1>
|
||||
<div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
|
||||
<div className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2">
|
||||
<Image src="/frame.svg" height={79} width={459} alt="frame" />
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
Video Editor
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Handlebars>Video Editor</Handlebars>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
@@ -112,51 +59,22 @@ export function Hero({ signupCount }: HeroProps) {
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
className="mt-12 flex gap-8 justify-center"
|
||||
className="mt-8 flex gap-8 justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6, duration: 0.8 }}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"
|
||||
>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
className="h-11 text-base flex-1"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Link href="/projects">
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="px-6 h-11 text-base !bg-foreground"
|
||||
disabled={isSubmitting}
|
||||
className="px-6 h-11 text-base bg-foreground"
|
||||
>
|
||||
<span className="relative z-10">
|
||||
{isSubmitting ? "Joining..." : "Join waitlist"}
|
||||
</span>
|
||||
Try early alpha
|
||||
<ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" />
|
||||
</Button>
|
||||
</form>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{signupCount > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8, duration: 0.6 }}
|
||||
className="mt-8 inline-flex items-center gap-2 text-sm text-muted-foreground justify-center"
|
||||
>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
<span>{signupCount.toLocaleString()} people already joined</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { Dialog, DialogContent } from "./ui/dialog";
|
||||
import { Button } from "./ui/button";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
export function Onboarding() {
|
||||
const [step, setStep] = useState(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding");
|
||||
if (!hasSeenOnboarding) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNext = () => {
|
||||
setStep(step + 1);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
localStorage.setItem("hasSeenOnboarding", "true");
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-3">
|
||||
<Title title="Welcome to OpenCut alpha! 🎉" />
|
||||
<Description description="You're among the first to try OpenCut - the fully open source CapCut alternative." />
|
||||
</div>
|
||||
<NextButton onClick={handleNext}>Next</NextButton>
|
||||
</div>
|
||||
);
|
||||
case 1:
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-3">
|
||||
<Title title="⚠️ This is a super early alpha!" />
|
||||
<Description description="OpenCut started just one month ago. There's still a ton of things to do to make this editor amazing." />
|
||||
<Description description="If you're curious, check out our roadmap [here](https://opencut.app/roadmap)" />
|
||||
</div>
|
||||
<NextButton onClick={handleNext}>Next</NextButton>
|
||||
</div>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-3">
|
||||
<Title title="🦋 Have fun testing!" />
|
||||
<Description description="Join our [Discord](https://discord.gg/zmR9N35cjK), chat with cool people and share feedback to help make OpenCut the best editor ever." />
|
||||
</div>
|
||||
<NextButton onClick={handleClose}>Finish</NextButton>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[425px] !outline-none">
|
||||
{renderStepContent()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Title({ title }: { title: string }) {
|
||||
return <h2 className="text-lg md:text-xl font-bold">{title}</h2>;
|
||||
}
|
||||
|
||||
function Subtitle({ subtitle }: { subtitle: string }) {
|
||||
return <h3 className="text-lg font-medium">{subtitle}</h3>;
|
||||
}
|
||||
|
||||
function Description({ description }: { description: string }) {
|
||||
return (
|
||||
<div className="text-muted-foreground">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
p: ({ children }) => <p className="mb-0">{children}</p>,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:text-foreground/80 underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NextButton({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button onClick={onClick} variant="default" className="w-full">
|
||||
{children}
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"fixed inset-0 z-[100] bg-black/20 backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -39,15 +39,23 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-[150] grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||
className
|
||||
)}
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<ScrollArea className="max-h-[85vh]">
|
||||
<div className="p-6 space-y-4">{children}</div>
|
||||
</ScrollArea>
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Upload } from "lucide-react";
|
||||
|
||||
interface DragOverlayProps {
|
||||
isVisible: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function DragOverlay({
|
||||
isVisible,
|
||||
title = "Drop files here",
|
||||
description = "Images, videos, and audio files",
|
||||
}: DragOverlayProps) {
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 bg-accent/20 backdrop-blur-lg border-2 border-dashed border-accent flex items-center justify-center z-10 pointer-events-none">
|
||||
<div className="text-center">
|
||||
<Upload className="h-8 w-8 text-accent mx-auto mb-2" />
|
||||
<p className="text-sm font-medium text-accent">{title}</p>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { ReactNode, useState, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Plus } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
|
||||
export interface DraggableMediaItemProps {
|
||||
name: string;
|
||||
preview: ReactNode;
|
||||
dragData: Record<string, any>;
|
||||
onDragStart?: (e: React.DragEvent) => void;
|
||||
onAddToTimeline?: (currentTime: number) => void;
|
||||
aspectRatio?: number;
|
||||
className?: string;
|
||||
showPlusOnDrag?: boolean;
|
||||
@@ -24,6 +31,7 @@ export function DraggableMediaItem({
|
||||
preview,
|
||||
dragData,
|
||||
onDragStart,
|
||||
onAddToTimeline,
|
||||
aspectRatio = 16 / 9,
|
||||
className = "",
|
||||
showPlusOnDrag = true,
|
||||
@@ -33,6 +41,11 @@ export function DraggableMediaItem({
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
const currentTime = usePlaybackStore((state) => state.currentTime);
|
||||
|
||||
const handleAddToTimeline = () => {
|
||||
onAddToTimeline?.(currentTime);
|
||||
};
|
||||
|
||||
const emptyImg = new window.Image();
|
||||
emptyImg.src =
|
||||
@@ -92,7 +105,10 @@ export function DraggableMediaItem({
|
||||
>
|
||||
{preview}
|
||||
{!isDragging && (
|
||||
<PlusButton className="opacity-0 group-hover:opacity-100" />
|
||||
<PlusButton
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
onClick={handleAddToTimeline}
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
{showLabel && (
|
||||
@@ -128,7 +144,12 @@ export function DraggableMediaItem({
|
||||
<div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none">
|
||||
{preview}
|
||||
</div>
|
||||
{showPlusOnDrag && <PlusButton />}
|
||||
{showPlusOnDrag && (
|
||||
<PlusButton
|
||||
onClick={handleAddToTimeline}
|
||||
tooltipText="Add to timeline or drag to position"
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -138,13 +159,40 @@ export function DraggableMediaItem({
|
||||
);
|
||||
}
|
||||
|
||||
function PlusButton({ className }: { className?: string }) {
|
||||
return (
|
||||
function PlusButton({
|
||||
className,
|
||||
onClick,
|
||||
tooltipText,
|
||||
}: {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
tooltipText?: string;
|
||||
}) {
|
||||
const button = (
|
||||
<Button
|
||||
size="icon"
|
||||
className={cn("absolute bottom-2 right-2 size-4", className)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
}}
|
||||
title={tooltipText}
|
||||
>
|
||||
<Plus className="!size-3" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltipText) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type React from "react";
|
||||
|
||||
type ProseProps = React.HTMLAttributes<HTMLElement> & {
|
||||
as?: "article";
|
||||
html: string;
|
||||
};
|
||||
|
||||
function Prose({ children, html, className }: ProseProps) {
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"prose prose-h2:font-semibold max-w-none prose-h1:text-xl prose-a:text-blue-600 prose-p:text-justify dark:prose-invert mx-auto",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : children}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default Prose;
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { GripVertical } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
@@ -29,7 +28,7 @@ const ResizableHandle = ({
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative w-0 bg-transparent cursor-col-resize data-[panel-group-direction=vertical]:h-0 data-[panel-group-direction=vertical]:cursor-row-resize",
|
||||
"relative flex w-px items-center justify-center bg-transparent after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import * as React from "react";
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui";
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
@@ -25,7 +25,7 @@ const Separator = React.forwardRef<
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator };
|
||||
export { Separator }
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { motion } from "motion/react";
|
||||
import { ComponentType } from "react";
|
||||
|
||||
interface SponsorButtonProps {
|
||||
href: string;
|
||||
logo: ComponentType<{ className?: string }>;
|
||||
companyName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SponsorButton({
|
||||
href,
|
||||
logo: Logo,
|
||||
companyName,
|
||||
className = "",
|
||||
}: SponsorButtonProps) {
|
||||
|
||||
return (
|
||||
<motion.a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-2 px-3 py-2 rounded-full border border-white/10 bg-white/5 backdrop-blur-sm hover:bg-white/10 hover:border-white/20 transition-all duration-200 group shadow-lg ${className}`}
|
||||
>
|
||||
<span className="text-xs font-medium text-zinc-400 group-hover:text-zinc-300 transition-colors">
|
||||
Sponsored by
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="text-zinc-100 group-hover:text-white transition-colors">
|
||||
<Logo className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<span className="text-xs font-medium text-zinc-100 group-hover:text-white transition-colors">
|
||||
{companyName}
|
||||
</span>
|
||||
</div>
|
||||
</motion.a>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ const TooltipContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 overflow-hidden rounded-md bg-foreground px-3 py-1.5 text-xs text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
/* An `action` is a unique verb that is associated with certain thing that can be done on OpenCut.
|
||||
* For example, toggling playback or seeking.
|
||||
*/
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
MutableRefObject,
|
||||
} from "react";
|
||||
|
||||
// Simple event emitter for action changes
|
||||
class ActionEmitter {
|
||||
private listeners: Array<(actions: Action[]) => void> = [];
|
||||
|
||||
subscribe(listener: (actions: Action[]) => void) {
|
||||
this.listeners.push(listener);
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter((l) => l !== listener);
|
||||
};
|
||||
}
|
||||
|
||||
emit(actions: Action[]) {
|
||||
this.listeners.forEach((listener) => listener(actions));
|
||||
}
|
||||
}
|
||||
|
||||
const actionEmitter = new ActionEmitter();
|
||||
|
||||
export type Action =
|
||||
| "toggle-play" // Toggle play/pause state
|
||||
| "stop-playback" // Stop playback
|
||||
| "seek-forward" // Seek forward in playback
|
||||
| "seek-backward" // Seek backward in playback
|
||||
| "frame-step-forward" // Step forward by one frame
|
||||
| "frame-step-backward" // Step backward by one frame
|
||||
| "jump-forward" // Jump forward by 5 seconds
|
||||
| "jump-backward" // Jump backward by 5 seconds
|
||||
| "goto-start" // Go to timeline start
|
||||
| "goto-end" // Go to timeline end
|
||||
| "split-element" // Split element at current time
|
||||
| "delete-selected" // Delete selected elements
|
||||
| "select-all" // Select all elements
|
||||
| "duplicate-selected" // Duplicate selected element
|
||||
| "toggle-snapping" // Toggle snapping
|
||||
| "undo" // Undo last action
|
||||
| "redo"; // Redo last undone action
|
||||
|
||||
/**
|
||||
* Defines the arguments, if present for a given type that is required to be passed on
|
||||
* invocation and will be passed to action handlers.
|
||||
*
|
||||
* This type is supposed to be an object with the key being one of the actions mentioned above.
|
||||
* The value to the key can be anything.
|
||||
* If an action has no argument, you do not need to add it to this type.
|
||||
*
|
||||
* NOTE: We can't enforce type checks to make sure the key is Action, you
|
||||
* will know if you got something wrong if there is a type error in this file
|
||||
*/
|
||||
type ActionArgsMap = {
|
||||
"seek-forward": { seconds: number } | undefined; // Args needed for seeking forward (default: 1)
|
||||
"seek-backward": { seconds: number } | undefined; // Args needed for seeking backward (default: 1)
|
||||
"jump-forward": { seconds: number } | undefined; // Args needed for jumping forward (default: 5)
|
||||
"jump-backward": { seconds: number } | undefined; // Args needed for jumping backward (default: 5)
|
||||
};
|
||||
|
||||
type KeysWithValueUndefined<T> = {
|
||||
[K in keyof T]: undefined extends T[K] ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
/**
|
||||
* Actions which require arguments for their invocation
|
||||
*/
|
||||
export type ActionWithArgs = keyof ActionArgsMap;
|
||||
|
||||
/**
|
||||
* Actions which optionally takes in arguments for their invocation
|
||||
*/
|
||||
|
||||
export type ActionWithOptionalArgs =
|
||||
| ActionWithNoArgs
|
||||
| KeysWithValueUndefined<ActionArgsMap>;
|
||||
|
||||
/**
|
||||
* Actions which do not require arguments for their invocation
|
||||
*/
|
||||
export type ActionWithNoArgs = Exclude<Action, ActionWithArgs>;
|
||||
|
||||
/**
|
||||
* Resolves the argument type for a given Action
|
||||
*/
|
||||
type ArgOfHoppAction<A extends Action> = A extends ActionWithArgs
|
||||
? ActionArgsMap[A]
|
||||
: undefined;
|
||||
|
||||
/**
|
||||
* Resolves the action function for a given Action, used by action handler function defs
|
||||
*/
|
||||
type ActionFunc<A extends Action> = A extends ActionWithArgs
|
||||
? (arg: ArgOfHoppAction<A>, trigger?: InvocationTriggers) => void
|
||||
: (_?: undefined, trigger?: InvocationTriggers) => void;
|
||||
|
||||
type BoundActionList = {
|
||||
[A in Action]?: Array<ActionFunc<A>>;
|
||||
};
|
||||
|
||||
const boundActions: BoundActionList = {};
|
||||
|
||||
let currentActiveActions: Action[] = [];
|
||||
|
||||
function updateActiveActions() {
|
||||
const newActions = Object.keys(boundActions) as Action[];
|
||||
currentActiveActions = newActions;
|
||||
actionEmitter.emit(newActions);
|
||||
}
|
||||
|
||||
export function bindAction<A extends Action>(
|
||||
action: A,
|
||||
handler: ActionFunc<A>
|
||||
) {
|
||||
if (boundActions[action]) {
|
||||
boundActions[action]?.push(handler);
|
||||
} else {
|
||||
// 'any' assertion because TypeScript doesn't seem to be able to figure out the links.
|
||||
boundActions[action] = [handler] as any;
|
||||
}
|
||||
|
||||
updateActiveActions();
|
||||
}
|
||||
|
||||
export type InvocationTriggers = "keypress" | "mouseclick";
|
||||
|
||||
type InvokeActionFunc = {
|
||||
(
|
||||
action: ActionWithOptionalArgs,
|
||||
args?: undefined,
|
||||
trigger?: InvocationTriggers
|
||||
): void;
|
||||
<A extends ActionWithArgs>(action: A, args: ActionArgsMap[A]): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Invokes an action, triggering action handlers if any registered.
|
||||
* The second and third arguments are optional
|
||||
* @param action The action to fire
|
||||
* @param args The argument passed to the action handler. Optional if action has no args required
|
||||
* @param trigger Optionally supply the trigger that invoked the action (keypress/mouseclick)
|
||||
*/
|
||||
export const invokeAction: InvokeActionFunc = <A extends Action>(
|
||||
action: A,
|
||||
args?: ArgOfHoppAction<A>,
|
||||
trigger?: InvocationTriggers
|
||||
) => {
|
||||
boundActions[action]?.forEach((handler) => (handler as any)(args, trigger));
|
||||
};
|
||||
|
||||
export function unbindAction<A extends Action>(
|
||||
action: A,
|
||||
handler: ActionFunc<A>
|
||||
) {
|
||||
// 'any' assertion because TypeScript doesn't seem to be able to figure out the links.
|
||||
boundActions[action] = boundActions[action]?.filter(
|
||||
(x) => x !== handler
|
||||
) as any;
|
||||
|
||||
if (boundActions[action]?.length === 0) {
|
||||
delete boundActions[action];
|
||||
}
|
||||
|
||||
updateActiveActions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a given action is bound at a given time
|
||||
*
|
||||
* @param action The action to check
|
||||
*/
|
||||
export function isActionBound(action: Action): boolean {
|
||||
return !!boundActions[action];
|
||||
}
|
||||
|
||||
/**
|
||||
* A React hook that defines a component can handle a given
|
||||
* Action. The handler will be bound when the component is mounted
|
||||
* and unbound when the component is unmounted.
|
||||
* @param action The action to be bound
|
||||
* @param handler The function to be called when the action is invoked
|
||||
* @param isActive A ref that indicates whether the action is active
|
||||
*/
|
||||
export function useActionHandler<A extends Action>(
|
||||
action: A,
|
||||
handler: ActionFunc<A>,
|
||||
isActive: MutableRefObject<boolean> | boolean | undefined = undefined
|
||||
) {
|
||||
const handlerRef = useRef(handler);
|
||||
const [isBound, setIsBound] = useState(false);
|
||||
|
||||
// Update handler ref when handler changes
|
||||
useEffect(() => {
|
||||
handlerRef.current = handler;
|
||||
}, [handler]);
|
||||
|
||||
// Create a stable handler wrapper
|
||||
const stableHandler = useCallback(
|
||||
(args: any, trigger?: InvocationTriggers) => {
|
||||
(handlerRef.current as any)(args, trigger);
|
||||
},
|
||||
[]
|
||||
) as ActionFunc<A>;
|
||||
|
||||
useEffect(() => {
|
||||
const shouldBind =
|
||||
isActive === undefined ||
|
||||
(typeof isActive === "boolean" ? isActive : isActive.current);
|
||||
|
||||
if (shouldBind && !isBound) {
|
||||
bindAction(action, stableHandler);
|
||||
setIsBound(true);
|
||||
} else if (!shouldBind && isBound) {
|
||||
unbindAction(action, stableHandler);
|
||||
setIsBound(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (isBound) {
|
||||
unbindAction(action, stableHandler);
|
||||
setIsBound(false);
|
||||
}
|
||||
};
|
||||
}, [action, stableHandler, isActive, isBound]);
|
||||
|
||||
// Handle ref-based isActive changes
|
||||
useEffect(() => {
|
||||
if (isActive && typeof isActive === "object" && "current" in isActive) {
|
||||
// Poll for ref changes
|
||||
const interval = setInterval(() => {
|
||||
const shouldBind = isActive.current;
|
||||
if (shouldBind !== isBound) {
|
||||
if (shouldBind) {
|
||||
bindAction(action, stableHandler);
|
||||
} else {
|
||||
unbindAction(action, stableHandler);
|
||||
}
|
||||
setIsBound(shouldBind);
|
||||
}
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [action, stableHandler, isActive, isBound]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A React hook that returns the current list of active actions
|
||||
* and re-renders when the list changes
|
||||
*/
|
||||
export function useActiveActions(): Action[] {
|
||||
const [activeActions, setActiveActions] = useState<Action[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Set initial value
|
||||
setActiveActions(currentActiveActions);
|
||||
|
||||
// Subscribe to changes
|
||||
const unsubscribe = actionEmitter.subscribe(setActiveActions);
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
return activeActions;
|
||||
}
|
||||
|
||||
/**
|
||||
* A React hook that returns whether a specific action is currently bound
|
||||
* and re-renders when the binding state changes
|
||||
*/
|
||||
export function useIsActionBound(action: Action): boolean {
|
||||
const [isBound, setIsBound] = useState(() => isActionBound(action));
|
||||
|
||||
useEffect(() => {
|
||||
const updateBoundState = () => {
|
||||
setIsBound(isActionBound(action));
|
||||
};
|
||||
|
||||
// Set initial value
|
||||
updateBoundState();
|
||||
|
||||
// Subscribe to changes
|
||||
const unsubscribe = actionEmitter.subscribe(updateBoundState);
|
||||
return unsubscribe;
|
||||
}, [action]);
|
||||
|
||||
return isBound;
|
||||
}
|
||||
@@ -74,5 +74,34 @@ export const TIMELINE_CONSTANTS = {
|
||||
PIXELS_PER_SECOND: 50,
|
||||
TRACK_HEIGHT: 60, // Default fallback
|
||||
DEFAULT_TEXT_DURATION: 5,
|
||||
DEFAULT_IMAGE_DURATION: 5,
|
||||
ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4],
|
||||
} as const;
|
||||
|
||||
// FPS presets for project settings
|
||||
export const FPS_PRESETS = [
|
||||
{ value: "24", label: "24 fps (Film)" },
|
||||
{ value: "25", label: "25 fps (PAL)" },
|
||||
{ value: "30", label: "30 fps (NTSC)" },
|
||||
{ value: "60", label: "60 fps (High)" },
|
||||
{ value: "120", label: "120 fps (Slow-mo)" },
|
||||
] as const;
|
||||
|
||||
// Frame snapping utilities
|
||||
export function timeToFrame(time: number, fps: number): number {
|
||||
return Math.round(time * fps);
|
||||
}
|
||||
|
||||
export function frameToTime(frame: number, fps: number): number {
|
||||
return frame / fps;
|
||||
}
|
||||
|
||||
export function snapTimeToFrame(time: number, fps: number): number {
|
||||
if (fps <= 0) return time; // Fallback for invalid FPS
|
||||
const frame = timeToFrame(time, fps);
|
||||
return frameToTime(frame, fps);
|
||||
}
|
||||
|
||||
export function getFrameDuration(fps: number): number {
|
||||
return 1 / fps;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { vercel } from "@t3-oss/env-core/presets-zod";
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
import { keys as auth } from "@opencut/auth/keys";
|
||||
import { keys as db } from "@opencut/db/keys";
|
||||
|
||||
export const env = createEnv({
|
||||
extends: [vercel(), auth(), db()],
|
||||
server: {
|
||||
ANALYZE: z.string().optional(),
|
||||
// Added by Vercel
|
||||
NEXT_RUNTIME: z.enum(["nodejs", "edge"]).optional(),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
UPSTASH_REDIS_REST_URL: z.string().url(),
|
||||
UPSTASH_REDIS_REST_TOKEN: z.string(),
|
||||
},
|
||||
client: {},
|
||||
runtimeEnv: {
|
||||
ANALYZE: process.env.ANALYZE,
|
||||
NEXT_RUNTIME: process.env.NEXT_RUNTIME,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL,
|
||||
UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN,
|
||||
},
|
||||
});
|
||||
@@ -25,7 +25,7 @@ export function useLogin() {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/editor");
|
||||
router.push("/projects");
|
||||
}, [router, email, password]);
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
@@ -35,7 +35,7 @@ export function useLogin() {
|
||||
try {
|
||||
await signIn.social({
|
||||
provider: "google",
|
||||
callbackURL: "/editor",
|
||||
callbackURL: "/projects",
|
||||
});
|
||||
} catch (error) {
|
||||
setError("Failed to sign in with Google. Please try again.");
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useActionHandler } from "@/constants/actions";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function useEditorActions() {
|
||||
const {
|
||||
tracks,
|
||||
selectedElements,
|
||||
clearSelectedElements,
|
||||
setSelectedElements,
|
||||
removeElementFromTrack,
|
||||
splitElement,
|
||||
addElementToTrack,
|
||||
snappingEnabled,
|
||||
toggleSnapping,
|
||||
undo,
|
||||
redo,
|
||||
} = useTimelineStore();
|
||||
|
||||
const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
|
||||
const { activeProject } = useProjectStore();
|
||||
|
||||
// Playback actions
|
||||
useActionHandler("toggle-play", () => {
|
||||
toggle();
|
||||
});
|
||||
|
||||
useActionHandler("stop-playback", () => {
|
||||
if (isPlaying) {
|
||||
toggle();
|
||||
}
|
||||
seek(0);
|
||||
});
|
||||
|
||||
useActionHandler("seek-forward", (args) => {
|
||||
const seconds = args?.seconds ?? 1;
|
||||
seek(Math.min(duration, currentTime + seconds));
|
||||
});
|
||||
|
||||
useActionHandler("seek-backward", (args) => {
|
||||
const seconds = args?.seconds ?? 1;
|
||||
seek(Math.max(0, currentTime - seconds));
|
||||
});
|
||||
|
||||
useActionHandler("frame-step-forward", () => {
|
||||
const projectFps = activeProject?.fps || 30;
|
||||
seek(Math.min(duration, currentTime + 1 / projectFps));
|
||||
});
|
||||
|
||||
useActionHandler("frame-step-backward", () => {
|
||||
const projectFps = activeProject?.fps || 30;
|
||||
seek(Math.max(0, currentTime - 1 / projectFps));
|
||||
});
|
||||
|
||||
useActionHandler("jump-forward", (args) => {
|
||||
const seconds = args?.seconds ?? 5;
|
||||
seek(Math.min(duration, currentTime + seconds));
|
||||
});
|
||||
|
||||
useActionHandler("jump-backward", (args) => {
|
||||
const seconds = args?.seconds ?? 5;
|
||||
seek(Math.max(0, currentTime - seconds));
|
||||
});
|
||||
|
||||
useActionHandler("goto-start", () => {
|
||||
seek(0);
|
||||
});
|
||||
|
||||
useActionHandler("goto-end", () => {
|
||||
seek(duration);
|
||||
});
|
||||
|
||||
// Timeline editing actions
|
||||
useActionHandler("split-element", () => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element to split");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t: any) => t.id === trackId);
|
||||
const element = track?.elements.find((el: any) => el.id === elementId);
|
||||
|
||||
if (element) {
|
||||
const effectiveStart = element.startTime;
|
||||
const effectiveEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
|
||||
splitElement(trackId, elementId, currentTime);
|
||||
} else {
|
||||
toast.error("Playhead must be within selected element");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useActionHandler("delete-selected", () => {
|
||||
if (selectedElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
selectedElements.forEach(
|
||||
({ trackId, elementId }: { trackId: string; elementId: string }) => {
|
||||
removeElementFromTrack(trackId, elementId);
|
||||
}
|
||||
);
|
||||
clearSelectedElements();
|
||||
});
|
||||
|
||||
useActionHandler("select-all", () => {
|
||||
const allElements = tracks.flatMap((track: any) =>
|
||||
track.elements.map((element: any) => ({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
}))
|
||||
);
|
||||
setSelectedElements(allElements);
|
||||
});
|
||||
|
||||
useActionHandler("duplicate-selected", () => {
|
||||
if (selectedElements.length !== 1) {
|
||||
toast.error("Select exactly one element to duplicate");
|
||||
return;
|
||||
}
|
||||
|
||||
const { trackId, elementId } = selectedElements[0];
|
||||
const track = tracks.find((t: any) => t.id === trackId);
|
||||
const element = track?.elements.find((el: any) => el.id === elementId);
|
||||
|
||||
if (element) {
|
||||
const newStartTime =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd) +
|
||||
0.1;
|
||||
const { id, ...elementWithoutId } = element;
|
||||
|
||||
addElementToTrack(trackId, {
|
||||
...elementWithoutId,
|
||||
startTime: newStartTime,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useActionHandler("toggle-snapping", () => {
|
||||
toggleSnapping();
|
||||
});
|
||||
|
||||
// History actions
|
||||
useActionHandler("undo", () => {
|
||||
undo();
|
||||
});
|
||||
|
||||
useActionHandler("redo", () => {
|
||||
redo();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { ActionWithOptionalArgs } from "@/constants/actions";
|
||||
|
||||
export interface KeybindingConflictInfo {
|
||||
key: string;
|
||||
actions: ActionWithOptionalArgs[];
|
||||
isConflict: boolean;
|
||||
}
|
||||
|
||||
export const useKeybindingConflicts = () => {
|
||||
const { keybindings } = useKeybindingsStore();
|
||||
|
||||
const conflicts = useMemo(() => {
|
||||
const keyToActions: Record<string, ActionWithOptionalArgs[]> = {};
|
||||
const conflictList: KeybindingConflictInfo[] = [];
|
||||
|
||||
// Group actions by key
|
||||
Object.entries(keybindings).forEach(([key, action]) => {
|
||||
if (!keyToActions[key]) {
|
||||
keyToActions[key] = [];
|
||||
}
|
||||
keyToActions[key].push(action);
|
||||
});
|
||||
|
||||
// Find conflicts
|
||||
Object.entries(keyToActions).forEach(([key, actions]) => {
|
||||
const uniqueActions = [...new Set(actions)];
|
||||
conflictList.push({
|
||||
key,
|
||||
actions: uniqueActions,
|
||||
isConflict: uniqueActions.length > 1,
|
||||
});
|
||||
});
|
||||
|
||||
return conflictList.filter((item) => item.isConflict);
|
||||
}, [keybindings]);
|
||||
|
||||
const hasConflicts = conflicts.length > 0;
|
||||
|
||||
const getConflictsForKey = (key: string): KeybindingConflictInfo | null => {
|
||||
return conflicts.find((conflict) => conflict.key === key) || null;
|
||||
};
|
||||
|
||||
const getConflictsForAction = (
|
||||
action: ActionWithOptionalArgs
|
||||
): KeybindingConflictInfo[] => {
|
||||
return conflicts.filter((conflict) => conflict.actions.includes(action));
|
||||
};
|
||||
|
||||
return {
|
||||
conflicts,
|
||||
hasConflicts,
|
||||
getConflictsForKey,
|
||||
getConflictsForAction,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect } from "react";
|
||||
import { invokeAction } from "../constants/actions";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
|
||||
/**
|
||||
* A composable that hooks to the caller component's
|
||||
* lifecycle and hooks to the keyboard events to fire
|
||||
* the appropriate actions based on keybindings
|
||||
*/
|
||||
export function useKeybindingsListener() {
|
||||
const { keybindings, getKeybindingString, keybindingsEnabled } =
|
||||
useKeybindingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
// Do not check keybinds if the mode is disabled
|
||||
if (!keybindingsEnabled) return;
|
||||
|
||||
const binding = getKeybindingString(ev);
|
||||
if (!binding) return;
|
||||
|
||||
const boundAction = keybindings[binding];
|
||||
if (!boundAction) return;
|
||||
|
||||
ev.preventDefault();
|
||||
|
||||
// Handle actions with default arguments
|
||||
let actionArgs: any = undefined;
|
||||
|
||||
if (boundAction === "seek-forward") {
|
||||
actionArgs = { seconds: 1 };
|
||||
} else if (boundAction === "seek-backward") {
|
||||
actionArgs = { seconds: 1 };
|
||||
} else if (boundAction === "jump-forward") {
|
||||
actionArgs = { seconds: 5 };
|
||||
} else if (boundAction === "jump-backward") {
|
||||
actionArgs = { seconds: 5 };
|
||||
}
|
||||
|
||||
invokeAction(boundAction, actionArgs, "keypress");
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [keybindings, getKeybindingString, keybindingsEnabled]);
|
||||
}
|
||||
|
||||
/**
|
||||
* This composable allows for the UI component to be disabled if the component in question is mounted
|
||||
*/
|
||||
export function useKeybindingDisabler() {
|
||||
const { disableKeybindings, enableKeybindings } = useKeybindingsStore();
|
||||
|
||||
return {
|
||||
disableKeybindings,
|
||||
enableKeybindings,
|
||||
};
|
||||
}
|
||||
|
||||
// Export the bindings for backward compatibility
|
||||
export const bindings = {};
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useKeybindingsStore } from "@/stores/keybindings-store";
|
||||
import { Action } from "@/constants/actions";
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
id: string;
|
||||
keys: string[];
|
||||
description: string;
|
||||
category: string;
|
||||
action: Action;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Map actions to their descriptions and categories
|
||||
const actionDescriptions: Record<
|
||||
Action,
|
||||
{ description: string; category: string }
|
||||
> = {
|
||||
"toggle-play": { description: "Play/Pause", category: "Playback" },
|
||||
"stop-playback": { description: "Stop playback", category: "Playback" },
|
||||
"seek-forward": {
|
||||
description: "Seek forward 1 second",
|
||||
category: "Playback",
|
||||
},
|
||||
"seek-backward": {
|
||||
description: "Seek backward 1 second",
|
||||
category: "Playback",
|
||||
},
|
||||
"frame-step-forward": {
|
||||
description: "Frame step forward",
|
||||
category: "Navigation",
|
||||
},
|
||||
"frame-step-backward": {
|
||||
description: "Frame step backward",
|
||||
category: "Navigation",
|
||||
},
|
||||
"jump-forward": {
|
||||
description: "Jump forward 5 seconds",
|
||||
category: "Navigation",
|
||||
},
|
||||
"jump-backward": {
|
||||
description: "Jump backward 5 seconds",
|
||||
category: "Navigation",
|
||||
},
|
||||
"goto-start": { description: "Go to timeline start", category: "Navigation" },
|
||||
"goto-end": { description: "Go to timeline end", category: "Navigation" },
|
||||
"split-element": {
|
||||
description: "Split element at playhead",
|
||||
category: "Editing",
|
||||
},
|
||||
"delete-selected": {
|
||||
description: "Delete selected elements",
|
||||
category: "Editing",
|
||||
},
|
||||
"select-all": { description: "Select all elements", category: "Selection" },
|
||||
"duplicate-selected": {
|
||||
description: "Duplicate selected element",
|
||||
category: "Selection",
|
||||
},
|
||||
"toggle-snapping": { description: "Toggle snapping", category: "Editing" },
|
||||
undo: { description: "Undo", category: "History" },
|
||||
redo: { description: "Redo", category: "History" },
|
||||
};
|
||||
|
||||
// Convert key binding format to display format
|
||||
const formatKey = (key: string): string => {
|
||||
return key
|
||||
.replace("ctrl", "Cmd")
|
||||
.replace("alt", "Alt")
|
||||
.replace("shift", "Shift")
|
||||
.replace("left", "ArrowLeft")
|
||||
.replace("right", "ArrowRight")
|
||||
.replace("up", "ArrowUp")
|
||||
.replace("down", "ArrowDown")
|
||||
.replace("space", "Space")
|
||||
.replace("home", "Home")
|
||||
.replace("end", "End")
|
||||
.replace("delete", "Delete")
|
||||
.replace("backspace", "Backspace")
|
||||
.replace("-", "+");
|
||||
};
|
||||
|
||||
export const useKeyboardShortcutsHelp = () => {
|
||||
const { keybindings } = useKeybindingsStore();
|
||||
|
||||
const shortcuts = useMemo(() => {
|
||||
const result: KeyboardShortcut[] = [];
|
||||
|
||||
// Group keybindings by action
|
||||
const actionToKeys: Record<Action, string[]> = {} as any;
|
||||
|
||||
Object.entries(keybindings).forEach(([key, action]) => {
|
||||
if (action) {
|
||||
if (!actionToKeys[action]) {
|
||||
actionToKeys[action] = [];
|
||||
}
|
||||
actionToKeys[action].push(formatKey(key));
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to shortcuts format
|
||||
Object.entries(actionToKeys).forEach(([action, keys]) => {
|
||||
const actionInfo = actionDescriptions[action as Action];
|
||||
if (actionInfo) {
|
||||
result.push({
|
||||
id: action,
|
||||
keys,
|
||||
description: actionInfo.description,
|
||||
category: actionInfo.category,
|
||||
action: action as Action,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort shortcuts by category first, then by description to ensure consistent ordering
|
||||
return result.sort((a, b) => {
|
||||
if (a.category !== b.category) {
|
||||
return a.category.localeCompare(b.category);
|
||||
}
|
||||
return a.description.localeCompare(b.description);
|
||||
});
|
||||
}, [keybindings]);
|
||||
|
||||
return {
|
||||
shortcuts,
|
||||
};
|
||||
};
|
||||
@@ -106,68 +106,4 @@ export const usePlaybackControls = () => {
|
||||
|
||||
separateAudio(trackId, elementId);
|
||||
}, [selectedElements, tracks, separateAudio]);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
if (isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
break;
|
||||
|
||||
case "s":
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleSplitSelectedElement();
|
||||
}
|
||||
break;
|
||||
|
||||
case "q":
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleSplitAndKeepLeftCallback();
|
||||
}
|
||||
break;
|
||||
|
||||
case "w":
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleSplitAndKeepRightCallback();
|
||||
}
|
||||
break;
|
||||
|
||||
case "d":
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleSeparateAudioCallback();
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
isPlaying,
|
||||
play,
|
||||
pause,
|
||||
handleSplitSelectedElement,
|
||||
handleSplitAndKeepLeftCallback,
|
||||
handleSplitAndKeepRightCallback,
|
||||
handleSeparateAudioCallback,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyPress);
|
||||
return () => document.removeEventListener("keydown", handleKeyPress);
|
||||
}, [handleKeyPress]);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { ResizeState, TimelineElement, TimelineTrack } from "@/types/timeline";
|
||||
import { useMediaStore } from "@/stores/media-store";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
|
||||
interface UseTimelineElementResizeProps {
|
||||
element: TimelineElement;
|
||||
@@ -28,6 +29,12 @@ export function useTimelineElementResize({
|
||||
}: UseTimelineElementResizeProps) {
|
||||
const [resizing, setResizing] = useState<ResizeState | null>(null);
|
||||
const { mediaItems } = useMediaStore();
|
||||
const {
|
||||
updateElementStartTime,
|
||||
updateElementTrim,
|
||||
updateElementDuration,
|
||||
pushHistory,
|
||||
} = useTimelineStore();
|
||||
|
||||
// Set up document-level mouse listeners during resize (like proper drag behavior)
|
||||
useEffect(() => {
|
||||
@@ -59,6 +66,9 @@ export function useTimelineElementResize({
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
// Push history once at the start of the resize operation
|
||||
pushHistory();
|
||||
|
||||
setResizing({
|
||||
elementId,
|
||||
side,
|
||||
@@ -100,12 +110,58 @@ export function useTimelineElementResize({
|
||||
const deltaTime = deltaX / (50 * zoomLevel);
|
||||
|
||||
if (resizing.side === "left") {
|
||||
// Left resize - only trim within original duration
|
||||
// Left resize - different behavior for media vs text/image elements
|
||||
const maxAllowed = element.duration - resizing.initialTrimEnd - 0.1;
|
||||
const calculated = resizing.initialTrimStart + deltaTime;
|
||||
const newTrimStart = Math.max(0, Math.min(maxAllowed, calculated));
|
||||
|
||||
onUpdateTrim(track.id, element.id, newTrimStart, resizing.initialTrimEnd);
|
||||
if (calculated >= 0) {
|
||||
// Normal trimming within available content
|
||||
const newTrimStart = Math.min(maxAllowed, calculated);
|
||||
const trimDelta = newTrimStart - resizing.initialTrimStart;
|
||||
const newStartTime = element.startTime + trimDelta;
|
||||
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
newTrimStart,
|
||||
resizing.initialTrimEnd,
|
||||
false
|
||||
);
|
||||
updateElementStartTime(track.id, element.id, newStartTime, false);
|
||||
} else {
|
||||
// Trying to extend beyond trimStart = 0
|
||||
if (canExtendElementDuration()) {
|
||||
// Text/Image: extend element to the left by moving startTime and increasing duration
|
||||
const extensionAmount = Math.abs(calculated);
|
||||
const newStartTime = element.startTime - extensionAmount;
|
||||
const newDuration = element.duration + extensionAmount;
|
||||
|
||||
// Keep trimStart at 0 and extend the element
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
0,
|
||||
resizing.initialTrimEnd,
|
||||
false
|
||||
);
|
||||
updateElementDuration(track.id, element.id, newDuration, false);
|
||||
updateElementStartTime(track.id, element.id, newStartTime, false);
|
||||
} else {
|
||||
// Video/Audio: can't extend beyond original content - limit to trimStart = 0
|
||||
const newTrimStart = 0;
|
||||
const trimDelta = newTrimStart - resizing.initialTrimStart;
|
||||
const newStartTime = element.startTime + trimDelta;
|
||||
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
newTrimStart,
|
||||
resizing.initialTrimEnd,
|
||||
false
|
||||
);
|
||||
updateElementStartTime(track.id, element.id, newStartTime, false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Right resize - can extend duration for supported element types
|
||||
const calculated = resizing.initialTrimEnd - deltaTime;
|
||||
@@ -119,36 +175,40 @@ export function useTimelineElementResize({
|
||||
const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending
|
||||
|
||||
// Update duration first, then trim
|
||||
onUpdateDuration(track.id, element.id, newDuration);
|
||||
onUpdateTrim(
|
||||
updateElementDuration(track.id, element.id, newDuration, false);
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
resizing.initialTrimStart,
|
||||
newTrimEnd
|
||||
newTrimEnd,
|
||||
false
|
||||
);
|
||||
} else {
|
||||
// Can't extend - just set trimEnd to 0 (maximum possible extension)
|
||||
onUpdateTrim(track.id, element.id, resizing.initialTrimStart, 0);
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
resizing.initialTrimStart,
|
||||
0,
|
||||
false
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Normal trimming within original duration
|
||||
const maxTrimEnd = element.duration - resizing.initialTrimStart - 0.1; // Leave at least 0.1s visible
|
||||
const newTrimEnd = Math.max(0, Math.min(maxTrimEnd, calculated));
|
||||
|
||||
onUpdateTrim(
|
||||
updateElementTrim(
|
||||
track.id,
|
||||
element.id,
|
||||
resizing.initialTrimStart,
|
||||
newTrimEnd
|
||||
newTrimEnd,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeMove = (e: React.MouseEvent) => {
|
||||
updateTrimFromMouseMove(e);
|
||||
};
|
||||
|
||||
const handleResizeEnd = () => {
|
||||
setResizing(null);
|
||||
};
|
||||
|
||||