208 Commits

Author SHA1 Message Date
b0e9901730 databuddy: enable errors 2025-07-13 13:25:43 +02:00
3b4b7e2009 databuddy 2025-07-13 13:20:06 +02:00
3037a7ecbf fix: handle border 2025-07-12 21:52:35 +02:00
ad7ace3fd2 fix: spacing 2025-07-12 21:52:18 +02:00
d0769fdcaa fix: reorder export statements for consistency 2025-07-12 23:53:15 +06:00
9756559811 chore: remove GripVertical as it is an unused import 2025-07-12 23:52:17 +06:00
0ee043b319 fix: enhance layout of editor and resizable components for better usability 2025-07-12 23:49:49 +06:00
9d828063c1 fix: update ResizableHandle styles for improved visibility and interaction 2025-07-12 23:40:54 +06:00
70517fec18 fix: improve layout and styling of resizable panels 2025-07-12 23:40:38 +06:00
22db0b8b89 Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-07-12 17:15:06 +02:00
71f666c6d7 feat: optimzation for deleting projects 2025-07-12 17:14:53 +02:00
e57ca15dc6 fix: missing name field 2025-07-12 16:54:55 +02:00
e96832bf9b Merge pull request #172 from Zaid-maker/bump-bun-version
Bump bun version
2025-07-12 16:52:42 +02:00
0a0f68d711 Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-07-12 16:51:18 +02:00
57dace960c Merge branch 'manuarora700/main' 2025-07-12 16:51:09 +02:00
145d01c8e4 cleanup 2025-07-12 16:50:34 +02:00
25d12a6fa4 Merge pull request #183 from youknowom/improve-contributing
docs: add note about npm workspace error and bun recommendation
2025-07-12 16:39:29 +02:00
a9b02df2e3 Merge pull request #185 from omraval18/fix/login-redirect-issue
fix: redirects to /projects after login instead of /editor
2025-07-12 16:38:32 +02:00
b76e84354d feat: dark mode always 2025-07-12 15:45:12 +02:00
233f78bf52 Merge remote-tracking branch 'anwarulislam/feat/fix-scroll' 2025-07-12 15:44:33 +02:00
f0b0451616 Merge pull request #200 from ahmedfahim21/feature/gesture-navigation-fix
fix: prevent unintended navigation on swipe
2025-07-12 15:39:14 +02:00
4d0382cc4b Merge pull request #202 from MarcBlattmann/patch-1
Remove Unused Bracket in readme
2025-07-12 15:38:13 +02:00
800e720e6e Merge pull request #208 from Sanjit-K/main
fixed: [BUG] crypto.randomUUID is not a function Runtime Error #205. …
2025-07-12 15:27:00 +02:00
457d828c45 Merge pull request #210 from Mirza-Samad-Ahmed-Baig/main
refactor: improve theme handling, env var safety, and db schema
2025-07-12 15:24:01 +02:00
d925b62a47 Merge pull request #215 from Chirag-varu/main
Better README and docker-compose port error solved
2025-07-12 15:22:51 +02:00
84d153e2c5 databuddy 2025-07-12 14:42:31 +02:00
27da1838cc Better README and docker-compose port error solved 2025-07-12 17:05:53 +05:30
8545d95070 refactor: add FPS and remove turbopack (yes, fuck you turbopack) 2025-07-12 12:48:31 +02:00
0726c27221 small change 2025-07-12 11:52:54 +02:00
2de4c7c153 feat: remove playback speed from timeline 2025-07-12 11:45:43 +02:00
ba809effb6 databuddy 2025-07-12 11:29:35 +02:00
09577b712a Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-07-12 11:24:05 +02:00
da06c5946d databuddy 2025-07-12 11:23:54 +02:00
d48a3fe287 refactor: improve theme handling, env var safety, and db schema 2025-07-12 13:31:50 +05:00
b4c93182d0 fix: apply suggestion 2025-07-12 12:45:50 +05:30
799fd2981a fixed: [BUG] crypto.randomUUID is not a function Runtime Error #205. Added fallback function with manual UUID generation if crypto.randomUUID() was not available. 2025-07-12 00:14:03 -05:00
ac1eef7bd0 Update README.md 2025-07-11 22:18:32 +02:00
6e6f5211fc Remove Unused Bracket in readme 2025-07-11 22:05:35 +02:00
338e13a601 fix: apply suggestion 2025-07-12 01:04:29 +05:30
410f8da1c9 fix: prevent unintended navigation on swipe 2025-07-12 00:39:17 +05:30
6ecc359d80 chore: clean up 2025-07-11 20:02:09 +06:00
fd017d6aca docs: add growth to README 2025-07-11 15:59:56 +02:00
4d8760d0e1 refactor: enhance scroll synchronization for timeline and track labels 2025-07-11 19:58:35 +06:00
9b78503562 fix styling 2025-07-11 14:50:51 +02:00
76229a1da5 colors 2025-07-11 04:21:40 +02:00
ac0d089bf7 fix: dragging time ruler would trigger the selection box 2025-07-11 04:19:39 +02:00
4d67e366ad feat: background settings (color, blur) 2025-07-11 03:52:39 +02:00
6c19dbb6bb feat: selection box 2025-07-11 01:37:42 +02:00
d643a9a277 small refactor 2025-07-11 00:57:09 +02:00
92d534760d fix: ensure element doesn't go above playhead 2025-07-11 00:50:33 +02:00
4880e3b10c fix: allow real-time playhead on ruler and cursor change 2025-07-11 00:48:17 +02:00
3a241d9112 feat: improve playhead 2025-07-11 00:25:07 +02:00
bb65d4fb96 refactor: timeline playhead component and hook 2025-07-10 23:55:08 +02:00
eabcdb0988 fix: preview image scaling 2025-07-10 23:45:59 +02:00
c86d200297 fix: increase max text size to 300 2025-07-10 23:22:57 +02:00
3d6786a587 fix: make text scale based on preview size 2025-07-10 23:22:40 +02:00
f43021e994 background 2025-07-10 22:40:01 +02:00
4fc14947ad fix: tailwind class 2025-07-10 22:21:09 +02:00
6c59fed5c0 style: font picker 2025-07-10 21:53:50 +02:00
7ec9167aeb refactor: new font picker component 2025-07-10 21:52:46 +02:00
f984f615ce style: text area bg 2025-07-10 21:49:27 +02:00
fe6492f359 refactor: separate property item component 2025-07-10 21:48:26 +02:00
ed4e9dad19 style: padding 2025-07-10 21:39:11 +02:00
eadd6940e4 feat: font size to text panel 2025-07-10 21:37:44 +02:00
0acead5bb1 refactor: move properties to dedicated folder 2025-07-10 21:24:47 +02:00
98d536a474 formatting 2025-07-10 21:23:50 +02:00
9bbb42c357 dropdown fix 2025-07-10 21:15:43 +02:00
445d01fc8f font size 2025-07-10 21:14:33 +02:00
3ada352730 tailwind 2025-07-10 21:13:46 +02:00
6aa071ef8d fix: tailwind 2025-07-10 21:08:31 +02:00
3e45be5c47 fix: redirects to /projects after login instead of /editor 2025-07-11 00:33:31 +05:30
e8b0057cc4 feat: initial fonts support 2025-07-10 20:53:58 +02:00
055a6af055 refactor: move constants to src/constants 2025-07-10 20:35:15 +02:00
1376bee16d fix: improve element dragging (can drag outside timeline without it breaking) 2025-07-10 20:10:01 +02:00
0f175b232f textarea size 2025-07-10 19:58:57 +02:00
aa0482b012 fix: select auto focus when closed 2025-07-10 19:58:39 +02:00
0223c34a1e fix: timestamp different widths 2025-07-10 19:58:03 +02:00
44f504f401 style: timestamp 2025-07-10 19:56:54 +02:00
d9d54df431 docs: update note to mention npm upgrade option 2025-07-10 23:20:32 +05:30
4d0c3268cc style: properties panel text area 2025-07-10 19:44:24 +02:00
5e74906e19 docs: add note about npm workspace error and bun recommendation 2025-07-10 23:12:57 +05:30
2fe67febd6 complete 1459cd 2025-07-10 14:30:12 +02:00
3a34485cc7 style: text box in properties panel 2025-07-10 14:28:08 +02:00
1459cd7232 feat: audio for playback 2025-07-10 14:23:13 +02:00
f8e8de4438 Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-07-09 23:59:49 +02:00
8dd6f9a9b3 feat: add conditional rendering to properties panel with text change functionality 2025-07-09 23:59:39 +02:00
7bf6671c0a feat: add conditional rendering to properties panel with text size functionality 2025-07-09 23:59:13 +02:00
b6aa8e10d6 fix: improve keyboard event handling in timeline component 2025-07-09 23:25:08 +02:00
6ba1021149 fix: make panels resizable 2025-07-09 22:46:10 +02:00
059a4f4205 style: timeline tracks 2025-07-09 22:31:45 +02:00
f4fbdf14a9 style: text align 2025-07-09 22:17:54 +02:00
db8cd93a99 style: replace track color circle with icon in timeline 2025-07-09 22:17:05 +02:00
27d65ca7c5 fix: make timeline zoom faster 2025-07-09 22:07:39 +02:00
53d6d0e1af fix: zoom on entire timeline 2025-07-09 22:07:25 +02:00
3089fb0418 refactor: move timeline zoom to custom hook 2025-07-09 22:01:30 +02:00
346368cf75 I FOUND THE C (removed it) 2025-07-09 21:54:52 +02:00
5e1f780fff style: completely fresh ui in editor 2025-07-09 21:45:25 +02:00
3d685f57dd fix: styling in preview 2025-07-09 21:07:42 +02:00
612fc03cde refactor: fix import syntax in page.tsx 2025-07-09 21:04:41 +02:00
aadc253fee refactor: clean up imports in page.tsx 2025-07-09 21:04:24 +02:00
c02f276303 refactor: fresh properties panel 2025-07-09 21:03:56 +02:00
dd35c91f39 fix: update header link from Editor to Projects for better navigation 2025-07-09 20:56:19 +02:00
51894544b2 fix: add padding to Why Not CapCut page for improved layout 2025-07-09 14:05:25 +02:00
c5d96a0ded add: actual handlebars to resize container 2025-07-09 17:30:37 +05:30
612fa55937 fix: enhance tone and clarity in Why Not CapCut page content 2025-07-09 10:39:21 +02:00
9c25814717 fix: update text in Why Not CapCut page for improved tone 2025-07-09 10:35:20 +02:00
9c8985d115 redirect 2025-07-09 07:49:17 +02:00
7a706f3bbc vercel 2025-07-09 07:43:35 +02:00
44ff4fe638 chore: remove vercel config 2025-07-09 07:41:19 +02:00
e5892fdea6 vercel 2025-07-09 07:39:00 +02:00
ad45c8c1ed chore: vercel config 2025-07-09 07:36:05 +02:00
c6cfb8ce87 feat: why not capcut page 2025-07-09 07:33:10 +02:00
813dbcb9c2 refactor: move timeline element context menu into timeline-element.tsx 2025-07-08 23:51:46 +02:00
66da1e20d3 feat: remove cascade deletion logic when removing media 2025-07-08 23:38:27 +02:00
3ef17cecb4 refactor: centralize logic to zustand store 2025-07-08 22:51:42 +02:00
24b9c89084 refactor: remove unused import 2025-07-08 22:43:08 +02:00
c60098987f fix: media item not right-clickable 2025-07-08 22:42:55 +02:00
60a1273206 fix: adjust element border colors 2025-07-08 20:54:19 +02:00
91d89f56d7 fix: don't render audio on preview 2025-07-08 20:54:05 +02:00
b5d04d591f Update bun-ci.yml 2025-07-08 13:29:03 +05:00
e26c3bccdb Update package.json 2025-07-08 13:25:13 +05:00
74408541fd Update package.json 2025-07-08 13:24:33 +05:00
65afd3f18b bump bun version in CI 2025-07-08 13:23:41 +05:00
ea59cc3950 fix: ensure timeline clips can be extended to longer than 5s 2025-07-08 00:42:46 +02:00
c0cc4c009e fix: ensure preview toolbar is always at bottom 2025-07-08 00:10:31 +02:00
d750d7f41d shipping too hard 2025-07-08 00:07:22 +02:00
9d2fd50fbc refactor: centralize track colors and timeline constants 2025-07-07 23:21:06 +02:00
d36df2fb62 refactor: remove element menu from timeline element component 2025-07-07 23:05:31 +02:00
acda7064bd fix: unable to resize elements to be bigger 2025-07-07 23:03:35 +02:00
f3763b8465 refactor: move element resize logic to react hook 2025-07-07 19:13:20 +02:00
bd0c7f2206 refactor: store media relative to project, add storage for timeline data, and other things 2025-07-07 19:06:36 +02:00
11c0b89bd1 Merge pull request #158 from Pratiyankkumar/added-repo-url
feat: make Open Source badge a clickable link to GitHub repository
2025-07-07 03:52:51 +02:00
25c9ffc131 Merge pull request #164 from Pratiyankkumar/select-multiple-projects
feat: add multiple project selection and bulk delete functionality
2025-07-07 03:46:48 +02:00
85a93ce090 fix: primary/foreground button 2025-07-06 23:59:26 +02:00
6edd5b36cf feat: main track and ordering tracks 2025-07-06 22:51:11 +02:00
40c7fbb4f8 refactor: move to a typed-tracks system and add support for text 2025-07-06 20:45:29 +02:00
0e32c732dd fix: ghost around draggable item (maybe) 2025-07-05 18:56:46 +02:00
13b2fad50f docs: add sponsors section to README and include Vercel support information 2025-07-05 02:57:41 +02:00
3d1efeaf36 Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-07-05 02:51:50 +02:00
163489f499 Merge pull request #166 from ArjunCodess/patch-1
docs: Replace placeholder repo URL with actual GitHub clone link in README
2025-07-04 16:22:48 +02:00
562cf38341 Update README.md 2025-07-04 07:16:46 -07:00
d04ba1468e style: change border color for selected timeline clips from primary to foreground 2025-07-04 15:15:53 +02:00
4728884931 refactor: new reusable draggable-item component and use it 2025-07-04 01:30:24 +02:00
fb9f47117c style: update primary color 2025-07-04 01:02:14 +02:00
9dbfa980c2 fix: ensure selecting an already selected clip doesn't deselect it 2025-07-04 00:37:59 +02:00
8be05901fb style: update card background color in dark theme 2025-07-03 22:29:16 +02:00
37d684748f refactor: replace div with Card component 2025-07-03 22:29:00 +02:00
b5af50b0d8 style: add select-none class to Default text in TextView component 2025-07-03 22:26:27 +02:00
c413b53c33 Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-07-03 22:07:44 +02:00
434a832d8e feat: add TextView component to media panel 2025-07-03 22:07:33 +02:00
1372c218ad feat: add custom scrollbar utilities to Tailwind configuration 2025-07-03 22:07:24 +02:00
8f365915a2 refactor: replace TextIcon with TypeIcon in media panel tabs 2025-07-03 22:06:43 +02:00
f991d707ea feat: enhance media panel tab navigation with scroll functionality 2025-07-03 22:06:24 +02:00
59a6c539a1 feat: implement media panel with tab navigation 2025-07-03 20:43:23 +02:00
ef0828a13d style: color in preview panel 2025-07-03 17:26:49 +02:00
b90f9922a1 fix drag in media panel and add toolbar 2025-07-03 17:26:32 +02:00
d3d5bbf51a feat: add multiple project selection and bulk delete functionality 2025-07-02 19:39:08 +05:30
b1ade266e5 fix: CDN instead of app for databooty 2025-07-02 16:25:08 +03:00
a4d7bdda24 feat: make Open Source badge a clickable link to GitHub repository 2025-07-02 09:50:28 +05:30
bc3fbec541 docs: update README 2025-07-02 01:39:32 +02:00
dcf3fccca1 Updated Readme 2025-07-01 21:17:26 +01:00
5d02169d63 fix: deselect clips by clicking outside of track 2025-07-01 19:48:00 +02:00
d95b7a9316 fix right-clicking a clip allows dragging 2025-07-01 19:42:09 +02:00
8bf865df0d feat: select clip when it's being right-clicked 2025-07-01 19:31:25 +02:00
b14e9e82fe docs: yeah 2025-07-01 19:26:42 +02:00
394d9f684c refactor: clean up comments 2025-07-01 19:16:37 +02:00
5dfe9c0aac fix: better drag ux on playhead 2025-07-01 17:47:07 +02:00
a3309b4c45 refactor: replace native select/input with shadcn 2025-07-01 17:07:44 +02:00
fe289db9b0 refactor: remove unused icon imports from media panel 2025-07-01 16:17:14 +02:00
849fb3d2af refactor: editor header 2025-07-01 16:06:36 +02:00
364e541d57 fix: remove success toasts for added media items in timeline components 2025-07-01 16:03:51 +02:00
d623ba6b4b refactor: simplify canvas preset names for clarity 2025-07-01 16:02:32 +02:00
fb487681b6 fix: select focus styling 2025-07-01 16:02:07 +02:00
a16c86092a fix: playhead not moving in real time 2025-07-01 15:30:50 +02:00
baf5e9907f fix: remove 4k aspect ratio 2025-07-01 15:06:10 +02:00
e4683e38db style: make select component look similar to other components 2025-07-01 15:05:45 +02:00
ac4ff63438 fix: ensure playback ruler appears above everything else 2025-07-01 15:02:34 +02:00
ee973cad21 feat: select clip when being dragged, even if it wasn't selected 2025-07-01 15:00:21 +02:00
9c8594d8f3 style: timeclip's focus state 2025-07-01 14:56:14 +02:00
c37c64c1b9 feat: add time formatting utility and update editor and preview components to use it 2025-07-01 01:30:02 +02:00
1a01871cfc refactor: update media processing to use width and height instead of aspect ratio 2025-07-01 01:13:14 +02:00
9b37ce6610 style: give preview panel a fresh look 2025-07-01 00:58:21 +02:00
d11d835c7c style: fresh look on media panel 2025-07-01 00:13:41 +02:00
1fa4c9c72f feat: update tools panel size from 25 to 45 2025-06-30 23:35:57 +02:00
50e3d92b92 style: set aspect ratio to 16:9 for the asset containers in the media panel 2025-06-30 23:35:37 +02:00
9e01efdc88 refactor: consolidate default panel sizes into a constant for improved maintainability 2025-06-30 23:30:36 +02:00
011be3d9a5 refactor: remove unused imports 2025-06-30 23:25:25 +02:00
b474ad6b15 feat: hide preview canvas when nothing in timeline 2025-06-30 23:23:18 +02:00
3e916f0f00 feat: implement automatic canvas size adjustment based on first media item aspect ratio 2025-06-30 23:19:37 +02:00
d50cd0b40d fix: update editor link in header component to include default editor ID 2025-06-30 21:44:46 +02:00
d0ae75d0b4 fix ts error 2025-06-30 20:01:18 +02:00
09373eb4a3 feat: implement project management features with storage integration, including project creation, deletion, and renaming dialogs 2025-06-30 19:58:36 +02:00
cd30c205b4 fix: await addMediaItem in timeline component to ensure proper item processing 2025-06-30 19:57:13 +02:00
16a319f2e4 delete editor.css 2025-06-30 18:32:47 +02:00
1466dd42e2 feat: remove contributor index display from contributors page 2025-06-29 23:37:39 +02:00
b461234c65 refactor: separate timeline track content into new file 2025-06-29 23:20:17 +02:00
822323d883 refactor: update timeline to use context menu component 2025-06-29 23:09:40 +02:00
ca29be23ff style: make context menu consistent to dropdown 2025-06-29 23:09:06 +02:00
796308e68e style: make everything consistent in dropdown 2025-06-29 23:08:52 +02:00
507d6a6a7e style: dropdown menu 2025-06-29 22:15:06 +02:00
c414b83bc4 style: remove shadow from card 2025-06-29 22:14:31 +02:00
e4f2ce9221 fix: auto-focus on dropdown trigger and hover effect disappearing 2025-06-29 22:04:35 +02:00
bfba482098 feat: add project page with mock data and update project type to include thumbnail 2025-06-29 21:32:51 +02:00
3bc00f8e40 fix: don't hard-code colors in header 2025-06-29 20:09:43 +02:00
02d7a92e06 fix: hero height 2025-06-29 19:38:30 +02:00
4e0352d4d6 Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-06-29 14:48:26 +02:00
8aa0aeb6e3 style: hero, minor tweaks 2025-06-29 14:36:14 +02:00
90eaa40bc6 fix: Context Menu Placement on edges 2025-06-28 21:39:03 +05:30
93 changed files with 9394 additions and 4385 deletions

View File

@ -10,6 +10,11 @@ Thank you for your interest in contributing to OpenCut! This document provides g
4. Install dependencies: `bun install`
5. Start the development server: `bun run dev`
> **Note:** If you see an error like `Unsupported URL Type "workspace:*"` when running `npm install`, you have two options:
>
> 1. Upgrade to a recent npm version (v9 or later), which has full workspace protocol support.
> 2. Use an alternative package manager such as **bun** or **pnpm**.
## Development Setup
### Prerequisites

View File

@ -31,13 +31,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

2
.npmrc
View File

@ -1,2 +1,2 @@
install-strategy="nested"
node-linker=isolated
node-linker=isolated

151
README.md
View File

@ -10,10 +10,6 @@
</tr>
</table>
## Why?
- **Privacy**: Your videos stay on your device
@ -49,81 +45,122 @@ Before you begin, ensure you have the following installed on your system:
### Setup
1. **Clone the repository**
```bash
git clone <repo-url>
cd OpenCut
```
## Getting Started
2. **Start backend services**
From the project root, start the PostgreSQL and Redis services:
```bash
docker-compose up -d
```
1. Fork the repository
2. Clone your fork locally
3. Navigate to the web app directory: `cd apps/web`
4. Install dependencies: `bun install`
5. Start the development server: `bun run dev`
3. **Set up environment variables**
Navigate into the web app's directory and create a `.env` file from the example:
```bash
cd apps/web
## Development Setup
# Unix/Linux/Mac
cp .env.example .env.local
### Prerequisites
# Windows Command Prompt
copy .env.example .env.local
# Windows PowerShell
Copy-Item .env.example .env.local
```
*The default values in the `.env` file should work for local development.*
- Node.js 18+
- Bun (latest version)
- Docker (for local database)
4. **Install dependencies**
Install the project dependencies using `bun` (recommended) or `npm`.
```bash
# With bun
bun install
### Local Development
# Or with npm
npm install
```
1. Start the database and Redis services:
5. **Run database migrations**
Apply the database schema to your local database:
```bash
# With bun
bun run db:push:local
```bash
# From project root
docker-compose up -d
```
# Or with npm
npm run db:push:local
```
2. Navigate to the web app directory:
6. **Start the development server**
```bash
# With bun
bun run dev
```bash
cd apps/web
```
# Or with npm
npm run dev
```
3. Copy `.env.example` to `.env.local`:
```bash
# Unix/Linux/Mac
cp .env.example .env.local
# Windows Command Prompt
copy .env.example .env.local
# Windows PowerShell
Copy-Item .env.example .env.local
```
4. Configure required environment variables in `.env.local`:
**Required Variables:**
```bash
# Database (matches docker-compose.yaml)
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
# Generate a secure secret for Better Auth
BETTER_AUTH_SECRET="your-generated-secret-here"
BETTER_AUTH_URL="http://localhost:3000"
# Redis (matches docker-compose.yaml)
UPSTASH_REDIS_REST_URL="http://localhost:8079"
UPSTASH_REDIS_REST_TOKEN="example_token"
# Development
NODE_ENV="development"
```
**Generate BETTER_AUTH_SECRET:**
```bash
# Unix/Linux/Mac
openssl rand -base64 32
# Windows PowerShell (simple method)
[System.Web.Security.Membership]::GeneratePassword(32, 0)
# Cross-platform (using Node.js)
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# Or use an online generator: https://generate-secret.vercel.app/32
```
**Optional Variables (for Google OAuth):**
```bash
# Only needed if you want to test Google login
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
```
5. Run database migrations: `bun run db:migrate` from (inside apps/web)
6. Start the development server: `bun run dev` from (inside apps/web)
The application will be available at [http://localhost:3000](http://localhost:3000).
---
## Contributing
Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
---
**Note**: We're currently moving at an extremely fast pace with rapid development and breaking changes. While we appreciate the interest, it's recommended to wait until the project stabilizes before contributing to avoid conflicts and wasted effort.
## Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
Quick start for contributors:
**Quick start for contributors:**
- Fork the repo and clone locally
- Follow the setup instructions in CONTRIBUTING.md
- Create a feature branch and submit a PR
## Sponsors
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FOpenCut-app%2FOpenCut&project-name=opencut&repository-name=opencut)
## License
[MIT LICENSE](LICENSE)
---
![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date)

View File

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

View File

@ -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",
@ -68,4 +68,4 @@
"tsx": "^4.7.1",
"typescript": "^5"
}
}
}

View File

@ -47,7 +47,7 @@ async function getContributors(): Promise<Contributor[]> {
return [];
}
const contributors = await response.json();
const contributors = (await response.json()) as Contributor[];
const filteredContributors = contributors.filter(
(contributor: Contributor) => contributor.type === "User"
@ -78,10 +78,15 @@ export default async function ContributorsPage() {
<div className="relative container mx-auto px-4 py-16">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-20">
<Badge variant="secondary" className="gap-2 mb-6">
<GithubIcon className="h-3 w-3" />
Open Source
</Badge>
<Link
href={"https://github.com/OpenCut-app/OpenCut"}
target="_blank"
>
<Badge variant="secondary" className="gap-2 mb-6">
<GithubIcon className="h-3 w-3" />
Open Source
</Badge>
</Link>
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
Contributors
</h1>
@ -140,9 +145,6 @@ export default async function ContributorsPage() {
{contributor.login.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="absolute -top-2 -right-2 bg-foreground text-background rounded-full w-8 h-8 flex items-center justify-center text-sm font-bold">
{index + 1}
</div>
</div>
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
{contributor.login}

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import { Inter } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { Analytics } from "@vercel/analytics/react";
import Script from "next/script";
@ -6,12 +5,9 @@ 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";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
import { defaultFont } from "../lib/font-config";
export const metadata = baseMetaData;
@ -22,22 +18,23 @@ export default function RootLayout({
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} font-sans antialiased`}>
<ThemeProvider attribute="class" forcedTheme="dark" enableSystem>
<body className={`${defaultFont.className} font-sans antialiased`}>
<ThemeProvider attribute="class" forcedTheme="dark">
<TooltipProvider>
{children}
<StorageProvider>{children}</StorageProvider>
<Analytics />
<Toaster />
<DevelopmentDebug />
<Script
src="https://app.databuddy.cc/databuddy.js"
src="https://cdn.databuddy.cc/databuddy.js"
strategy="afterInteractive"
async
data-client-id="UP-Wcoy5arxFeK7oyjMMZ"
data-track-attributes={true}
data-track-attributes={false}
data-track-errors={true}
data-track-outgoing-links={true}
data-track-web-vitals={true}
data-track-outgoing-links={false}
data-track-web-vitals={false}
data-track-sessions={false}
/>
</TooltipProvider>
</ThemeProvider>

View File

@ -2,7 +2,6 @@ import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
import { getWaitlistCount } from "@/lib/waitlist";
import Image from "next/image";
// Force dynamic rendering so waitlist count updates in real-time
export const dynamic = "force-dynamic";
@ -12,13 +11,6 @@ export default async function Home() {
return (
<div>
<Image
className="fixed top-0 left-0 -z-50 size-full object-cover"
src="/landing-page-bg.png"
height={1903.5}
width={1269}
alt="landing-page.bg"
/>
<Header />
<Hero signupCount={signupCount} />
<Footer />

View File

@ -0,0 +1,552 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
ChevronLeft,
Plus,
Calendar,
MoreHorizontal,
Video,
Loader2,
X,
Trash2,
} from "lucide-react";
import { TProject } from "@/types/project";
import Image from "next/image";
import {
DropdownMenu,
DropdownMenuItem,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { useProjectStore } from "@/stores/project-store";
import { useRouter } from "next/navigation";
import { DeleteProjectDialog } from "@/components/delete-project-dialog";
import { RenameProjectDialog } from "@/components/rename-project-dialog";
export default function ProjectsPage() {
const {
createNewProject,
savedProjects,
isLoading,
isInitialized,
deleteProject,
} = useProjectStore();
const router = useRouter();
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(
new Set()
);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const handleCreateProject = async () => {
const projectId = await createNewProject("New Project");
console.log("projectId", projectId);
router.push(`/editor/${projectId}`);
};
const handleSelectProject = (projectId: string, checked: boolean) => {
const newSelected = new Set(selectedProjects);
if (checked) {
newSelected.add(projectId);
} else {
newSelected.delete(projectId);
}
setSelectedProjects(newSelected);
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedProjects(new Set(savedProjects.map((p) => p.id)));
} else {
setSelectedProjects(new Set());
}
};
const handleCancelSelection = () => {
setIsSelectionMode(false);
setSelectedProjects(new Set());
};
const handleBulkDelete = async () => {
await Promise.all(
Array.from(selectedProjects).map((projectId) => deleteProject(projectId))
);
setSelectedProjects(new Set());
setIsSelectionMode(false);
setIsBulkDeleteDialogOpen(false);
};
const allSelected =
savedProjects.length > 0 && selectedProjects.size === savedProjects.length;
const someSelected =
selectedProjects.size > 0 && selectedProjects.size < savedProjects.length;
return (
<div className="min-h-screen bg-background">
<div className="pt-6 px-6 flex items-center justify-between w-full h-16">
<Link
href="/"
className="flex items-center gap-1 hover:text-muted-foreground transition-colors"
>
<ChevronLeft className="!size-5 shrink-0" />
<span className="text-sm font-medium">Back</span>
</Link>
<div className="block md:hidden">
{isSelectionMode ? (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCancelSelection}
>
<X className="!size-4" />
Cancel
</Button>
{selectedProjects.size > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<Trash2 className="!size-4" />
Delete ({selectedProjects.size})
</Button>
)}
</div>
) : (
<CreateButton onClick={handleCreateProject} />
)}
</div>
</div>
<main className="max-w-6xl mx-auto px-6 pt-6 pb-6">
<div className="mb-8 flex items-center justify-between">
<div className="flex flex-col gap-3">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
Your Projects
</h1>
<p className="text-muted-foreground">
{savedProjects.length}{" "}
{savedProjects.length === 1 ? "project" : "projects"}
{isSelectionMode && selectedProjects.size > 0 && (
<span className="ml-2 text-primary">
{selectedProjects.size} selected
</span>
)}
</p>
</div>
<div className="hidden md:block">
{isSelectionMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancelSelection}>
<X className="!size-4" />
Cancel
</Button>
{selectedProjects.size > 0 && (
<Button
variant="destructive"
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<Trash2 className="!size-4" />
Delete Selected ({selectedProjects.size})
</Button>
)}
</div>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setIsSelectionMode(true)}
disabled={savedProjects.length === 0}
>
Select Projects
</Button>
<CreateButton onClick={handleCreateProject} />
</div>
)}
</div>
</div>
{isSelectionMode && savedProjects.length > 0 && (
<div className="mb-6 p-4 bg-muted/30 rounded-lg border">
<div className="flex items-center gap-3">
<Checkbox
checked={allSelected}
ref={(el) => {
if (el) {
const checkboxElement = el.querySelector(
"input"
) as HTMLInputElement;
if (checkboxElement) {
checkboxElement.indeterminate = someSelected;
}
}
}}
onCheckedChange={handleSelectAll}
/>
<span className="text-sm font-medium">
{allSelected ? "Deselect All" : "Select All"}
</span>
<span className="text-sm text-muted-foreground">
({selectedProjects.size} of {savedProjects.length} selected)
</span>
</div>
</div>
)}
{isLoading || !isInitialized ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
</div>
) : savedProjects.length === 0 ? (
<NoProjects onCreateProject={handleCreateProject} />
) : (
<div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
{savedProjects.map((project) => (
<ProjectCard
key={project.id}
project={project}
isSelectionMode={isSelectionMode}
isSelected={selectedProjects.has(project.id)}
onSelect={handleSelectProject}
/>
))}
</div>
)}
</main>
<DeleteProjectDialog
isOpen={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
onConfirm={handleBulkDelete}
/>
</div>
);
}
interface ProjectCardProps {
project: TProject;
isSelectionMode?: boolean;
isSelected?: boolean;
onSelect?: (projectId: string, checked: boolean) => void;
}
function ProjectCard({
project,
isSelectionMode = false,
isSelected = false,
onSelect,
}: ProjectCardProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const { deleteProject, renameProject, duplicateProject } = useProjectStore();
const formatDate = (date: Date): string => {
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
const handleDeleteProject = async () => {
await deleteProject(project.id);
setIsDropdownOpen(false);
};
const handleRenameProject = async (newName: string) => {
await renameProject(project.id, newName);
setIsRenameDialogOpen(false);
};
const handleDuplicateProject = async () => {
setIsDropdownOpen(false);
await duplicateProject(project.id);
};
const handleCardClick = (e: React.MouseEvent) => {
if (isSelectionMode) {
e.preventDefault();
onSelect?.(project.id, !isSelected);
}
};
return (
<>
{isSelectionMode ? (
<div onClick={handleCardClick} className="block group cursor-pointer">
<Card
className={`overflow-hidden bg-background border-none p-0 transition-all ${
isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
}`}
>
<div
className={`relative aspect-square bg-muted transition-opacity ${
isDropdownOpen
? "opacity-65"
: "opacity-100 group-hover:opacity-65"
}`}
>
{/* Selection checkbox */}
{isSelectionMode && (
<div className="absolute top-3 left-3 z-10">
<div className="w-5 h-5 rounded bg-background/80 backdrop-blur-sm border flex items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
onSelect?.(project.id, checked as boolean)
}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4"
/>
</div>
</div>
)}
{/* Thumbnail preview or placeholder */}
<div className="absolute inset-0">
{project.thumbnail ? (
<Image
src={project.thumbnail}
alt="Project thumbnail"
fill
className="object-cover"
/>
) : (
<div className="w-full h-full bg-muted/50 flex items-center justify-center">
<Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" />
</div>
)}
</div>
</div>
<CardContent className="px-0 pt-5 flex flex-col gap-1">
<div className="flex items-start justify-between">
<h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
{project.name}
</h3>
{!isSelectionMode && (
<DropdownMenu
open={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
>
<DropdownMenuTrigger asChild>
<Button
variant="text"
size="sm"
className={`size-6 p-0 transition-all shrink-0 ml-2 ${
isDropdownOpen
? "opacity-100"
: "opacity-0 group-hover:opacity-100"
}`}
onClick={(e) => e.preventDefault()}
>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDropdownOpen(false);
setIsRenameDialogOpen(true);
}}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDuplicateProject();
}}
>
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDropdownOpen(false);
setIsDeleteDialogOpen(true);
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="!size-4" />
<span>Created {formatDate(project.createdAt)}</span>
</div>
</div>
</CardContent>
</Card>
</div>
) : (
<Link href={`/editor/${project.id}`} className="block group">
<Card
className={`overflow-hidden bg-background border-none p-0 transition-all ${
isSelectionMode && isSelected ? "ring-2 ring-primary" : ""
}`}
>
<div
className={`relative aspect-square bg-muted transition-opacity ${
isDropdownOpen
? "opacity-65"
: "opacity-100 group-hover:opacity-65"
}`}
>
{/* Thumbnail preview or placeholder */}
<div className="absolute inset-0">
{project.thumbnail ? (
<Image
src={project.thumbnail}
alt="Project thumbnail"
fill
className="object-cover"
/>
) : (
<div className="w-full h-full bg-muted/50 flex items-center justify-center">
<Video className="h-12 w-12 flex-shrink-0 text-muted-foreground" />
</div>
)}
</div>
</div>
<CardContent className="px-0 pt-5 flex flex-col gap-1">
<div className="flex items-start justify-between">
<h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
{project.name}
</h3>
<DropdownMenu
open={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
>
<DropdownMenuTrigger asChild>
<Button
variant="text"
size="sm"
className={`size-6 p-0 transition-all shrink-0 ml-2 ${
isDropdownOpen
? "opacity-100"
: "opacity-0 group-hover:opacity-100"
}`}
onClick={(e) => e.preventDefault()}
>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDropdownOpen(false);
setIsRenameDialogOpen(true);
}}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDuplicateProject();
}}
>
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDropdownOpen(false);
setIsDeleteDialogOpen(true);
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="!size-4" />
<span>Created {formatDate(project.createdAt)}</span>
</div>
</div>
</CardContent>
</Card>
</Link>
)}
<DeleteProjectDialog
isOpen={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleDeleteProject}
/>
<RenameProjectDialog
isOpen={isRenameDialogOpen}
onOpenChange={setIsRenameDialogOpen}
onConfirm={handleRenameProject}
projectName={project.name}
/>
</>
);
}
function CreateButton({ onClick }: { onClick?: () => void }) {
return (
<Button className="flex" onClick={onClick}>
<Plus className="!size-4" />
<span className="text-sm font-medium">New project</span>
</Button>
);
}
function NoProjects({ onCreateProject }: { onCreateProject: () => void }) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
<Video className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">No projects yet</h3>
<p className="text-muted-foreground mb-6 max-w-md">
Start creating your first video project. Import media, edit, and export
professional videos.
</p>
<Button size="lg" className="gap-2" onClick={onCreateProject}>
<Plus className="h-4 w-4" />
Create Your First Project
</Button>
</div>
);
}

View File

@ -0,0 +1,191 @@
import { Header } from "@/components/header";
export default function WhyNotCapcut() {
return (
<div className="min-h-screen bg-background px-5">
<Header />
<main className="relative mt-12">
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -right-40 w-96 h-96 bg-gradient-to-br from-muted/20 to-transparent rounded-full blur-3xl" />
<div className="absolute top-1/2 -left-40 w-80 h-80 bg-gradient-to-tr from-muted/10 to-transparent rounded-full blur-3xl" />
</div>
<div className="relative container mx-auto px-4 py-16">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-20">
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
Fuck CapCut
</h1>
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto leading-relaxed">
Roasting time, so get ready motherfucker.
</p>
</div>
<div className="max-w-4xl mx-auto space-y-12">
<div>
<h2 className="text-3xl font-bold mb-6">
Seriously, what the fuck else do you want?
</h2>
<p className="text-lg mb-6">
You probably use CapCut and think your video editing is
special. You think your fucking TikTok with 47 transitions and
12 different fonts is going to get you some viral fame. You
think loading up every goddamn effect in their library makes
your content better. Wrong, motherfucker. Let me describe what
CapCut actually gives you:
</p>
<ul className="text-lg space-y-2 mb-6 list-disc list-inside">
<li>A paywall every time you breathe</li>
<li>Terms of service that steal your shit</li>
<li>
More "Get Pro" dialogs than a Windows 95 error message
</li>
<li>
Features that disappear behind paywalls while you're fucking
using them
</li>
<li>Bugs disguised as "premium features"</li>
</ul>
<p className="text-lg mb-6">
<strong>Well guess what, motherfucker:</strong>
</p>
<p className="text-lg mb-6">
You. Are. Getting. Scammed. Look at this shit. It's a fucking
video editor. Why the fuck do you need to pay $20/month just
to remove a goddamn watermark? You spent hours editing your
video and they slap their logo on it like they fucking made
it.
</p>
</div>
<div>
<h2 className="text-3xl font-bold mb-6">
The "Get Pro" dialog is everywhere
</h2>
<p className="text-lg mb-6">
This motherfucking dialog pops up more than ads on a pirated
movie site. Want to add a transition? Get Pro. Want to export
without their watermark? Get Pro. Want to use more than 2
fonts? Get fucking Pro, peasant.
</p>
<p className="text-lg mb-6">
Did you seriously think you could edit a video without seeing
this dialog 47 times? You click one button and BAM - there it
is again, asking for your credit card like a desperate ex
asking for money.
</p>
</div>
<div>
<h2 className="text-3xl font-bold mb-6">
Everything costs money now
</h2>
<p className="text-lg mb-6">
You dumbass. You thought CapCut was free, but no. Free means
they let you open the app. Everything else costs money. Basic
shake effect? That'll be $20/month. A decent transition that isn't
"fade"? Pay up, motherfucker.
</p>
<p className="text-lg mb-6">
Here's my favorite piece of bullshit: You import an MP3 file -
you know, AUDIO - and try to export. "Sorry, can't export
because you're using our premium extract audio feature!"
</p>
<p className="text-lg mb-6">
<strong>
My MP3 was already fucking audio, you absolute morons.
</strong>
</p>
<p className="text-lg mb-6">
But wait, there's more! If you drag that same MP3 to their
media panel first, then to the timeline, it magically works.
This isn't a bug, it's a fucking scam disguised as software
engineering.
</p>
</div>
<div>
<h2 className="text-3xl font-bold mb-6">
Their Terms of Service are insane
</h2>
<p className="text-lg mb-6">
Look at this shit. You upload your content and they basically
say "thanks for the free content, we own it now, but if Disney
sues anyone, that's your problem."
</p>
<p className="text-lg mb-6">
<strong>CapCut's Terms of Service:</strong> We get full rights
to use, modify, distribute, and monetize everything you upload
- permanently and without paying you shit. But you're still
responsible if anything goes wrong.
</p>
<p className="text-lg mb-6">
Translation: "We'll make money off your viral video, you
handle the lawsuits." Brilliant legal strategy, you fucks.
</p>
</div>
<div>
<h2 className="text-3xl font-bold mb-6">
The editor is actually good
</h2>
<p className="text-lg mb-6">
Here's the thing that makes me want to punch my monitor: the
actual video editor is fucking good. It's intuitive, powerful,
and anyone can figure it out. When it's not begging for money
every 30 seconds, it actually works well.
</p>
<p className="text-lg mb-6">
Which makes everything else so much worse. They built
something people want to use, then turned it into a digital
slot machine. Every click might trigger a payment request.
</p>
</div>
<div>
<h2 className="text-3xl font-bold mb-6">
This is a video editor. Look at it. You've never seen one
before.
</h2>
<p className="text-lg mb-6">
Like the person who's never used software that doesn't
constantly beg for money, you have no fucking idea what a
video editor should be. All you've ever seen are predatory
apps disguised as creative tools.
</p>
<p className="text-lg mb-6">
A real video editor lets you edit videos. It doesn't steal
your content. It doesn't pop up payment dialogs every 5
seconds. It doesn't charge you separately for basic features
that should be free.
</p>
</div>
<div>
<h2 className="text-3xl font-bold mb-6">
Yes, this is fucking satire, you fuck
</h2>
<p className="text-lg mb-6">
I'm not actually saying all video editors should be basic as
shit. What I'm saying is that all the problems we have with
video editing apps are{" "}
<strong>ones they create themselves</strong>. Video editors
aren't broken by default - they edit videos, export them, and
let you use basic features without constantly begging for
money. CapCut breaks them. They turn them into payment
processors with video editing as a side feature.
</p>
<p className="text-lg">
<em>"Good software gets out of your way."</em>
<br />- Some smart motherfucker who definitely wasn't working
at CapCut
</p>
</div>
</div>
</div>
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,184 @@
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Button } from "./ui/button";
import { BackgroundIcon } from "./icons";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { colors } from "@/data/colors";
import { useProjectStore } from "@/stores/project-store";
import { PipetteIcon } from "lucide-react";
type BackgroundTab = "color" | "blur";
export function BackgroundSettings() {
const { activeProject, updateBackgroundType } = useProjectStore();
// ✅ Good: derive activeTab from activeProject during rendering
const activeTab = activeProject?.backgroundType || "color";
const handleColorSelect = (color: string) => {
updateBackgroundType("color", { backgroundColor: color });
};
const handleBlurSelect = (blurIntensity: number) => {
updateBackgroundType("blur", { blurIntensity });
};
const tabs = [
{
label: "Color",
value: "color",
},
{
label: "Blur",
value: "blur",
},
];
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="text"
size="icon"
className="!size-4 border border-muted-foreground"
>
<BackgroundIcon className="!size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col items-start w-[20rem] h-[16rem] overflow-hidden p-0">
<div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3">
<h2 className="text-sm">Background</h2>
<div className="flex items-center gap-2 text-sm">
{tabs.map((tab) => (
<span
key={tab.value}
onClick={() => {
// Switch to the background type when clicking tabs
if (tab.value === "color") {
updateBackgroundType("color", {
backgroundColor:
activeProject?.backgroundColor || "#000000",
});
} else {
updateBackgroundType("blur", {
blurIntensity: activeProject?.blurIntensity || 8,
});
}
}}
className={cn(
"text-muted-foreground cursor-pointer",
activeTab === tab.value && "text-foreground"
)}
>
{tab.label}
</span>
))}
</div>
</div>
{activeTab === "color" ? (
<ColorView
selectedColor={activeProject?.backgroundColor || "#000000"}
onColorSelect={handleColorSelect}
/>
) : (
<BlurView
selectedBlur={activeProject?.blurIntensity || 8}
onBlurSelect={handleBlurSelect}
/>
)}
</PopoverContent>
</Popover>
);
}
function ColorView({
selectedColor,
onColorSelect,
}: {
selectedColor: string;
onColorSelect: (color: string) => void;
}) {
return (
<div className="w-full h-full">
<div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-gradient-to-b from-popover to-transparent pointer-events-none"></div>
<div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto">
<div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center">
<PipetteIcon className="size-4" />
</div>
{colors.map((color) => (
<ColorItem
key={color}
color={color}
isSelected={color === selectedColor}
onClick={() => onColorSelect(color)}
/>
))}
</div>
</div>
);
}
function ColorItem({
color,
isSelected,
onClick,
}: {
color: string;
isSelected: boolean;
onClick: () => void;
}) {
return (
<div
className={cn(
"w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary",
isSelected && "border-2 border-primary"
)}
style={{ backgroundColor: color }}
onClick={onClick}
/>
);
}
function BlurView({
selectedBlur,
onBlurSelect,
}: {
selectedBlur: number;
onBlurSelect: (blurIntensity: number) => void;
}) {
const blurLevels = [
{ label: "Light", value: 4 },
{ label: "Medium", value: 8 },
{ label: "Heavy", value: 18 },
];
const blurImage =
"https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";
return (
<div className="grid grid-cols-3 gap-2 w-full p-3 pt-0">
{blurLevels.map((blur) => (
<div
key={blur.value}
className={cn(
"w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden",
selectedBlur === blur.value && "border-2 border-primary"
)}
onClick={() => onBlurSelect(blur.value)}
>
<Image
src={blurImage}
alt={`Blur preview ${blur.label}`}
fill
className="object-cover"
style={{ filter: `blur(${blur.value}px)` }}
/>
<div className="absolute bottom-1 left-1 right-1 text-center">
<span className="text-xs text-white bg-black/50 px-1 rounded">
{blur.label}
</span>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,53 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function DeleteProjectDialog({
isOpen,
onOpenChange,
onConfirm,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}) {
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent
onOpenAutoFocus={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<DialogHeader>
<DialogTitle>Delete Project</DialogTitle>
<DialogDescription>
Are you sure you want to delete this project? This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenChange(false);
}}
>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,20 +1,18 @@
"use client";
import {
useTimelineStore,
type TimelineClip,
type TimelineTrack,
} from "@/stores/timeline-store";
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 ActiveClip {
clip: TimelineClip;
interface ActiveElement {
element: TimelineElement;
track: TimelineTrack;
mediaItem: MediaItem | null;
}
@ -28,31 +26,32 @@ export function DevelopmentDebug() {
// Don't render anything in production
if (!SHOW_DEBUG_INFO) return null;
// Get active clips at current time
const getActiveClips = (): ActiveClip[] => {
const activeClips: ActiveClip[] = [];
// Get active elements at current time
const getActiveElements = (): ActiveElement[] => {
const activeElements: ActiveElement[] = [];
tracks.forEach((track) => {
track.clips.forEach((clip) => {
const clipStart = clip.startTime;
const clipEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
track.elements.forEach((element) => {
const elementStart = element.startTime;
const elementEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime >= clipStart && currentTime < clipEnd) {
if (currentTime >= elementStart && currentTime < elementEnd) {
const mediaItem =
clip.mediaId === "test"
? null // Test clips don't have a real media item
: mediaItems.find((item) => item.id === clip.mediaId) || null;
element.type === "media"
? mediaItems.find((item) => item.id === element.mediaId) || null
: null; // Text elements don't have media items
activeClips.push({ clip, track, mediaItem });
activeElements.push({ element, track, mediaItem });
}
});
});
return activeClips;
return activeElements;
};
const activeClips = getActiveClips();
const activeElements = getActiveElements();
return (
<div className="fixed bottom-4 right-4 z-50">
@ -71,28 +70,30 @@ export function DevelopmentDebug() {
{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 Clips ({activeClips.length})
Active Elements ({activeElements.length})
</div>
<div className="space-y-1 max-h-40 overflow-y-auto">
{activeClips.map((clipData, index) => (
{activeElements.map((elementData, index) => (
<div
key={clipData.clip.id}
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">{clipData.clip.name}</div>
<div className="truncate">{elementData.element.name}</div>
<div className="text-muted-foreground text-[10px]">
{clipData.mediaItem?.type || "test"}
{elementData.element.type === "media"
? elementData.mediaItem?.type || "media"
: "text"}
</div>
</div>
</div>
))}
{activeClips.length === 0 && (
{activeElements.length === 0 && (
<div className="text-muted-foreground text-xs py-2 text-center">
No active clips
No active elements
</div>
)}
</div>

View File

@ -5,44 +5,50 @@ import { Button } from "./ui/button";
import { ChevronLeft, Download } from "lucide-react";
import { useTimelineStore } from "@/stores/timeline-store";
import { HeaderBase } from "./header-base";
import { ProjectNameEditor } from "./editor/project-name-editor";
import { formatTimeCode } from "@/lib/time";
import { useProjectStore } from "@/stores/project-store";
export function EditorHeader() {
const { getTotalDuration } = useTimelineStore();
const { activeProject } = useProjectStore();
const handleExport = () => {
// TODO: Implement export functionality
console.log("Export project");
};
// Format duration from seconds to MM:SS format
const formatDuration = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
};
const leftContent = (
<div className="flex items-center gap-2">
<Link
href="/"
href="/projects"
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<ChevronLeft className="h-4 w-4" />
<span className="text-sm">{activeProject?.name}</span>
</Link>
<ProjectNameEditor />
</div>
);
const centerContent = (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatDuration(getTotalDuration())}</span>
<div className="flex items-center gap-2 text-xs">
<span>
{formatTimeCode(
getTotalDuration(),
"HH:MM:SS:FF",
activeProject?.fps || 30
)}
</span>
</div>
);
const rightContent = (
<nav className="flex items-center gap-2">
<Button size="sm" onClick={handleExport}>
<Button
size="sm"
variant="primary"
className="h-7 text-xs"
onClick={handleExport}
>
<Download className="h-4 w-4" />
<span className="text-sm">Export</span>
</Button>
@ -54,7 +60,7 @@ export function EditorHeader() {
leftContent={leftContent}
centerContent={centerContent}
rightContent={rightContent}
className="bg-background border-b"
className="bg-background h-[3.2rem] px-4"
/>
);
}

View File

@ -0,0 +1,55 @@
"use client";
import { TabBar } from "./tabbar";
import { MediaView } from "./views/media";
import { useMediaPanelStore, Tab } from "./store";
import { TextView } from "./views/text";
export function MediaPanel() {
const { activeTab } = useMediaPanelStore();
const viewMap: Record<Tab, React.ReactNode> = {
media: <MediaView />,
audio: (
<div className="p-4 text-muted-foreground">Audio view coming soon...</div>
),
text: <TextView />,
stickers: (
<div className="p-4 text-muted-foreground">
Stickers view coming soon...
</div>
),
effects: (
<div className="p-4 text-muted-foreground">
Effects view coming soon...
</div>
),
transitions: (
<div className="p-4 text-muted-foreground">
Transitions view coming soon...
</div>
),
captions: (
<div className="p-4 text-muted-foreground">
Captions view coming soon...
</div>
),
filters: (
<div className="p-4 text-muted-foreground">
Filters view coming soon...
</div>
),
adjustment: (
<div className="p-4 text-muted-foreground">
Adjustment view coming soon...
</div>
),
};
return (
<div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden">
<TabBar />
<div className="flex-1">{viewMap[activeTab]}</div>
</div>
);
}

View File

@ -0,0 +1,73 @@
import {
CaptionsIcon,
ArrowLeftRightIcon,
SparklesIcon,
StickerIcon,
MusicIcon,
VideoIcon,
BlendIcon,
SlidersHorizontalIcon,
LucideIcon,
TypeIcon,
} from "lucide-react";
import { create } from "zustand";
export type Tab =
| "media"
| "audio"
| "text"
| "stickers"
| "effects"
| "transitions"
| "captions"
| "filters"
| "adjustment";
export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
media: {
icon: VideoIcon,
label: "Media",
},
audio: {
icon: MusicIcon,
label: "Audio",
},
text: {
icon: TypeIcon,
label: "Text",
},
stickers: {
icon: StickerIcon,
label: "Stickers",
},
effects: {
icon: SparklesIcon,
label: "Effects",
},
transitions: {
icon: ArrowLeftRightIcon,
label: "Transitions",
},
captions: {
icon: CaptionsIcon,
label: "Captions",
},
filters: {
icon: BlendIcon,
label: "Filters",
},
adjustment: {
icon: SlidersHorizontalIcon,
label: "Adjustment",
},
};
interface MediaPanelStore {
activeTab: Tab;
setActiveTab: (tab: Tab) => void;
}
export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
activeTab: "media",
setActiveTab: (tab) => set({ activeTab: tab }),
}));

View File

@ -0,0 +1,124 @@
"use client";
import { cn } from "@/lib/utils";
import { Tab, tabs, useMediaPanelStore } from "./store";
import { Button } from "@/components/ui/button";
import { ChevronRight, ChevronLeft } from "lucide-react";
import { useRef, useState, useEffect } from "react";
export function TabBar() {
const { activeTab, setActiveTab } = useMediaPanelStore();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [isAtEnd, setIsAtEnd] = useState(false);
const [isAtStart, setIsAtStart] = useState(true);
const scrollToEnd = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: scrollContainerRef.current.scrollWidth,
});
setIsAtEnd(true);
setIsAtStart(false);
}
};
const scrollToStart = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: 0,
});
setIsAtStart(true);
setIsAtEnd(false);
}
};
const checkScrollPosition = () => {
if (scrollContainerRef.current) {
const { scrollLeft, scrollWidth, clientWidth } =
scrollContainerRef.current;
const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1;
const isAtStartNow = scrollLeft <= 1;
setIsAtEnd(isAtEndNow);
setIsAtStart(isAtStartNow);
}
};
// We're using useEffect because we need to sync with external DOM scroll events
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
checkScrollPosition();
container.addEventListener("scroll", checkScrollPosition);
const resizeObserver = new ResizeObserver(checkScrollPosition);
resizeObserver.observe(container);
return () => {
container.removeEventListener("scroll", checkScrollPosition);
resizeObserver.disconnect();
};
}, []);
return (
<div className="flex">
<ScrollButton
direction="left"
onClick={scrollToStart}
isVisible={!isAtStart}
/>
<div
ref={scrollContainerRef}
className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative"
>
{(Object.keys(tabs) as Tab[]).map((tabKey) => {
const tab = tabs[tabKey];
return (
<div
className={cn(
"flex flex-col gap-0.5 items-center cursor-pointer",
activeTab === tabKey ? "text-primary" : "text-muted-foreground"
)}
onClick={() => setActiveTab(tabKey)}
key={tabKey}
>
<tab.icon className="!size-[1.1rem]" />
<span className="text-[0.65rem]">{tab.label}</span>
</div>
);
})}
</div>
<ScrollButton
direction="right"
onClick={scrollToEnd}
isVisible={!isAtEnd}
/>
</div>
);
}
function ScrollButton({
direction,
onClick,
isVisible,
}: {
direction: "left" | "right";
onClick: () => void;
isVisible: boolean;
}) {
if (!isVisible) return null;
const Icon = direction === "left" ? ChevronLeft : ChevronRight;
return (
<div className="bg-panel-accent w-12 h-full flex items-center justify-center">
<Button
size="icon"
className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10"
onClick={onClick}
>
<Icon className="!size-4 text-foreground" />
</Button>
</div>
);
}

View File

@ -1,331 +1,311 @@
"use client";
import { useDragDrop } from "@/hooks/use-drag-drop";
import { processMediaFiles } from "@/lib/media-processing";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { Image, Music, Plus, Trash2, Upload, Video } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { AspectRatio } from "../ui/aspect-ratio";
import { Button } from "../ui/button";
import { DragOverlay } from "../ui/drag-overlay";
// MediaPanel lets users add, view, and drag media (images, videos, audio) into the project.
// You can upload files or drag them from your computer. Dragging from here to the timeline adds them to your video project.
export function MediaPanel() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [mediaFilter, setMediaFilter] = useState("all");
const processFiles = async (files: FileList | File[]) => {
if (!files || files.length === 0) return;
setIsProcessing(true);
setProgress(0);
try {
// Process files (extract metadata, generate thumbnails, etc.)
const processedItems = await processMediaFiles(files, (p) =>
setProgress(p)
);
// Add each processed media item to the store
processedItems.forEach((item) => addMediaItem(item));
} catch (error) {
// Show error toast if processing fails
console.error("Error processing files:", error);
toast.error("Failed to process files");
} finally {
setIsProcessing(false);
setProgress(0);
}
};
const { isDragOver, dragProps } = useDragDrop({
// When files are dropped, process them
onDrop: processFiles,
});
const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// When files are selected via file picker, process them
if (e.target.files) processFiles(e.target.files);
e.target.value = ""; // Reset input
};
const handleRemove = (e: React.MouseEvent, id: string) => {
// Remove a media item from the store
e.stopPropagation();
// Remove tracks automatically when delete media
const { tracks, removeTrack } = useTimelineStore.getState();
tracks.forEach((track) => {
const clipsToRemove = track.clips.filter((clip) => clip.mediaId === id);
clipsToRemove.forEach((clip) => {
useTimelineStore.getState().removeClipFromTrack(track.id, clip.id);
});
// Only remove track if it becomes empty and has no other clips
const updatedTrack = useTimelineStore.getState().tracks.find(t => t.id === track.id);
if (updatedTrack && updatedTrack.clips.length === 0) {
removeTrack(track.id);
}
});
removeMediaItem(id);
};
const formatDuration = (duration: number) => {
// Format seconds as mm:ss
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
};
const startDrag = (e: React.DragEvent, item: MediaItem) => {
// When dragging a media item, set drag data for timeline to read
e.dataTransfer.setData(
"application/x-media-item",
JSON.stringify({
id: item.id,
type: item.type,
name: item.name,
})
);
e.dataTransfer.effectAllowed = "copy";
};
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
useEffect(() => {
const filtered = mediaItems.filter((item) => {
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
return false;
}
if (
searchQuery &&
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
return true;
});
setFilteredMediaItems(filtered);
}, [mediaItems, mediaFilter, searchQuery]);
const renderPreview = (item: MediaItem) => {
// Render a preview for each media type (image, video, audio, unknown)
// Each preview is draggable to the timeline
const baseDragProps = {
draggable: true,
onDragStart: (e: React.DragEvent) => startDrag(e, item),
};
if (item.type === "image") {
return (
<img
src={item.url}
alt={item.name}
className="w-full h-full object-cover rounded cursor-grab active:cursor-grabbing"
loading="lazy"
{...baseDragProps}
/>
);
}
if (item.type === "video") {
if (item.thumbnailUrl) {
return (
<div
className="relative w-full h-full cursor-grab active:cursor-grabbing"
{...baseDragProps}
>
<img
src={item.thumbnailUrl}
alt={item.name}
className="w-full h-full object-cover rounded"
loading="lazy"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
<Video className="h-6 w-6 text-white drop-shadow-md" />
</div>
{item.duration && (
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
{formatDuration(item.duration)}
</div>
)}
</div>
);
}
return (
<div
className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded cursor-grab active:cursor-grabbing"
{...baseDragProps}
>
<Video className="h-6 w-6 mb-1" />
<span className="text-xs">Video</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
);
}
if (item.type === "audio") {
return (
<div
className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20 cursor-grab active:cursor-grabbing"
{...baseDragProps}
>
<Music className="h-6 w-6 mb-1" />
<span className="text-xs">Audio</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
);
}
return (
<div
className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded cursor-grab active:cursor-grabbing"
{...baseDragProps}
>
<Image className="h-6 w-6" />
<span className="text-xs mt-1">Unknown</span>
</div>
);
};
return (
<>
{/* Hidden file input for uploading media */}
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*,audio/*"
multiple
className="hidden"
onChange={handleFileChange}
/>
<div
className={`h-full flex flex-col transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
{...dragProps}
>
{/* Show overlay when dragging files over the panel */}
<DragOverlay isVisible={isDragOver} />
<div className="p-2 border-b">
{/* Button to add/upload media */}
<div className="flex gap-2">
{/* Search and filter controls */}
<select
value={mediaFilter}
onChange={(e) => setMediaFilter(e.target.value)}
className="px-2 py-1 text-xs border rounded bg-background"
>
<option value="all">All</option>
<option value="video">Video</option>
<option value="audio">Audio</option>
<option value="image">Image</option>
</select>
<input
type="text"
placeholder="Search media..."
className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{/* Add media button */}
<Button
variant="outline"
size="sm"
onClick={handleFileSelect}
disabled={isProcessing}
className="flex-none min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
>
{isProcessing ? (
<>
<Upload className="h-4 w-4 animate-spin" />
<span className="hidden md:inline ml-2">{progress}%</span>
</>
) : (
<>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline ml-2" aria-label="Add file">
Add
</span>
</>
)}
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{/* 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>
) : (
<div className="grid grid-cols-2 gap-2">
{/* Render each media item as a draggable button */}
{filteredMediaItems.map((item) => (
<div key={item.id} className="relative group">
<Button
variant="outline"
className="flex flex-col gap-2 p-2 h-auto w-full relative"
>
<AspectRatio ratio={item.aspectRatio}>
{renderPreview(item)}
</AspectRatio>
<span
className="text-xs truncate px-1 max-w-full"
aria-label={item.name}
title={item.name}
>
{item.name.length > 8
? `${item.name.slice(0, 4)}...${item.name.slice(-3)}`
: item.name}
</span>
</Button>
{/* Show remove button on hover */}
<div className="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="destructive"
size="icon"
className="h-6 w-6"
onClick={(e) => handleRemove(e, item.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</>
);
}
"use client";
import { useDragDrop } from "@/hooks/use-drag-drop";
import { processMediaFiles } from "@/lib/media-processing";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { Image, Music, Plus, Upload, Video } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { DragOverlay } from "@/components/ui/drag-overlay";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { useProjectStore } from "@/stores/project-store";
export function MediaView() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
const { activeProject } = useProjectStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [mediaFilter, setMediaFilter] = useState("all");
const processFiles = async (files: FileList | File[]) => {
if (!files || files.length === 0) return;
if (!activeProject) {
toast.error("No active project");
return;
}
setIsProcessing(true);
setProgress(0);
try {
// Process files (extract metadata, generate thumbnails, etc.)
const processedItems = await processMediaFiles(files, (p) =>
setProgress(p)
);
// Add each processed media item to the store
for (const item of processedItems) {
await addMediaItem(activeProject.id, item);
}
} catch (error) {
// Show error toast if processing fails
console.error("Error processing files:", error);
toast.error("Failed to process files");
} finally {
setIsProcessing(false);
setProgress(0);
}
};
const { isDragOver, dragProps } = useDragDrop({
// When files are dropped, process them
onDrop: processFiles,
});
const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// When files are selected via file picker, process them
if (e.target.files) processFiles(e.target.files);
e.target.value = ""; // Reset input
};
const handleRemove = async (e: React.MouseEvent, id: string) => {
// Remove a media item from the store
e.stopPropagation();
if (!activeProject) {
toast.error("No active project");
return;
}
// Media store now handles cascade deletion automatically
await removeMediaItem(activeProject.id, id);
};
const formatDuration = (duration: number) => {
// Format seconds as mm:ss
const min = Math.floor(duration / 60);
const sec = Math.floor(duration % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
};
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems);
useEffect(() => {
const filtered = mediaItems.filter((item) => {
if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
return false;
}
if (
searchQuery &&
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
return true;
});
setFilteredMediaItems(filtered);
}, [mediaItems, mediaFilter, searchQuery]);
const renderPreview = (item: MediaItem) => {
// Render a preview for each media type (image, video, audio, unknown)
if (item.type === "image") {
return (
<div className="w-full h-full flex items-center justify-center">
<img
src={item.url}
alt={item.name}
className="max-w-full max-h-full object-contain"
loading="lazy"
/>
</div>
);
}
if (item.type === "video") {
if (item.thumbnailUrl) {
return (
<div className="relative w-full h-full">
<img
src={item.thumbnailUrl}
alt={item.name}
className="w-full h-full object-cover rounded"
loading="lazy"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
<Video className="h-6 w-6 text-white drop-shadow-md" />
</div>
{item.duration && (
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
{formatDuration(item.duration)}
</div>
)}
</div>
);
}
return (
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
<Video className="h-6 w-6 mb-1" />
<span className="text-xs">Video</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
);
}
if (item.type === "audio") {
return (
<div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
<Music className="h-6 w-6 mb-1" />
<span className="text-xs">Audio</span>
{item.duration && (
<span className="text-xs opacity-70">
{formatDuration(item.duration)}
</span>
)}
</div>
);
}
return (
<div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
<Image className="h-6 w-6" />
<span className="text-xs mt-1">Unknown</span>
</div>
);
};
return (
<>
{/* Hidden file input for uploading media */}
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*,audio/*"
multiple
className="hidden"
onChange={handleFileChange}
/>
<div
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 */}
<div className="flex gap-2">
{/* Search and filter controls */}
<Select value={mediaFilter} onValueChange={setMediaFilter}>
<SelectTrigger className="w-[80px] h-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="">
<SelectItem value="all">All</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
<SelectItem value="image">Image</SelectItem>
</SelectContent>
</Select>
<Input
type="text"
placeholder="Search media..."
className="min-w-[60px] flex-1 h-full text-xs"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{/* Add media button */}
<Button
variant="outline"
size="sm"
onClick={handleFileSelect}
disabled={isProcessing}
className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
>
{isProcessing ? (
<>
<Upload className="h-4 w-4 animate-spin" />
<span className="hidden md:inline ml-2">{progress}%</span>
</>
) : (
<>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline ml-2" aria-label="Add file">
Add
</span>
</>
)}
</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>
) : (
<div
className="grid gap-2"
style={{
gridTemplateColumns: "repeat(auto-fill, 160px)",
}}
>
{/* Render each media item as a draggable button */}
{filteredMediaItems.map((item) => (
<ContextMenu key={item.id}>
<ContextMenuTrigger>
<DraggableMediaItem
name={item.name}
preview={renderPreview(item)}
dragData={{
id: item.id,
type: item.type,
name: item.name,
}}
showPlusOnDrag={false}
rounded={false}
/>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Export clips</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onClick={(e) => handleRemove(e, item.id)}
>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</div>
)}
</div>
</div>
</>
);
}

View File

@ -0,0 +1,24 @@
import { DraggableMediaItem } from "@/components/ui/draggable-item";
export function TextView() {
return (
<div className="p-4">
<DraggableMediaItem
name="Default text"
preview={
<div className="flex items-center justify-center w-full h-full bg-accent rounded">
<span className="text-xs select-none">Default text</span>
</div>
}
dragData={{
id: "default-text",
type: "text",
name: "Default text",
content: "Default text",
}}
aspectRatio={1}
showLabel={false}
/>
</div>
);
}

View File

@ -1,19 +1,31 @@
"use client";
import {
useTimelineStore,
type TimelineClip,
type TimelineTrack,
} from "@/stores/timeline-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { TimelineElement, TimelineTrack } from "@/types/timeline";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useEditorStore } from "@/stores/editor-store";
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
import { VideoPlayer } from "@/components/ui/video-player";
import { AudioPlayer } from "@/components/ui/audio-player";
import { Button } from "@/components/ui/button";
import { Play, Pause, Volume2, VolumeX, Plus } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Play, Pause, Expand } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time";
import { FONT_CLASS_MAP } from "@/lib/font-config";
import { BackgroundSettings } from "../background-settings";
import { useProjectStore } from "@/stores/project-store";
interface ActiveClip {
clip: TimelineClip;
interface ActiveElement {
element: TimelineElement;
track: TimelineTrack;
mediaItem: MediaItem | null;
}
@ -21,14 +33,15 @@ interface ActiveClip {
export function PreviewPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { currentTime, muted, toggleMute, volume } = usePlaybackStore();
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
const { currentTime } = usePlaybackStore();
const { canvasSize } = useEditorStore();
const previewRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [previewDimensions, setPreviewDimensions] = useState({
width: 0,
height: 0,
});
const { activeProject } = useProjectStore();
// Calculate optimal preview size that fits in container while maintaining aspect ratio
useEffect(() => {
@ -90,73 +103,110 @@ export function PreviewPanel() {
return () => resizeObserver.disconnect();
}, [canvasSize.width, canvasSize.height]);
// Get active clips at current time
const getActiveClips = (): ActiveClip[] => {
const activeClips: ActiveClip[] = [];
// Get active elements at current time
const getActiveElements = (): ActiveElement[] => {
const activeElements: ActiveElement[] = [];
tracks.forEach((track) => {
track.clips.forEach((clip) => {
const clipStart = clip.startTime;
const clipEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
track.elements.forEach((element) => {
const elementStart = element.startTime;
const elementEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime >= clipStart && currentTime < clipEnd) {
const mediaItem =
clip.mediaId === "test"
? null // Test clips don't have a real media item
: mediaItems.find((item) => item.id === clip.mediaId) || null;
if (currentTime >= elementStart && currentTime < elementEnd) {
let mediaItem = null;
activeClips.push({ clip, track, mediaItem });
// Only get media item for media elements
if (element.type === "media") {
mediaItem =
element.mediaId === "test"
? null // Test elements don't have a real media item
: mediaItems.find((item) => item.id === element.mediaId) ||
null;
}
activeElements.push({ element, track, mediaItem });
}
});
});
return activeClips;
return activeElements;
};
const activeClips = getActiveClips();
const activeElements = getActiveElements();
// Render a clip
const renderClip = (clipData: ActiveClip, index: number) => {
const { clip, mediaItem } = clipData;
// Check if there are any elements in the timeline at all
const hasAnyElements = tracks.some((track) => track.elements.length > 0);
// Test clips
if (!mediaItem || clip.mediaId === "test") {
return (
<div
key={clip.id}
className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
>
<div className="text-center">
<div className="text-2xl mb-2">🎬</div>
<p className="text-xs text-white">{clip.name}</p>
</div>
</div>
);
// Get media elements for blur background (video/image only)
const getBlurBackgroundElements = (): ActiveElement[] => {
return activeElements.filter(
({ element, mediaItem }) =>
element.type === "media" &&
mediaItem &&
(mediaItem.type === "video" || mediaItem.type === "image") &&
element.mediaId !== "test" // Exclude test elements
);
};
const blurBackgroundElements = getBlurBackgroundElements();
// Render blur background layer
const renderBlurBackground = () => {
if (
!activeProject?.backgroundType ||
activeProject.backgroundType !== "blur" ||
blurBackgroundElements.length === 0
) {
return null;
}
// Video clips
// Use the first media element for background (could be enhanced to use primary/focused element)
const backgroundElement = blurBackgroundElements[0];
const { element, mediaItem } = backgroundElement;
if (!mediaItem) return null;
const blurIntensity = activeProject.blurIntensity || 8;
if (mediaItem.type === "video") {
return (
<div key={clip.id} className="absolute inset-0">
<div
key={`blur-${element.id}`}
className="absolute inset-0 overflow-hidden"
style={{
filter: `blur(${blurIntensity}px)`,
transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts
transformOrigin: "center",
}}
>
<VideoPlayer
src={mediaItem.url}
src={mediaItem.url!}
poster={mediaItem.thumbnailUrl}
clipStartTime={clip.startTime}
trimStart={clip.trimStart}
trimEnd={clip.trimEnd}
clipDuration={clip.duration}
clipStartTime={element.startTime}
trimStart={element.trimStart}
trimEnd={element.trimEnd}
clipDuration={element.duration}
className="w-full h-full object-cover"
/>
</div>
);
}
// Image clips
if (mediaItem.type === "image") {
return (
<div key={clip.id} className="absolute inset-0">
<div
key={`blur-${element.id}`}
className="absolute inset-0 overflow-hidden"
style={{
filter: `blur(${blurIntensity}px)`,
transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts
transformOrigin: "center",
}}
>
<img
src={mediaItem.url}
src={mediaItem.url!}
alt={mediaItem.name}
className="w-full h-full object-cover"
draggable={false}
@ -165,119 +215,283 @@ export function PreviewPanel() {
);
}
// Audio clips (visual representation)
if (mediaItem.type === "audio") {
return null;
};
// Render an element
const renderElement = (elementData: ActiveElement, index: number) => {
const { element, mediaItem } = elementData;
// Text elements
if (element.type === "text") {
const fontClassName =
FONT_CLASS_MAP[element.fontFamily as keyof typeof FONT_CLASS_MAP] || "";
const scaleRatio = previewDimensions.width / canvasSize.width;
return (
<div
key={clip.id}
className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
key={element.id}
className="absolute flex items-center justify-center"
style={{
left: `${50 + (element.x / canvasSize.width) * 100}%`,
top: `${50 + (element.y / canvasSize.height) * 100}%`,
transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`,
opacity: element.opacity,
zIndex: 100 + index, // Text elements on top
}}
>
<div className="text-center">
<div className="text-2xl mb-2">🎵</div>
<p className="text-xs text-white">{mediaItem.name}</p>
<div
className={fontClassName}
style={{
fontSize: `${element.fontSize}px`,
color: element.color,
backgroundColor: element.backgroundColor,
textAlign: element.textAlign,
fontWeight: element.fontWeight,
fontStyle: element.fontStyle,
textDecoration: element.textDecoration,
padding: "4px 8px",
borderRadius: "2px",
whiteSpace: "nowrap",
// Fallback for system fonts that don't have classes
...(fontClassName === "" && { fontFamily: element.fontFamily }),
}}
>
{element.content}
</div>
</div>
);
}
// Media elements
if (element.type === "media") {
// Test elements
if (!mediaItem || element.mediaId === "test") {
return (
<div
key={element.id}
className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center"
>
<div className="text-center">
<div className="text-2xl mb-2">🎬</div>
<p className="text-xs text-white">{element.name}</p>
</div>
</div>
);
}
// Video elements
if (mediaItem.type === "video") {
return (
<div
key={element.id}
className="absolute inset-0 flex items-center justify-center"
>
<VideoPlayer
src={mediaItem.url!}
poster={mediaItem.thumbnailUrl}
clipStartTime={element.startTime}
trimStart={element.trimStart}
trimEnd={element.trimEnd}
clipDuration={element.duration}
/>
</div>
);
}
// Image elements
if (mediaItem.type === "image") {
return (
<div
key={element.id}
className="absolute inset-0 flex items-center justify-center"
>
<img
src={mediaItem.url!}
alt={mediaItem.name}
className="max-w-full max-h-full object-contain"
draggable={false}
/>
</div>
);
}
// Audio elements (no visual representation)
if (mediaItem.type === "audio") {
return (
<div key={element.id} className="absolute inset-0">
<AudioPlayer
src={mediaItem.url!}
clipStartTime={element.startTime}
trimStart={element.trimStart}
trimEnd={element.trimEnd}
clipDuration={element.duration}
trackMuted={elementData.track.muted}
/>
</div>
);
}
}
return null;
};
// Canvas presets
const canvasPresets = [
{ name: "16:9 HD", width: 1920, height: 1080 },
{ name: "16:9 4K", width: 3840, height: 2160 },
{ name: "9:16 Mobile", width: 1080, height: 1920 },
{ name: "1:1 Square", width: 1080, height: 1080 },
{ name: "4:3 Standard", width: 1440, height: 1080 },
];
return (
<div className="h-full w-full flex flex-col min-h-0 min-w-0">
{/* Controls */}
<div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0">
<span className="text-muted-foreground">Canvas:</span>
<select
value={`${canvasSize.width}x${canvasSize.height}`}
onChange={(e) => {
const preset = canvasPresets.find(
(p) => `${p.width}x${p.height}` === e.target.value
);
if (preset)
setCanvasSize({ width: preset.width, height: preset.height });
}}
className="bg-background border rounded px-2 py-1 text-xs"
>
{canvasPresets.map((preset) => (
<option
key={preset.name}
value={`${preset.width}x${preset.height}`}
>
{preset.name} ({preset.width}×{preset.height})
</option>
))}
</select>
<Button
variant="outline"
size="sm"
onClick={toggleMute}
className="ml-auto"
>
{muted || volume === 0 ? (
<VolumeX className="h-3 w-3 mr-1" />
) : (
<Volume2 className="h-3 w-3 mr-1" />
)}
{muted || volume === 0 ? "Unmute" : "Mute"}
</Button>
</div>
{/* Preview Area */}
<div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm">
<div
ref={containerRef}
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4"
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0"
>
<div
ref={previewRef}
className="relative overflow-hidden rounded-sm bg-black border"
style={{
width: previewDimensions.width,
height: previewDimensions.height,
}}
>
{activeClips.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
{tracks.length === 0
? "No media added to timeline"
: "No clips at current time"}
</div>
) : (
activeClips.map((clipData, index) => renderClip(clipData, index))
)}
</div>
<div className="flex-1"></div>
{hasAnyElements ? (
<div
ref={previewRef}
className="relative overflow-hidden rounded-sm border"
style={{
width: previewDimensions.width,
height: previewDimensions.height,
backgroundColor:
activeProject?.backgroundType === "blur"
? "transparent"
: activeProject?.backgroundColor || "#000000",
}}
>
{renderBlurBackground()}
{activeElements.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
No elements at current time
</div>
) : (
activeElements.map((elementData, index) =>
renderElement(elementData, index)
)
)}
{/* Show message when blur is selected but no media available */}
{activeProject?.backgroundType === "blur" &&
blurBackgroundElements.length === 0 &&
activeElements.length > 0 && (
<div className="absolute bottom-2 left-2 right-2 bg-black/70 text-white text-xs p-2 rounded">
Add a video or image to use blur background
</div>
)}
</div>
) : null}
<PreviewToolbar />
<div className="flex-1"></div>
<PreviewToolbar hasAnyElements={hasAnyElements} />
</div>
</div>
);
}
function PreviewToolbar() {
const { isPlaying, toggle } = usePlaybackStore();
function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
const { isPlaying, toggle, currentTime } = usePlaybackStore();
const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore();
const { getTotalDuration } = useTimelineStore();
const { activeProject } = useProjectStore();
const {
currentPreset,
isOriginal,
getOriginalAspectRatio,
getDisplayName,
canvasPresets,
} = useAspectRatio();
const handlePresetSelect = (preset: { width: number; height: number }) => {
setCanvasSize({ width: preset.width, height: preset.height });
};
const handleOriginalSelect = () => {
const aspectRatio = getOriginalAspectRatio();
setCanvasSizeToOriginal(aspectRatio);
};
return (
<div
data-toolbar
className="flex items-center justify-center gap-2 px-4 pt-2 bg-background-500 w-full"
className="flex items-end justify-between gap-2 p-1 pt-2 w-full"
>
<Button variant="text" size="icon" onClick={toggle}>
<div>
<p
className={cn(
"text-[0.75rem] text-muted-foreground flex items-center gap-1",
!hasAnyElements && "opacity-50"
)}
>
<span className="text-primary tabular-nums">
{formatTimeCode(
currentTime,
"HH:MM:SS:FF",
activeProject?.fps || 30
)}
</span>
<span className="opacity-50">/</span>
<span className="tabular-nums">
{formatTimeCode(
getTotalDuration(),
"HH:MM:SS:FF",
activeProject?.fps || 30
)}
</span>
</p>
</div>
<Button
variant="text"
size="icon"
onClick={toggle}
disabled={!hasAnyElements}
className="h-auto p-0"
>
{isPlaying ? (
<Pause className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
<div className="flex items-center gap-3">
<BackgroundSettings />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
className="!bg-panel-accent text-foreground/85 text-[0.70rem] h-4 rounded-none border border-muted-foreground px-0.5 py-0 font-light"
disabled={!hasAnyElements}
>
{getDisplayName()}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={handleOriginalSelect}
className={cn("text-xs", isOriginal && "font-semibold")}
>
Original
</DropdownMenuItem>
<DropdownMenuSeparator />
{canvasPresets.map((preset) => (
<DropdownMenuItem
key={preset.name}
onClick={() => handlePresetSelect(preset)}
className={cn(
"text-xs",
currentPreset?.name === preset.name && "font-semibold"
)}
>
{preset.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="text"
size="icon"
className="!size-4 text-muted-foreground"
>
<Expand className="!size-4" />
</Button>
</div>
</div>
);
}

View File

@ -1,110 +0,0 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { Input } from "../ui/input";
import { useProjectStore } from "@/stores/project-store";
import { Edit2, Check, X } from "lucide-react";
import { Button } from "../ui/button";
interface ProjectNameEditorProps {
className?: string;
}
export function ProjectNameEditor({ className }: ProjectNameEditorProps) {
const { activeProject, updateProjectName } = useProjectStore();
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (activeProject) {
setEditValue(activeProject.name);
}
}, [activeProject]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const handleStartEdit = () => {
if (activeProject) {
setEditValue(activeProject.name);
setIsEditing(true);
}
};
const handleSave = () => {
if (editValue.trim()) {
updateProjectName(editValue.trim());
setIsEditing(false);
}
};
const handleCancel = () => {
if (activeProject) {
setEditValue(activeProject.name);
}
setIsEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSave();
} else if (e.key === "Escape") {
handleCancel();
}
};
if (!activeProject) {
return <span className="text-sm text-muted-foreground">Loading...</span>;
}
if (isEditing) {
return (
<div className="flex items-center gap-1">
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
className="h-7 text-sm px-3 py-1 min-w-[200px]"
size={1}
/>
<Button
size="sm"
variant="text"
onClick={handleSave}
className="h-7 w-7 p-0"
disabled={!editValue.trim()}
>
<Check className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="text"
onClick={handleCancel}
className="h-7 w-7 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
);
}
return (
<div className="flex items-center gap-1 group">
<span className="text-sm font-medium">{activeProject.name}</span>
<Button
size="sm"
variant="text"
onClick={handleStartEdit}
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Edit2 className="h-3 w-3" />
</Button>
</div>
);
}

View File

@ -1,218 +0,0 @@
"use client";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Slider } from "../ui/slider";
import { ScrollArea } from "../ui/scroll-area";
import { Separator } from "../ui/separator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
import { useState } from "react";
import { SpeedControl } from "./speed-control";
import type { BackgroundType } from "@/types/editor";
export function PropertiesPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur");
const [backgroundColor, setBackgroundColor] = useState("#000000");
// Get the first video clip for preview (simplified)
const firstVideoClip = tracks
.flatMap((track) => track.clips)
.find((clip) => {
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
return mediaItem?.type === "video";
});
const firstVideoItem = firstVideoClip
? mediaItems.find((item) => item.id === firstVideoClip.mediaId)
: null;
const firstImageClip = tracks
.flatMap((track) => track.clips)
.find((clip) => {
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
return mediaItem?.type === "image";
});
const firstImageItem = firstImageClip
? mediaItems.find((item) => item.id === firstImageClip.mediaId)
: null;
return (
<ScrollArea className="h-full">
<div className="space-y-6 p-5">
{/* Image Treatment - only show if an image is selected */}
{firstImageItem && (
<>
<div className="space-y-4">
<h3 className="text-sm font-medium">Image Treatment</h3>
<div className="space-y-4">
{/* Preview */}
<div className="space-y-2">
<Label>Preview</Label>
<div className="w-full aspect-video max-w-48">
<ImageTimelineTreatment
src={firstImageItem.url}
alt={firstImageItem.name}
targetAspectRatio={16 / 9}
className="rounded-sm border"
backgroundType={backgroundType}
backgroundColor={backgroundColor}
/>
</div>
</div>
{/* Background Type */}
<div className="space-y-2">
<Label htmlFor="bg-type">Background Type</Label>
<Select
value={backgroundType}
onValueChange={(value: BackgroundType) =>
setBackgroundType(value)
}
>
<SelectTrigger>
<SelectValue placeholder="Select background type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="blur">Blur</SelectItem>
<SelectItem value="mirror">Mirror</SelectItem>
<SelectItem value="color">Solid Color</SelectItem>
</SelectContent>
</Select>
</div>
{/* Background Color - only show for color type */}
{backgroundType === "color" && (
<div className="space-y-2">
<Label htmlFor="bg-color">Background Color</Label>
<div className="flex gap-2">
<Input
id="bg-color"
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="w-16 h-10 p-1"
/>
<Input
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
placeholder="#000000"
className="flex-1"
/>
</div>
</div>
)}
</div>
</div>
<Separator />
</>
)}
{/* Video Controls - only show if a video is selected */}
{firstVideoItem && (
<>
<SpeedControl />
<Separator />
</>
)}
{/* Transform */}
<div className="space-y-4">
<h3 className="text-sm font-medium">Transform</h3>
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="x">X Position</Label>
<Input id="x" type="number" defaultValue="0" />
</div>
<div className="space-y-1">
<Label htmlFor="y">Y Position</Label>
<Input id="y" type="number" defaultValue="0" />
</div>
</div>
<div className="space-y-1">
<Label htmlFor="rotation">Rotation</Label>
<Slider
id="rotation"
max={360}
step={1}
defaultValue={[0]}
className="mt-2"
/>
</div>
</div>
</div>
<Separator />
{/* Effects */}
<div className="space-y-4">
<h3 className="text-sm font-medium">Effects</h3>
<div className="space-y-4">
<div className="space-y-1">
<Label htmlFor="opacity">Opacity</Label>
<Slider
id="opacity"
max={100}
step={1}
defaultValue={[100]}
className="mt-2"
/>
</div>
<div className="space-y-1">
<Label htmlFor="blur">Blur</Label>
<Slider
id="blur"
max={20}
step={0.5}
defaultValue={[0]}
className="mt-2"
/>
</div>
</div>
</div>
<Separator />
{/* Timing */}
<div className="space-y-4">
<h3 className="text-sm font-medium">Timing</h3>
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="duration">Duration (seconds)</Label>
<Input
id="duration"
type="number"
min="0"
step="0.1"
defaultValue="5"
/>
</div>
<div className="space-y-1">
<Label htmlFor="delay">Delay (seconds)</Label>
<Input
id="delay"
type="number"
min="0"
step="0.1"
defaultValue="0"
/>
</div>
</div>
</div>
</div>
</ScrollArea>
);
}

View File

@ -0,0 +1,5 @@
import { MediaElement } from "@/types/timeline";
export function AudioProperties({ element }: { element: MediaElement }) {
return <div className="space-y-4 p-5">Audio properties</div>;
}

View File

@ -0,0 +1,109 @@
"use client";
import { useProjectStore } from "@/stores/project-store";
import { useAspectRatio } from "@/hooks/use-aspect-ratio";
import { Label } from "../../ui/label";
import { ScrollArea } from "../../ui/scroll-area";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { AudioProperties } from "./audio-properties";
import { MediaProperties } from "./media-properties";
import { TextProperties } from "./text-properties";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../ui/select";
import { FPS_PRESETS } from "@/constants/timeline-constants";
export function PropertiesPanel() {
const { activeProject, updateProjectFps } = useProjectStore();
const { getDisplayName, canvasSize } = useAspectRatio();
const { selectedElements, tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const handleFpsChange = (value: string) => {
const fps = parseFloat(value);
if (!isNaN(fps) && fps > 0) {
updateProjectFps(fps);
}
};
const emptyView = (
<div className="space-y-4 p-5">
{/* Media Properties */}
<div className="flex flex-col gap-3">
<PropertyItem label="Name:" value={activeProject?.name || ""} />
<PropertyItem label="Aspect ratio:" value={getDisplayName()} />
<PropertyItem
label="Resolution:"
value={`${canvasSize.width} × ${canvasSize.height}`}
/>
<div className="flex justify-between items-center">
<Label className="text-xs text-muted-foreground">Frame rate:</Label>
<Select
value={(activeProject?.fps || 30).toString()}
onValueChange={handleFpsChange}
>
<SelectTrigger className="w-32 h-6 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FPS_PRESETS.map(({ value, label }) => (
<SelectItem key={value} value={value} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
return (
<ScrollArea className="h-full bg-panel rounded-sm">
{selectedElements.length > 0
? selectedElements.map(({ trackId, elementId }) => {
const track = tracks.find((t) => t.id === trackId);
const element = track?.elements.find((e) => e.id === elementId);
if (element?.type === "text") {
return (
<div key={elementId}>
<TextProperties element={element} trackId={trackId} />
</div>
);
}
if (element?.type === "media") {
const mediaItem = mediaItems.find(
(item) => item.id === element.mediaId
);
if (mediaItem?.type === "audio") {
return <AudioProperties element={element} />;
}
return (
<div key={elementId}>
<MediaProperties element={element} />
</div>
);
}
return null;
})
: emptyView}
</ScrollArea>
);
}
function PropertyItem({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between">
<Label className="text-xs text-muted-foreground">{label}</Label>
<span className="text-xs text-right">{value}</span>
</div>
);
}

View File

@ -0,0 +1,5 @@
import { MediaElement } from "@/types/timeline";
export function MediaProperties({ element }: { element: MediaElement }) {
return <div className="space-y-4 p-5">Media properties</div>;
}

View File

@ -0,0 +1,47 @@
import { cn } from "@/lib/utils";
interface PropertyItemProps {
direction?: "row" | "column";
children: React.ReactNode;
className?: string;
}
export function PropertyItem({
direction = "row",
children,
className,
}: PropertyItemProps) {
return (
<div
className={cn(
"flex gap-2",
direction === "row"
? "items-center justify-between gap-6"
: "flex-col gap-1",
className
)}
>
{children}
</div>
);
}
export function PropertyItemLabel({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <label className={cn("text-xs", className)}>{children}</label>;
}
export function PropertyItemValue({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("flex-1", className)}>{children}</div>;
}

View File

@ -0,0 +1,76 @@
import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker";
import { FontFamily } from "@/constants/font-constants";
import { TextElement } from "@/types/timeline";
import { useTimelineStore } from "@/stores/timeline-store";
import { Slider } from "@/components/ui/slider";
import { Input } from "@/components/ui/input";
import {
PropertyItem,
PropertyItemLabel,
PropertyItemValue,
} from "./property-item";
export function TextProperties({
element,
trackId,
}: {
element: TextElement;
trackId: string;
}) {
const { updateTextElement } = useTimelineStore();
return (
<div className="space-y-6 p-5">
<Textarea
placeholder="Name"
defaultValue={element.content}
className="min-h-[4.5rem] resize-none bg-background/50"
onChange={(e) =>
updateTextElement(trackId, element.id, { content: e.target.value })
}
/>
<PropertyItem direction="row">
<PropertyItemLabel>Font</PropertyItemLabel>
<PropertyItemValue>
<FontPicker
defaultValue={element.fontFamily}
onValueChange={(value: FontFamily) =>
updateTextElement(trackId, element.id, { fontFamily: value })
}
/>
</PropertyItemValue>
</PropertyItem>
<PropertyItem direction="column">
<PropertyItemLabel>Font size</PropertyItemLabel>
<PropertyItemValue>
<div className="flex items-center gap-2">
<Slider
defaultValue={[element.fontSize]}
min={8}
max={300}
step={1}
onValueChange={([value]) =>
updateTextElement(trackId, element.id, { fontSize: value })
}
className="w-full"
/>
<Input
type="number"
value={element.fontSize}
onChange={(e) =>
updateTextElement(trackId, element.id, {
fontSize: parseInt(e.target.value),
})
}
className="w-12 !text-xs h-7 rounded-sm text-center
[appearance:textfield]
[&::-webkit-outer-spin-button]:appearance-none
[&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</PropertyItemValue>
</PropertyItem>
</div>
);
}

View File

@ -0,0 +1,58 @@
"use client";
import { useEffect, useRef } from "react";
interface SelectionBoxProps {
startPos: { x: number; y: number } | null;
currentPos: { x: number; y: number } | null;
containerRef: React.RefObject<HTMLElement>;
isActive: boolean;
}
export function SelectionBox({
startPos,
currentPos,
containerRef,
isActive,
}: SelectionBoxProps) {
const selectionBoxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isActive || !startPos || !currentPos || !containerRef.current) return;
const container = containerRef.current;
const containerRect = container.getBoundingClientRect();
// Calculate relative positions within the container
const startX = startPos.x - containerRect.left;
const startY = startPos.y - containerRect.top;
const currentX = currentPos.x - containerRect.left;
const currentY = currentPos.y - containerRect.top;
// Calculate the selection rectangle bounds
const left = Math.min(startX, currentX);
const top = Math.min(startY, currentY);
const width = Math.abs(currentX - startX);
const height = Math.abs(currentY - startY);
// Update the selection box position and size
if (selectionBoxRef.current) {
selectionBoxRef.current.style.left = `${left}px`;
selectionBoxRef.current.style.top = `${top}px`;
selectionBoxRef.current.style.width = `${width}px`;
selectionBoxRef.current.style.height = `${height}px`;
}
}, [startPos, currentPos, isActive, containerRef]);
if (!isActive || !startPos || !currentPos) return null;
return (
<div
ref={selectionBoxRef}
className="absolute pointer-events-none z-50"
style={{
backgroundColor: "hsl(var(--foreground) / 0.1)",
}}
/>
);
}

View File

@ -1,380 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "../ui/button";
import {
MoreVertical,
Scissors,
Trash2,
SplitSquareHorizontal,
Music,
ChevronRight,
ChevronLeft,
} from "lucide-react";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import AudioWaveform from "./audio-waveform";
import { toast } from "sonner";
import { TimelineClipProps, ResizeState } from "@/types/timeline";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "../ui/dropdown-menu";
import { isDragging } from "motion/react";
export function TimelineClip({
clip,
track,
zoomLevel,
isSelected,
onContextMenu,
onClipMouseDown,
onClipClick,
}: TimelineClipProps) {
const { mediaItems } = useMediaStore();
const {
updateClipTrim,
addClipToTrack,
removeClipFromTrack,
dragState,
splitClip,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
const [resizing, setResizing] = useState<ResizeState | null>(null);
const [clipMenuOpen, setClipMenuOpen] = useState(false);
const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
// Use real-time position during drag, otherwise use stored position
const isBeingDragged = dragState.clipId === clip.id;
const clipStartTime =
isBeingDragged && dragState.isDragging
? dragState.currentTime
: clip.startTime;
const clipLeft = clipStartTime * 50 * zoomLevel;
const getTrackColor = (type: string) => {
switch (type) {
case "video":
return "bg-blue-500/20 border-blue-500/30";
case "audio":
return "bg-green-500/20 border-green-500/30";
case "effects":
return "bg-purple-500/20 border-purple-500/30";
default:
return "bg-gray-500/20 border-gray-500/30";
}
};
// Resize handles for trimming clips
const handleResizeStart = (
e: React.MouseEvent,
clipId: string,
side: "left" | "right"
) => {
e.stopPropagation();
e.preventDefault();
setResizing({
clipId,
side,
startX: e.clientX,
initialTrimStart: clip.trimStart,
initialTrimEnd: clip.trimEnd,
});
};
const updateTrimFromMouseMove = (e: { clientX: number }) => {
if (!resizing) return;
const deltaX = e.clientX - resizing.startX;
const deltaTime = deltaX / (50 * zoomLevel);
if (resizing.side === "left") {
const newTrimStart = Math.max(
0,
Math.min(
clip.duration - clip.trimEnd - 0.1,
resizing.initialTrimStart + deltaTime
)
);
updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd);
} else {
const newTrimEnd = Math.max(
0,
Math.min(
clip.duration - clip.trimStart - 0.1,
resizing.initialTrimEnd - deltaTime
)
);
updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd);
}
};
const handleResizeMove = (e: React.MouseEvent) => {
updateTrimFromMouseMove(e);
};
const handleResizeEnd = () => {
setResizing(null);
};
const handleDeleteClip = () => {
removeClipFromTrack(track.id, clip.id);
setClipMenuOpen(false);
toast.success("Clip deleted");
};
const handleSplitClip = () => {
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within clip to split");
return;
}
const secondClipId = splitClip(track.id, clip.id, currentTime);
if (secondClipId) {
toast.success("Clip split successfully");
} else {
toast.error("Failed to split clip");
}
setClipMenuOpen(false);
};
const handleSplitAndKeepLeft = () => {
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within clip");
return;
}
splitAndKeepLeft(track.id, clip.id, currentTime);
toast.success("Split and kept left portion");
setClipMenuOpen(false);
};
const handleSplitAndKeepRight = () => {
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within clip");
return;
}
splitAndKeepRight(track.id, clip.id, currentTime);
toast.success("Split and kept right portion");
setClipMenuOpen(false);
};
const handleSeparateAudio = () => {
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
if (!mediaItem || mediaItem.type !== "video") {
toast.error("Audio separation only available for video clips");
return;
}
const audioClipId = separateAudio(track.id, clip.id);
if (audioClipId) {
toast.success("Audio separated to audio track");
} else {
toast.error("Failed to separate audio");
}
setClipMenuOpen(false);
};
const canSplitAtPlayhead = () => {
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
return currentTime > effectiveStart && currentTime < effectiveEnd;
};
const canSeparateAudio = () => {
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
return mediaItem?.type === "video" && track.type === "video";
};
const renderClipContent = () => {
const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
if (!mediaItem) {
return (
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
);
}
if (mediaItem.type === "image") {
return (
<div className="w-full h-full flex items-center justify-center">
<img
src={mediaItem.url}
alt={mediaItem.name}
className="w-full h-full object-cover"
draggable={false}
/>
</div>
);
}
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
return (
<div className="w-full h-full flex items-center gap-2">
<div className="w-8 h-8 flex-shrink-0">
<img
src={mediaItem.thumbnailUrl}
alt={mediaItem.name}
className="w-full h-full object-cover rounded-sm"
draggable={false}
/>
</div>
<span className="text-xs text-foreground/80 truncate flex-1">
{clip.name}
</span>
</div>
);
}
if (mediaItem.type === "audio") {
return (
<div className="w-full h-full flex items-center gap-2">
<div className="flex-1 min-w-0">
<AudioWaveform
audioUrl={mediaItem.url}
height={24}
className="w-full"
/>
</div>
</div>
);
}
return (
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
);
};
const handleClipMouseDown = (e: React.MouseEvent) => {
if (onClipMouseDown) {
onClipMouseDown(e, clip);
}
};
return (
<div
className={`absolute top-0 h-full select-none transition-all duration-75 ${
isBeingDragged ? "z-50" : "z-10"
} ${isSelected ? "ring-2 ring-primary" : ""}`}
style={{
left: `${clipLeft}px`,
width: `${clipWidth}px`,
}}
onMouseMove={resizing ? handleResizeMove : undefined}
onMouseUp={resizing ? handleResizeEnd : undefined}
onMouseLeave={resizing ? handleResizeEnd : undefined}
>
<div
className={`relative h-full rounded border cursor-pointer overflow-hidden ${getTrackColor(
track.type
)} ${isSelected ? "ring-2 ring-primary ring-offset-1" : ""}`}
onClick={(e) => onClipClick && onClipClick(e, clip)}
onMouseDown={handleClipMouseDown}
onContextMenu={(e) => onContextMenu && onContextMenu(e, clip.id)}
>
<div className="absolute inset-1 flex items-center p-1">
{renderClipContent()}
</div>
<div
className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart(e, clip.id, "left")}
/>
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart(e, clip.id, "right")}
/>
<div className="absolute top-1 right-1">
<DropdownMenu open={clipMenuOpen} onOpenChange={setClipMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80 hover:bg-background"
onClick={(e) => {
e.stopPropagation();
setClipMenuOpen(true);
}}
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{/* Split operations - only available when playhead is within clip */}
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={!canSplitAtPlayhead()}>
<Scissors className="mr-2 h-4 w-4" />
Split
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={handleSplitClip}>
<SplitSquareHorizontal className="mr-2 h-4 w-4" />
Split at Playhead
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSplitAndKeepLeft}>
<ChevronLeft className="mr-2 h-4 w-4" />
Split and Keep Left
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSplitAndKeepRight}>
<ChevronRight className="mr-2 h-4 w-4" />
Split and Keep Right
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Audio separation - only available for video clips */}
{canSeparateAudio() && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSeparateAudio}>
<Music className="mr-2 h-4 w-4" />
Separate Audio
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDeleteClip}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Clip
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,405 @@
"use client";
import { useState } from "react";
import { Button } from "../ui/button";
import {
MoreVertical,
Scissors,
Trash2,
SplitSquareHorizontal,
Music,
ChevronRight,
ChevronLeft,
Type,
Copy,
RefreshCw,
} from "lucide-react";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import AudioWaveform from "./audio-waveform";
import { toast } from "sonner";
import { TimelineElementProps, TrackType } from "@/types/timeline";
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
import {
getTrackElementClasses,
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "../ui/dropdown-menu";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "../ui/context-menu";
export function TimelineElement({
element,
track,
zoomLevel,
isSelected,
onElementMouseDown,
onElementClick,
}: TimelineElementProps) {
const { mediaItems } = useMediaStore();
const {
updateElementTrim,
updateElementDuration,
removeElementFromTrack,
dragState,
splitElement,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
addElementToTrack,
replaceElementMedia,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
const [elementMenuOpen, setElementMenuOpen] = useState(false);
const {
resizing,
isResizing,
handleResizeStart,
handleResizeMove,
handleResizeEnd,
} = useTimelineElementResize({
element,
track,
zoomLevel,
onUpdateTrim: updateElementTrim,
onUpdateDuration: updateElementDuration,
});
const effectiveDuration =
element.duration - element.trimStart - element.trimEnd;
const elementWidth = Math.max(
TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
);
// Use real-time position during drag, otherwise use stored position
const isBeingDragged = dragState.elementId === element.id;
const elementStartTime =
isBeingDragged && dragState.isDragging
? dragState.currentTime
: element.startTime;
const elementLeft = elementStartTime * 50 * zoomLevel;
const handleDeleteElement = () => {
removeElementFromTrack(track.id, element.id);
setElementMenuOpen(false);
};
const handleSplitElement = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within element to split");
return;
}
const secondElementId = splitElement(track.id, element.id, currentTime);
if (!secondElementId) {
toast.error("Failed to split element");
}
setElementMenuOpen(false);
};
const handleSplitAndKeepLeft = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within element");
return;
}
splitAndKeepLeft(track.id, element.id, currentTime);
setElementMenuOpen(false);
};
const handleSplitAndKeepRight = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within element");
return;
}
splitAndKeepRight(track.id, element.id, currentTime);
setElementMenuOpen(false);
};
const handleSeparateAudio = () => {
if (element.type !== "media") {
toast.error("Audio separation only available for media elements");
return;
}
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
if (!mediaItem || mediaItem.type !== "video") {
toast.error("Audio separation only available for video elements");
return;
}
const audioElementId = separateAudio(track.id, element.id);
if (!audioElementId) {
toast.error("Failed to separate audio");
}
setElementMenuOpen(false);
};
const canSplitAtPlayhead = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
return currentTime > effectiveStart && currentTime < effectiveEnd;
};
const canSeparateAudio = () => {
if (element.type !== "media") return false;
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
return mediaItem?.type === "video" && track.type === "media";
};
const handleElementSplitContext = () => {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
const secondElementId = splitElement(track.id, element.id, currentTime);
if (!secondElementId) {
toast.error("Failed to split element");
}
} else {
toast.error("Playhead must be within element to split");
}
};
const handleElementDuplicateContext = () => {
const { id, ...elementWithoutId } = element;
addElementToTrack(track.id, {
...elementWithoutId,
name: element.name + " (copy)",
startTime:
element.startTime +
(element.duration - element.trimStart - element.trimEnd) +
0.1,
});
};
const handleElementDeleteContext = () => {
removeElementFromTrack(track.id, element.id);
};
const handleReplaceClip = () => {
if (element.type !== "media") {
toast.error("Replace is only available for media clips");
return;
}
// Create a file input to select replacement media
const input = document.createElement("input");
input.type = "file";
input.accept = "video/*,audio/*,image/*";
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const success = await replaceElementMedia(track.id, element.id, file);
if (success) {
toast.success("Clip replaced successfully");
} else {
toast.error("Failed to replace clip");
}
} catch (error) {
toast.error("Failed to replace clip");
console.log(
JSON.stringify({ error: "Failed to replace clip", details: error })
);
}
};
input.click();
};
const renderElementContent = () => {
if (element.type === "text") {
return (
<div className="w-full h-full flex items-center justify-start pl-2">
<span className="text-xs text-foreground/80 truncate">
{element.content}
</span>
</div>
);
}
// Render media element ->
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
if (!mediaItem) {
return (
<span className="text-xs text-foreground/80 truncate">
{element.name}
</span>
);
}
if (mediaItem.type === "image") {
return (
<div className="w-full h-full flex items-center justify-center">
<div className="bg-[#004D52] py-3 w-full h-full">
<img
src={mediaItem.url}
alt={mediaItem.name}
className="w-full h-full object-cover"
draggable={false}
/>
</div>
</div>
);
}
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
return (
<div className="w-full h-full flex items-center gap-2">
<div className="w-8 h-8 flex-shrink-0">
<img
src={mediaItem.thumbnailUrl}
alt={mediaItem.name}
className="w-full h-full object-cover rounded-sm"
draggable={false}
/>
</div>
<span className="text-xs text-foreground/80 truncate flex-1">
{element.name}
</span>
</div>
);
}
// Render audio element ->
if (mediaItem.type === "audio") {
return (
<div className="w-full h-full flex items-center gap-2">
<div className="flex-1 min-w-0">
<AudioWaveform
audioUrl={mediaItem.url || ""}
height={24}
className="w-full"
/>
</div>
</div>
);
}
return (
<span className="text-xs text-foreground/80 truncate">
{element.name}
</span>
);
};
const handleElementMouseDown = (e: React.MouseEvent) => {
if (onElementMouseDown) {
onElementMouseDown(e, element);
}
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className={`absolute top-0 h-full select-none timeline-element ${
isBeingDragged ? "z-50" : "z-10"
}`}
style={{
left: `${elementLeft}px`,
width: `${elementWidth}px`,
}}
data-element-id={element.id}
data-track-id={track.id}
onMouseMove={resizing ? handleResizeMove : undefined}
onMouseUp={resizing ? handleResizeEnd : undefined}
onMouseLeave={resizing ? handleResizeEnd : undefined}
>
<div
className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
track.type
)} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
isBeingDragged ? "z-50" : "z-10"
}`}
onClick={(e) => onElementClick && onElementClick(e, element)}
onMouseDown={handleElementMouseDown}
onContextMenu={(e) =>
onElementMouseDown && onElementMouseDown(e, element)
}
>
<div className="absolute inset-0 flex items-center h-full">
{renderElementContent()}
</div>
{isSelected && (
<>
<div
className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
/>
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
/>
</>
)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={handleElementSplitContext}>
<Scissors className="h-4 w-4 mr-2" />
Split at playhead
</ContextMenuItem>
<ContextMenuItem onClick={handleElementDuplicateContext}>
<Copy className="h-4 w-4 mr-2" />
Duplicate {element.type === "text" ? "text" : "clip"}
</ContextMenuItem>
{element.type === "media" && (
<ContextMenuItem onClick={handleReplaceClip}>
<RefreshCw className="h-4 w-4 mr-2" />
Replace clip
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem
onClick={handleElementDeleteContext}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete {element.type === "text" ? "text" : "clip"}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}

View File

@ -0,0 +1,110 @@
"use client";
import { useRef } from "react";
import { TimelineTrack } from "@/types/timeline";
import {
TIMELINE_CONSTANTS,
getTotalTracksHeight,
} from "@/constants/timeline-constants";
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
interface TimelinePlayheadProps {
currentTime: number;
duration: number;
zoomLevel: number;
tracks: TimelineTrack[];
seek: (time: number) => void;
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
trackLabelsRef?: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>;
}
export function TimelinePlayhead({
currentTime,
duration,
zoomLevel,
tracks,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
trackLabelsRef,
timelineRef,
playheadRef: externalPlayheadRef,
}: TimelinePlayheadProps) {
const internalPlayheadRef = useRef<HTMLDivElement>(null);
const playheadRef = externalPlayheadRef || internalPlayheadRef;
const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
});
// Use timeline container height minus a few pixels for breathing room
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
// Get dynamic track labels width, fallback to 0 if no tracks or no ref
const trackLabelsWidth =
tracks.length > 0 && trackLabelsRef?.current
? trackLabelsRef.current.offsetWidth
: 0;
const leftPosition =
trackLabelsWidth +
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
return (
<div
ref={playheadRef}
className="absolute pointer-events-auto z-[100]"
style={{
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
width: "2px", // Slightly wider for better click target
}}
onMouseDown={handlePlayheadMouseDown}
>
{/* The red line spanning full height */}
<div className="absolute left-0 w-0.5 bg-foreground cursor-col-resize h-full" />
{/* Red dot indicator at the top (in ruler area) */}
<div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-foreground rounded-full border-2 border-foreground shadow-sm" />
</div>
);
}
// Also export a hook for getting ruler handlers
export function useTimelinePlayheadRuler({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
});
return { handleRulerMouseDown, isDraggingRuler };
}
export { TimelinePlayhead as default };

View File

@ -102,7 +102,7 @@ export function TimelineToolbar({
variant="outline"
size="sm"
onClick={() => {
const trackId = addTrack("video");
const trackId = addTrack("media");
addClipToTrack(trackId, {
mediaId: "test",
name: "Test Clip",

View File

@ -0,0 +1,962 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { toast } from "sonner";
import { TimelineElement } from "./timeline-element";
import {
TimelineTrack,
sortTracksByOrder,
ensureMainTrack,
getMainTrack,
canElementGoOnTrack,
} from "@/types/timeline";
import { usePlaybackStore } from "@/stores/playback-store";
import type {
TimelineElement as TimelineElementType,
DragData,
} from "@/types/timeline";
import {
snapTimeToFrame,
TIMELINE_CONSTANTS,
} from "@/constants/timeline-constants";
import { useProjectStore } from "@/stores/project-store";
export function TimelineTrackContent({
track,
zoomLevel,
}: {
track: TimelineTrack;
zoomLevel: number;
}) {
const { mediaItems } = useMediaStore();
const {
tracks,
moveElementToTrack,
updateElementStartTime,
addElementToTrack,
selectedElements,
selectElement,
dragState,
startDrag: startDragAction,
updateDragTime,
endDrag: endDragAction,
clearSelectedElements,
insertTrackAt,
} = useTimelineStore();
const timelineRef = useRef<HTMLDivElement>(null);
const [isDropping, setIsDropping] = useState(false);
const [dropPosition, setDropPosition] = useState<number | null>(null);
const [wouldOverlap, setWouldOverlap] = useState(false);
const dragCounterRef = useRef(0);
const [mouseDownLocation, setMouseDownLocation] = useState<{
x: number;
y: number;
} | null>(null);
// Set up mouse event listeners for drag
useEffect(() => {
if (!dragState.isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!timelineRef.current) return;
// On first mouse move during drag, ensure the element is selected
if (dragState.elementId && dragState.trackId) {
const isSelected = selectedElements.some(
(c) =>
c.trackId === dragState.trackId &&
c.elementId === dragState.elementId
);
if (!isSelected) {
// Select this element (replacing other selections) since we're dragging it
selectElement(dragState.trackId, dragState.elementId, false);
}
}
const timelineRect = timelineRef.current.getBoundingClientRect();
const mouseX = e.clientX - timelineRect.left;
const mouseTime = Math.max(
0,
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
);
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
// Use frame snapping if project has FPS, otherwise use decimal snapping
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
const snappedTime = snapTimeToFrame(adjustedTime, projectFps);
updateDragTime(snappedTime);
};
const handleMouseUp = (e: MouseEvent) => {
if (!dragState.elementId || !dragState.trackId) return;
// If this track initiated the drag, we should handle the mouse up regardless of where it occurs
const isTrackThatStartedDrag = dragState.trackId === track.id;
const timelineRect = timelineRef.current?.getBoundingClientRect();
if (!timelineRect) {
if (isTrackThatStartedDrag) {
updateElementStartTime(
track.id,
dragState.elementId,
dragState.currentTime
);
endDragAction();
}
return;
}
const isMouseOverThisTrack =
e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom;
if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return;
const finalTime = dragState.currentTime;
if (isMouseOverThisTrack) {
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const movingElement = sourceTrack?.elements.find(
(c) => c.id === dragState.elementId
);
if (movingElement) {
const movingElementDuration =
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
const movingElementEnd = finalTime + movingElementDuration;
const targetTrack = tracks.find((t) => t.id === track.id);
const hasOverlap = targetTrack?.elements.some((existingElement) => {
if (
dragState.trackId === track.id &&
existingElement.id === dragState.elementId
) {
return false;
}
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
return finalTime < existingEnd && movingElementEnd > existingStart;
});
if (!hasOverlap) {
if (dragState.trackId === track.id) {
updateElementStartTime(track.id, dragState.elementId, finalTime);
} else {
moveElementToTrack(
dragState.trackId,
track.id,
dragState.elementId
);
requestAnimationFrame(() => {
updateElementStartTime(
track.id,
dragState.elementId!,
finalTime
);
});
}
}
}
} else if (isTrackThatStartedDrag) {
// Mouse is not over this track, but this track started the drag
// This means user released over ruler/outside - update position within same track
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const movingElement = sourceTrack?.elements.find(
(c) => c.id === dragState.elementId
);
if (movingElement) {
const movingElementDuration =
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
const movingElementEnd = finalTime + movingElementDuration;
const hasOverlap = track.elements.some((existingElement) => {
if (existingElement.id === dragState.elementId) {
return false;
}
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
return finalTime < existingEnd && movingElementEnd > existingStart;
});
if (!hasOverlap) {
updateElementStartTime(track.id, dragState.elementId, finalTime);
}
}
}
if (isTrackThatStartedDrag) {
endDragAction();
}
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [
dragState.isDragging,
dragState.clickOffsetTime,
dragState.elementId,
dragState.trackId,
dragState.currentTime,
zoomLevel,
tracks,
track.id,
updateDragTime,
updateElementStartTime,
moveElementToTrack,
endDragAction,
selectedElements,
selectElement,
]);
const handleElementMouseDown = (
e: React.MouseEvent,
element: TimelineElementType
) => {
setMouseDownLocation({ x: e.clientX, y: e.clientY });
// Detect right-click (button 2) and handle selection without starting drag
const isRightClick = e.button === 2;
const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
if (isRightClick) {
// Handle right-click selection
const isSelected = selectedElements.some(
(c) => c.trackId === track.id && c.elementId === element.id
);
// If element is not selected, select it (keep other selections if multi-select)
if (!isSelected) {
selectElement(track.id, element.id, isMultiSelect);
}
// If element is already selected, keep it selected
// Don't start drag action for right-clicks
return;
}
// Handle multi-selection for left-click with modifiers
if (isMultiSelect) {
selectElement(track.id, element.id, true);
}
// Calculate the offset from the left edge of the element to where the user clicked
const elementElement = e.currentTarget as HTMLElement;
const elementRect = elementElement.getBoundingClientRect();
const clickOffsetX = e.clientX - elementRect.left;
const clickOffsetTime =
clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
startDragAction(
element.id,
track.id,
e.clientX,
element.startTime,
clickOffsetTime
);
};
const handleElementClick = (
e: React.MouseEvent,
element: TimelineElementType
) => {
e.stopPropagation();
// Check if mouse moved significantly
if (mouseDownLocation) {
const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
// If it moved more than a few pixels, consider it a drag and not a click.
if (deltaX > 5 || deltaY > 5) {
setMouseDownLocation(null); // Reset for next interaction
return;
}
}
// Skip selection logic for multi-selection (handled in mousedown)
if (e.metaKey || e.ctrlKey || e.shiftKey) {
return;
}
// Handle single selection
const isSelected = selectedElements.some(
(c) => c.trackId === track.id && c.elementId === element.id
);
if (!isSelected) {
// If element is not selected, select it (replacing other selections)
selectElement(track.id, element.id, false);
}
// If element is already selected, keep it selected (do nothing)
};
const handleTrackDragOver = (e: React.DragEvent) => {
e.preventDefault();
// Handle both timeline elements and media items
const hasTimelineElement = e.dataTransfer.types.includes(
"application/x-timeline-element"
);
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item"
);
if (!hasTimelineElement && !hasMediaItem) return;
// Calculate drop position for overlap checking
const trackContainer = e.currentTarget.querySelector(
".track-elements-container"
) as HTMLElement;
let dropTime = 0;
if (trackContainer) {
const rect = trackContainer.getBoundingClientRect();
const mouseX = Math.max(0, e.clientX - rect.left);
dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
}
// Check for potential overlaps and show appropriate feedback
let wouldOverlap = false;
if (hasMediaItem) {
try {
const mediaItemData = e.dataTransfer.getData(
"application/x-media-item"
);
if (mediaItemData) {
const dragData: DragData = JSON.parse(mediaItemData);
if (dragData.type === "text") {
// Text elements have default duration of 5 seconds
const newElementDuration = 5;
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
const snappedTime = snapTimeToFrame(dropTime, projectFps);
const newElementEnd = snappedTime + newElementDuration;
wouldOverlap = track.elements.some((existingElement) => {
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
return snappedTime < existingEnd && newElementEnd > existingStart;
});
} else {
// Media elements
const mediaItem = mediaItems.find(
(item) => item.id === dragData.id
);
if (mediaItem) {
const newElementDuration = mediaItem.duration || 5;
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
const snappedTime = snapTimeToFrame(dropTime, projectFps);
const newElementEnd = snappedTime + newElementDuration;
wouldOverlap = track.elements.some((existingElement) => {
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
return (
snappedTime < existingEnd && newElementEnd > existingStart
);
});
}
}
}
} catch (error) {
// Continue with default behavior
}
} else if (hasTimelineElement) {
try {
const timelineElementData = e.dataTransfer.getData(
"application/x-timeline-element"
);
if (timelineElementData) {
const { elementId, trackId: fromTrackId } =
JSON.parse(timelineElementData);
const sourceTrack = tracks.find(
(t: TimelineTrack) => t.id === fromTrackId
);
const movingElement = sourceTrack?.elements.find(
(c: any) => c.id === elementId
);
if (movingElement) {
const movingElementDuration =
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
const snappedTime = snapTimeToFrame(dropTime, projectFps);
const movingElementEnd = snappedTime + movingElementDuration;
wouldOverlap = track.elements.some((existingElement) => {
if (fromTrackId === track.id && existingElement.id === elementId)
return false;
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
return (
snappedTime < existingEnd && movingElementEnd > existingStart
);
});
}
}
} catch (error) {
// Continue with default behavior
}
}
if (wouldOverlap) {
e.dataTransfer.dropEffect = "none";
setWouldOverlap(true);
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
setDropPosition(snapTimeToFrame(dropTime, projectFps));
return;
}
e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
setWouldOverlap(false);
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
setDropPosition(snapTimeToFrame(dropTime, projectFps));
};
const handleTrackDragEnter = (e: React.DragEvent) => {
e.preventDefault();
const hasTimelineElement = e.dataTransfer.types.includes(
"application/x-timeline-element"
);
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item"
);
if (!hasTimelineElement && !hasMediaItem) return;
dragCounterRef.current++;
setIsDropping(true);
};
const handleTrackDragLeave = (e: React.DragEvent) => {
e.preventDefault();
const hasTimelineElement = e.dataTransfer.types.includes(
"application/x-timeline-element"
);
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item"
);
if (!hasTimelineElement && !hasMediaItem) return;
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDropping(false);
setWouldOverlap(false);
setDropPosition(null);
}
};
const handleTrackDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Reset all drag states
dragCounterRef.current = 0;
setIsDropping(false);
setWouldOverlap(false);
const hasTimelineElement = e.dataTransfer.types.includes(
"application/x-timeline-element"
);
const hasMediaItem = e.dataTransfer.types.includes(
"application/x-media-item"
);
if (!hasTimelineElement && !hasMediaItem) return;
const trackContainer = e.currentTarget.querySelector(
".track-elements-container"
) as HTMLElement;
if (!trackContainer) return;
const rect = trackContainer.getBoundingClientRect();
const mouseX = Math.max(0, e.clientX - rect.left);
const mouseY = e.clientY - rect.top; // Get Y position relative to this track
const newStartTime =
mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
const snappedTime = snapTimeToFrame(newStartTime, projectFps);
// Calculate drop position relative to tracks
const currentTrackIndex = tracks.findIndex((t) => t.id === track.id);
// Determine drop zone within the track (top 20px, middle 20px, bottom 20px)
let dropPosition: "above" | "on" | "below";
if (mouseY < 20) {
dropPosition = "above";
} else if (mouseY > 40) {
dropPosition = "below";
} else {
dropPosition = "on";
}
try {
if (hasTimelineElement) {
// Handle timeline element movement
const timelineElementData = e.dataTransfer.getData(
"application/x-timeline-element"
);
if (!timelineElementData) return;
const {
elementId,
trackId: fromTrackId,
clickOffsetTime = 0,
} = JSON.parse(timelineElementData);
// Find the element being moved
const sourceTrack = tracks.find(
(t: TimelineTrack) => t.id === fromTrackId
);
const movingElement = sourceTrack?.elements.find(
(c: TimelineElementType) => c.id === elementId
);
if (!movingElement) {
toast.error("Element not found");
return;
}
// Adjust position based on where user clicked on the element
const adjustedStartTime = snappedTime - clickOffsetTime;
const finalStartTime = Math.max(
0,
snapTimeToFrame(adjustedStartTime, projectFps)
);
// Check for overlaps with existing elements (excluding the moving element itself)
const movingElementDuration =
movingElement.duration -
movingElement.trimStart -
movingElement.trimEnd;
const movingElementEnd = finalStartTime + movingElementDuration;
const hasOverlap = track.elements.some((existingElement) => {
// Skip the element being moved if it's on the same track
if (fromTrackId === track.id && existingElement.id === elementId)
return false;
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
// Check if elements overlap
return (
finalStartTime < existingEnd && movingElementEnd > existingStart
);
});
if (hasOverlap) {
toast.error(
"Cannot move element here - it would overlap with existing elements"
);
return;
}
if (fromTrackId === track.id) {
// Moving within same track
updateElementStartTime(track.id, elementId, finalStartTime);
} else {
// Moving to different track
moveElementToTrack(fromTrackId, track.id, elementId);
requestAnimationFrame(() => {
updateElementStartTime(track.id, elementId, finalStartTime);
});
}
} else if (hasMediaItem) {
// Handle media item drop
const mediaItemData = e.dataTransfer.getData(
"application/x-media-item"
);
if (!mediaItemData) return;
const dragData: DragData = JSON.parse(mediaItemData);
if (dragData.type === "text") {
let targetTrackId = track.id;
let targetTrack = track;
// Handle position-aware track creation for text
if (track.type !== "text" || dropPosition !== "on") {
// Text tracks should go above the main track
const mainTrack = getMainTrack(tracks);
let insertIndex: number;
if (dropPosition === "above") {
insertIndex = currentTrackIndex;
} else if (dropPosition === "below") {
insertIndex = currentTrackIndex + 1;
} else {
// dropPosition === "on" but track is not text type
// Insert above main track if main track exists, otherwise at top
if (mainTrack) {
const mainTrackIndex = tracks.findIndex(
(t) => t.id === mainTrack.id
);
insertIndex = mainTrackIndex;
} else {
insertIndex = 0; // Top of timeline
}
}
targetTrackId = insertTrackAt("text", insertIndex);
// Get the updated tracks array after creating the new track
const updatedTracks = useTimelineStore.getState().tracks;
const newTargetTrack = updatedTracks.find(
(t) => t.id === targetTrackId
);
if (!newTargetTrack) return;
targetTrack = newTargetTrack;
}
// Check for overlaps with existing elements in target track
const newElementDuration = 5; // Default text duration
const newElementEnd = snappedTime + newElementDuration;
const hasOverlap = targetTrack.elements.some((existingElement) => {
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
// Check if elements overlap
return snappedTime < existingEnd && newElementEnd > existingStart;
});
if (hasOverlap) {
toast.error(
"Cannot place element here - it would overlap with existing elements"
);
return;
}
addElementToTrack(targetTrackId, {
type: "text",
name: dragData.name || "Text",
content: dragData.content || "Default Text",
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime: snappedTime,
trimStart: 0,
trimEnd: 0,
fontSize: 48,
fontFamily: "Arial",
color: "#ffffff",
backgroundColor: "transparent",
textAlign: "center",
fontWeight: "normal",
fontStyle: "normal",
textDecoration: "none",
x: 0,
y: 0,
rotation: 0,
opacity: 1,
});
} else {
// Handle media items
const mediaItem = mediaItems.find((item) => item.id === dragData.id);
if (!mediaItem) {
toast.error("Media item not found");
return;
}
let targetTrackId = track.id;
// Check if track type is compatible
const isVideoOrImage =
dragData.type === "video" || dragData.type === "image";
const isAudio = dragData.type === "audio";
const isCompatible = isVideoOrImage
? canElementGoOnTrack("media", track.type)
: isAudio
? canElementGoOnTrack("media", track.type)
: false;
let targetTrack = tracks.find((t) => t.id === targetTrackId);
// Handle position-aware track creation for media elements
if (!isCompatible || dropPosition !== "on") {
const needsNewTrack = !isCompatible || dropPosition !== "on";
if (needsNewTrack) {
if (isVideoOrImage) {
// For video/image, check if we need a main track or additional media track
const mainTrack = getMainTrack(tracks);
if (!mainTrack) {
// No main track exists, create it
const updatedTracks = ensureMainTrack(tracks);
const newMainTrack = getMainTrack(updatedTracks);
if (newMainTrack && newMainTrack.elements.length === 0) {
targetTrackId = newMainTrack.id;
targetTrack = newMainTrack;
} else {
// Main track was created but somehow has elements, create new media track
const mainTrackIndex = updatedTracks.findIndex(
(t) => t.id === newMainTrack?.id
);
targetTrackId = insertTrackAt("media", mainTrackIndex);
const updatedTracksAfterInsert =
useTimelineStore.getState().tracks;
const newTargetTrack = updatedTracksAfterInsert.find(
(t) => t.id === targetTrackId
);
if (!newTargetTrack) return;
targetTrack = newTargetTrack;
}
} else if (
mainTrack.elements.length === 0 &&
dropPosition === "on"
) {
// Main track exists and is empty, use it
targetTrackId = mainTrack.id;
targetTrack = mainTrack;
} else {
// Create new media track above main track
const mainTrackIndex = tracks.findIndex(
(t) => t.id === mainTrack.id
);
let insertIndex: number;
if (dropPosition === "above") {
insertIndex = currentTrackIndex;
} else if (dropPosition === "below") {
insertIndex = currentTrackIndex + 1;
} else {
// Insert above main track
insertIndex = mainTrackIndex;
}
targetTrackId = insertTrackAt("media", insertIndex);
const updatedTracks = useTimelineStore.getState().tracks;
const newTargetTrack = updatedTracks.find(
(t) => t.id === targetTrackId
);
if (!newTargetTrack) return;
targetTrack = newTargetTrack;
}
} else if (isAudio) {
// Audio tracks go at the bottom
const mainTrack = getMainTrack(tracks);
let insertIndex: number;
if (dropPosition === "above") {
insertIndex = currentTrackIndex;
} else if (dropPosition === "below") {
insertIndex = currentTrackIndex + 1;
} else {
// Insert after main track (bottom area)
if (mainTrack) {
const mainTrackIndex = tracks.findIndex(
(t) => t.id === mainTrack.id
);
insertIndex = mainTrackIndex + 1;
} else {
insertIndex = tracks.length; // Bottom of timeline
}
}
targetTrackId = insertTrackAt("audio", insertIndex);
const updatedTracks = useTimelineStore.getState().tracks;
const newTargetTrack = updatedTracks.find(
(t) => t.id === targetTrackId
);
if (!newTargetTrack) return;
targetTrack = newTargetTrack;
}
}
}
if (!targetTrack) return;
// Check for overlaps with existing elements in target track
const newElementDuration = mediaItem.duration || 5;
const newElementEnd = snappedTime + newElementDuration;
const hasOverlap = targetTrack.elements.some((existingElement) => {
const existingStart = existingElement.startTime;
const existingEnd =
existingElement.startTime +
(existingElement.duration -
existingElement.trimStart -
existingElement.trimEnd);
// Check if elements overlap
return snappedTime < existingEnd && newElementEnd > existingStart;
});
if (hasOverlap) {
toast.error(
"Cannot place element here - it would overlap with existing elements"
);
return;
}
addElementToTrack(targetTrackId, {
type: "media",
mediaId: mediaItem.id,
name: mediaItem.name,
duration: mediaItem.duration || 5,
startTime: snappedTime,
trimStart: 0,
trimEnd: 0,
});
}
}
} catch (error) {
console.error("Error handling drop:", error);
toast.error("Failed to add media to track");
}
};
return (
<div
className="w-full h-full hover:bg-muted/20"
onClick={(e) => {
// If clicking empty area (not on an element), deselect all elements
if (!(e.target as HTMLElement).closest(".timeline-element")) {
clearSelectedElements();
}
}}
onDragOver={handleTrackDragOver}
onDragEnter={handleTrackDragEnter}
onDragLeave={handleTrackDragLeave}
onDrop={handleTrackDrop}
>
<div
ref={timelineRef}
className="h-full relative track-elements-container min-w-full"
>
{track.elements.length === 0 ? (
<div
className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
isDropping
? wouldOverlap
? "border-red-500 bg-red-500/10 text-red-600"
: "border-blue-500 bg-blue-500/10 text-blue-600"
: "border-muted/30"
}`}
>
{isDropping
? wouldOverlap
? "Cannot drop - would overlap"
: "Drop element here"
: ""}
</div>
) : (
<>
{track.elements.map((element) => {
const isSelected = selectedElements.some(
(c) => c.trackId === track.id && c.elementId === element.id
);
const handleElementSplit = () => {
const { currentTime } = usePlaybackStore();
const { splitElement } = useTimelineStore();
const splitTime = currentTime;
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (splitTime > effectiveStart && splitTime < effectiveEnd) {
const secondElementId = splitElement(
track.id,
element.id,
splitTime
);
if (!secondElementId) {
toast.error("Failed to split element");
}
} else {
toast.error("Playhead must be within element to split");
}
};
const handleElementDuplicate = () => {
const { addElementToTrack } = useTimelineStore.getState();
const { id, ...elementWithoutId } = element;
addElementToTrack(track.id, {
...elementWithoutId,
name: element.name + " (copy)",
startTime:
element.startTime +
(element.duration - element.trimStart - element.trimEnd) +
0.1,
});
};
const handleElementDelete = () => {
const { removeElementFromTrack } = useTimelineStore.getState();
removeElementFromTrack(track.id, element.id);
};
return (
<TimelineElement
key={element.id}
element={element}
track={track}
zoomLevel={zoomLevel}
isSelected={isSelected}
onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick}
/>
);
})}
</>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@ import { motion } from "motion/react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { RiGithubLine, RiTwitterXLine } from "react-icons/ri";
import { getStars } from "@/lib/fetchGhStars";
import { getStars } from "@/lib/fetch-github-stars";
import Image from "next/image";
export function Footer() {
@ -25,12 +25,12 @@ export function Footer() {
return (
<motion.footer
className="bg-background/80 backdrop-blur-sm border mt-16 m-6 rounded-sm"
className="bg-background border-t"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8, duration: 0.8 }}
>
<div className="max-w-5xl mx-auto px-4 py-10">
<div className="max-w-5xl mx-auto px-8 py-10">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-8">
{/* Brand Section */}
<div className="md:col-span-1 max-w-sm">

View File

@ -5,7 +5,7 @@ import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react";
import { HeaderBase } from "./header-base";
import { useSession } from "@opencut/auth/client";
import { getStars } from "@/lib/fetchGhStars";
import { getStars } from "@/lib/fetch-github-stars";
import { useEffect, useState } from "react";
import Image from "next/image";
@ -41,9 +41,9 @@ export function Header() {
</Button>
</Link>
{process.env.NODE_ENV === "development" ? (
<Link href="/editor">
<Link href="/projects">
<Button size="sm" className="text-sm ml-4">
Editor
Projects
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
@ -61,7 +61,7 @@ export function Header() {
return (
<div className="mx-4 md:mx-0">
<HeaderBase
className="bg-[#1D1D1D] border border-white/10 rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
className="bg-accent border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
leftContent={leftContent}
rightContent={rightContent}
/>

View File

@ -37,3 +37,64 @@ export function GithubIcon({ className }: { className?: string }) {
</svg>
);
}
export function BackgroundIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="353"
height="353"
viewBox="0 0 353 353"
fill="none"
className={className}
>
<g clipPath="url(#clip0_1_3)">
<rect
x="-241.816"
y="233.387"
width="592.187"
height="17.765"
transform="rotate(-37 -241.816 233.387)"
fill="white"
/>
<rect
x="-189.907"
y="306.804"
width="592.187"
height="17.765"
transform="rotate(-37 -189.907 306.804)"
fill="white"
/>
<rect
x="-146.928"
y="389.501"
width="592.187"
height="17.765"
transform="rotate(-37 -146.928 389.501)"
fill="white"
/>
<rect
x="-103.144"
y="477.904"
width="592.187"
height="17.765"
transform="rotate(-37 -103.144 477.904)"
fill="white"
/>
<rect
x="-57.169"
y="570.714"
width="592.187"
height="17.765"
transform="rotate(-37 -57.169 570.714)"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_1_3">
<rect width="353" height="353" fill="white" />
</clipPath>
</defs>
</svg>
);
}

View File

@ -0,0 +1,164 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import { motion, useMotionValue, useTransform, PanInfo } from "motion/react";
interface HandlebarsProps {
children: React.ReactNode;
minWidth?: number;
maxWidth?: number;
onRangeChange?: (left: number, right: number) => void;
}
export function Handlebars({
children,
minWidth = 50,
maxWidth = 400,
onRangeChange,
}: HandlebarsProps) {
const [leftHandle, setLeftHandle] = useState(0);
const [rightHandle, setRightHandle] = useState(maxWidth);
const [contentWidth, setContentWidth] = useState(maxWidth);
const leftHandleX = useMotionValue(0);
const rightHandleX = useMotionValue(maxWidth);
const visibleWidth = useTransform(
[leftHandleX, rightHandleX],
(values: number[]) => values[1] - values[0]
);
const contentLeft = useTransform(leftHandleX, (left: number) => -left);
const containerRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!measureRef.current) return;
const measureContent = () => {
if (measureRef.current) {
const width = measureRef.current.scrollWidth;
const paddedWidth = width + 32;
setContentWidth(paddedWidth);
setRightHandle(paddedWidth);
rightHandleX.set(paddedWidth);
}
};
measureContent();
const timer = setTimeout(measureContent, 50);
return () => clearTimeout(timer);
}, [children, rightHandleX]);
useEffect(() => {
leftHandleX.set(leftHandle);
}, [leftHandle, leftHandleX]);
useEffect(() => {
rightHandleX.set(rightHandle);
}, [rightHandle, rightHandleX]);
useEffect(() => {
onRangeChange?.(leftHandle, rightHandle);
}, [leftHandle, rightHandle, onRangeChange]);
const handleLeftDrag = (event: any, info: PanInfo) => {
const newLeft = Math.max(
0,
Math.min(leftHandle + info.offset.x, rightHandle - minWidth)
);
setLeftHandle(newLeft);
};
const handleRightDrag = (event: any, info: PanInfo) => {
const newRight = Math.max(
leftHandle + minWidth,
Math.min(contentWidth, rightHandle + info.offset.x)
);
setRightHandle(newRight);
};
return (
<div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
<div
ref={measureRef}
className="absolute -left-[9999px] top-0 invisible px-4 whitespace-nowrap font-[inherit]"
>
{children}
</div>
<div
ref={containerRef}
className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2"
style={{ width: contentWidth }}
>
<div className="absolute inset-0 w-full h-full rounded-2xl border border-yellow-500 flex justify-between">
<motion.div
className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none"
style={{
position: "absolute",
x: leftHandleX,
left: 0,
zIndex: 10,
}}
drag="x"
dragConstraints={{ left: 0, right: rightHandle - minWidth }}
dragElastic={0}
dragMomentum={false}
onDrag={handleLeftDrag}
whileHover={{ scale: 1.05 }}
whileDrag={{ scale: 1.1 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
>
<div className="w-2 h-8 rounded-full bg-yellow-500"></div>
</motion.div>
<motion.div
className="h-full border border-yellow-500 w-7 rounded-full bg-accent flex items-center justify-center cursor-ew-resize select-none"
style={{
position: "absolute",
x: rightHandleX,
left: -30,
zIndex: 10,
}}
drag="x"
dragConstraints={{
left: leftHandle + minWidth,
right: contentWidth,
}}
dragElastic={0}
dragMomentum={false}
onDrag={handleRightDrag}
whileHover={{ scale: 1.05 }}
whileDrag={{ scale: 1.1 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
>
<div className="w-2 h-8 rounded-full bg-yellow-500"></div>
</motion.div>
</div>
<motion.div
className="relative overflow-hidden rounded-2xl"
style={{
width: visibleWidth,
x: leftHandleX,
height: "100%",
}}
>
<motion.div
className="w-full h-full flex items-center justify-center px-4"
style={{
x: contentLeft,
width: contentWidth,
whiteSpace: "nowrap",
}}
>
{children}
</motion.div>
</motion.div>
</div>
</div>
);
};

View File

@ -4,11 +4,11 @@ import { motion } from "motion/react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { ArrowRight } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import Image from "next/image";
import { Handlebars } from "./handlebars";
interface HeroProps {
signupCount: number;
@ -42,7 +42,7 @@ export function Hero({ signupCount }: HeroProps) {
body: JSON.stringify({ email: email.trim() }),
});
const data = await response.json();
const data = (await response.json()) as { error: string };
if (response.ok) {
toast({
@ -53,7 +53,9 @@ export function Hero({ signupCount }: HeroProps) {
} else {
toast({
title: "Oops!",
description: data.error || "Something went wrong. Please try again.",
description:
(data as { error: string }).error ||
"Something went wrong. Please try again.",
variant: "destructive",
});
}
@ -69,7 +71,14 @@ export function Hero({ signupCount }: HeroProps) {
};
return (
<div className="min-h-[calc(100vh-6rem)] supports-[height:100dvh]:min-h-[calc(100dvh-6rem)] flex flex-col justify-between items-center text-center px-4">
<div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4">
<Image
className="absolute top-0 left-0 -z-50 size-full object-cover"
src="/landing-page-bg.png"
height={1903.5}
width={1269}
alt="landing-page.bg"
/>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@ -83,14 +92,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
@ -113,19 +115,21 @@ export function Hero({ signupCount }: HeroProps) {
onSubmit={handleSubmit}
className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"
>
<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 className="relative w-full">
<Input
type="email"
placeholder="Enter your email"
className="h-11 text-base flex-1"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isSubmitting}
required
/>
</div>
<Button
type="submit"
size="lg"
className="px-6 h-11 text-base"
className="px-6 h-11 text-base !bg-foreground"
disabled={isSubmitting}
>
<span className="relative z-10">

View File

@ -0,0 +1,73 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useState } from "react";
export function RenameProjectDialog({
isOpen,
onOpenChange,
onConfirm,
projectName,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (name: string) => void;
projectName: string;
}) {
const [name, setName] = useState(projectName);
// Reset the name when dialog opens - this is better UX than syncing with every prop change
const handleOpenChange = (open: boolean) => {
if (open) {
setName(projectName);
}
onOpenChange(open);
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Project</DialogTitle>
<DialogDescription>
Enter a new name for your project.
</DialogDescription>
</DialogHeader>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm(name);
}
}}
placeholder="Enter a new name"
className="mt-4"
/>
<DialogFooter>
<Button
variant="outline"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenChange(false);
}}
>
Cancel
</Button>
<Button onClick={() => onConfirm(name)}>Rename</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,80 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { useProjectStore } from "@/stores/project-store";
import { useMediaStore } from "@/stores/media-store";
import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner";
interface StorageContextType {
isInitialized: boolean;
isLoading: boolean;
hasSupport: boolean;
error: string | null;
}
const StorageContext = createContext<StorageContextType | null>(null);
export function useStorage() {
const context = useContext(StorageContext);
if (!context) {
throw new Error("useStorage must be used within StorageProvider");
}
return context;
}
interface StorageProviderProps {
children: React.ReactNode;
}
export function StorageProvider({ children }: StorageProviderProps) {
const [status, setStatus] = useState<StorageContextType>({
isInitialized: false,
isLoading: true,
hasSupport: false,
error: null,
});
const loadAllProjects = useProjectStore((state) => state.loadAllProjects);
useEffect(() => {
const initializeStorage = async () => {
setStatus((prev) => ({ ...prev, isLoading: true }));
try {
// Check browser support
const hasSupport = storageService.isFullySupported();
if (!hasSupport) {
toast.warning(
"Storage not fully supported. Some features may not work."
);
}
// Load saved projects (media will be loaded when a project is loaded)
await loadAllProjects();
setStatus({
isInitialized: true,
isLoading: false,
hasSupport,
error: null,
});
} catch (error) {
console.error("Failed to initialize storage:", error);
setStatus({
isInitialized: false,
isLoading: false,
hasSupport: storageService.isFullySupported(),
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
initializeStorage();
}, [loadAllProjects]);
return (
<StorageContext.Provider value={status}>{children}</StorageContext.Provider>
);
}

View File

@ -0,0 +1,127 @@
"use client";
import { useRef, useEffect } from "react";
import { usePlaybackStore } from "@/stores/playback-store";
interface AudioPlayerProps {
src: string;
className?: string;
clipStartTime: number;
trimStart: number;
trimEnd: number;
clipDuration: number;
trackMuted?: boolean;
}
export function AudioPlayer({
src,
className = "",
clipStartTime,
trimStart,
trimEnd,
clipDuration,
trackMuted = false,
}: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
// Calculate if we're within this clip's timeline range
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
const isInClipRange =
currentTime >= clipStartTime && currentTime < clipEndTime;
// Sync playback events
useEffect(() => {
const audio = audioRef.current;
if (!audio || !isInClipRange) return;
const handleSeekEvent = (e: CustomEvent) => {
// Always update audio time, even if outside clip range
const timelineTime = e.detail.time;
const audioTime = Math.max(
trimStart,
Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
)
);
audio.currentTime = audioTime;
};
const handleUpdateEvent = (e: CustomEvent) => {
// Always update audio time, even if outside clip range
const timelineTime = e.detail.time;
const targetTime = Math.max(
trimStart,
Math.min(
clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
)
);
if (Math.abs(audio.currentTime - targetTime) > 0.5) {
audio.currentTime = targetTime;
}
};
const handleSpeed = (e: CustomEvent) => {
audio.playbackRate = e.detail.speed;
};
window.addEventListener("playback-seek", handleSeekEvent as EventListener);
window.addEventListener(
"playback-update",
handleUpdateEvent as EventListener
);
window.addEventListener("playback-speed", handleSpeed as EventListener);
return () => {
window.removeEventListener(
"playback-seek",
handleSeekEvent as EventListener
);
window.removeEventListener(
"playback-update",
handleUpdateEvent as EventListener
);
window.removeEventListener(
"playback-speed",
handleSpeed as EventListener
);
};
}, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
// Sync playback state
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying && isInClipRange && !trackMuted) {
audio.play().catch(() => {});
} else {
audio.pause();
}
}, [isPlaying, isInClipRange, trackMuted]);
// Sync volume and speed
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
audio.volume = volume;
audio.muted = muted || trackMuted;
audio.playbackRate = speed;
}, [volume, speed, muted, trackMuted]);
return (
<audio
ref={audioRef}
src={src}
className={className}
preload="auto"
controls={false}
style={{ display: "none" }} // Audio elements don't need visual representation
onContextMenu={(e) => e.preventDefault()}
/>
);
}

View File

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

View File

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

View File

@ -3,6 +3,7 @@
import * as React from "react";
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
@ -18,23 +19,40 @@ const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const contextMenuItemVariants = cva(
"relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "focus:opacity-65 focus:text-accent-foreground",
destructive: "text-destructive focus:text-destructive/80",
},
},
defaultVariants: {
variant: "default",
},
}
);
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
}
>(({ className, inset, children, ...props }, ref) => (
>(({ className, inset, children, variant = "default", ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
contextMenuItemVariants({ variant }),
"data-[state=open]:bg-accent data-[state=open]:opacity-65",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
<ChevronRight className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
@ -62,7 +80,8 @@ const ContextMenuContent = React.forwardRef<
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
@ -75,12 +94,13 @@ const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
}
>(({ className, inset, ...props }, ref) => (
>(({ className, inset, variant = "default", ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
contextMenuItemVariants({ variant }),
inset && "pl-8",
className
)}
@ -91,14 +111,13 @@ ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
}
>(({ className, children, checked, variant = "default", ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)}
checked={checked}
{...props}
>
@ -115,19 +134,18 @@ ContextMenuCheckboxItem.displayName =
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & {
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
}
>(({ className, children, variant = "default", ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
className={cn(contextMenuItemVariants({ variant }), "pl-8 pr-2", className)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
@ -144,7 +162,7 @@ const ContextMenuLabel = React.forwardRef<
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
@ -159,7 +177,7 @@ const ContextMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
className={cn("-mx-1 my-1 h-px bg-foreground/10", className)}
{...props}
/>
));
@ -171,10 +189,7 @@ const ContextMenuShortcut = ({
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);

View File

@ -0,0 +1,150 @@
"use client";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { Button } from "@/components/ui/button";
import { ReactNode, useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
export interface DraggableMediaItemProps {
name: string;
preview: ReactNode;
dragData: Record<string, any>;
onDragStart?: (e: React.DragEvent) => void;
aspectRatio?: number;
className?: string;
showPlusOnDrag?: boolean;
showLabel?: boolean;
rounded?: boolean;
}
export function DraggableMediaItem({
name,
preview,
dragData,
onDragStart,
aspectRatio = 16 / 9,
className = "",
showPlusOnDrag = true,
showLabel = true,
rounded = true,
}: DraggableMediaItemProps) {
const [isDragging, setIsDragging] = useState(false);
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
const dragRef = useRef<HTMLDivElement>(null);
const emptyImg = new window.Image();
emptyImg.src =
"data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
useEffect(() => {
if (!isDragging) return;
const handleDragOver = (e: DragEvent) => {
setDragPosition({ x: e.clientX, y: e.clientY });
};
document.addEventListener("dragover", handleDragOver);
return () => {
document.removeEventListener("dragover", handleDragOver);
};
}, [isDragging]);
const handleDragStart = (e: React.DragEvent) => {
e.dataTransfer.setDragImage(emptyImg, 0, 0);
// Set drag data
e.dataTransfer.setData(
"application/x-media-item",
JSON.stringify(dragData)
);
e.dataTransfer.effectAllowed = "copy";
// Set initial position and show custom drag preview
setDragPosition({ x: e.clientX, y: e.clientY });
setIsDragging(true);
onDragStart?.(e);
};
const handleDragEnd = () => {
setIsDragging(false);
};
return (
<>
<div ref={dragRef} className="relative group w-28 h-28">
<div
className={`flex flex-col gap-1 p-0 h-auto w-full relative cursor-default ${className}`}
>
<AspectRatio
ratio={aspectRatio}
className={cn(
"bg-accent relative overflow-hidden",
rounded && "rounded-md",
"[&::-webkit-drag-ghost]:opacity-0" // Webkit-specific ghost hiding
)}
draggable={true}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{preview}
{!isDragging && (
<PlusButton className="opacity-0 group-hover:opacity-100" />
)}
</AspectRatio>
{showLabel && (
<span
className="text-[0.7rem] text-muted-foreground truncate w-full text-left"
aria-label={name}
title={name}
>
{name.length > 8
? `${name.slice(0, 16)}...${name.slice(-3)}`
: name}
</span>
)}
</div>
</div>
{/* Custom drag preview */}
{isDragging &&
typeof document !== "undefined" &&
createPortal(
<div
className="fixed pointer-events-none z-[9999]"
style={{
left: dragPosition.x - 40, // Center the preview (half of 80px)
top: dragPosition.y - 40, // Center the preview (half of 80px)
}}
>
<div className="w-[80px]">
<AspectRatio
ratio={1}
className="relative rounded-md overflow-hidden shadow-2xl ring ring-primary"
>
<div className="w-full h-full [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_img]:rounded-none">
{preview}
</div>
{showPlusOnDrag && <PlusButton />}
</AspectRatio>
</div>
</div>,
document.body
)}
</>
);
}
function PlusButton({ className }: { className?: string }) {
return (
<Button
size="icon"
className={cn("absolute bottom-2 right-2 size-4", className)}
>
<Plus className="!size-3" />
</Button>
);
}

View File

@ -19,16 +19,33 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const dropdownMenuItemVariants = cva(
"relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "focus:opacity-65 focus:text-accent-foreground",
destructive: "text-destructive focus:text-destructive/80",
},
},
defaultVariants: {
variant: "default",
},
}
);
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
}
>(({ className, inset, children, ...props }, ref) => (
>(({ className, inset, children, variant = "default", ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
dropdownMenuItemVariants({ variant }),
"data-[state=open]:bg-accent data-[state=open]:opacity-65",
inset && "pl-8",
className
)}
@ -65,8 +82,12 @@ const DropdownMenuContent = React.forwardRef<
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
onCloseAutoFocus={(e) => {
e.stopPropagation();
e.preventDefault();
}}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 bg-popover text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
@ -76,22 +97,6 @@ const DropdownMenuContent = React.forwardRef<
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const dropdownMenuItemVariants = cva(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "focus:bg-accent focus:text-accent-foreground",
destructive:
"text-destructive focus:bg-destructive focus:text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
@ -113,12 +118,15 @@ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
}
>(({ className, children, checked, variant = "default", ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
dropdownMenuItemVariants({ variant }),
"pl-8 pr-2",
className
)}
checked={checked}
@ -137,12 +145,15 @@ DropdownMenuCheckboxItem.displayName =
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
}
>(({ className, children, variant = "default", ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
dropdownMenuItemVariants({ variant }),
"pl-8 pr-2",
className
)}
{...props}
@ -181,7 +192,7 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
className={cn("-mx-1 my-1 h-px bg-foreground/10", className)}
{...props}
/>
));

View File

@ -0,0 +1,40 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { FONT_OPTIONS, FontFamily } from "@/constants/font-constants";
interface FontPickerProps {
defaultValue?: FontFamily;
onValueChange?: (value: FontFamily) => void;
className?: string;
}
export function FontPicker({
defaultValue,
onValueChange,
className,
}: FontPickerProps) {
return (
<Select defaultValue={defaultValue} onValueChange={onValueChange}>
<SelectTrigger className={`w-full text-xs ${className || ""}`}>
<SelectValue placeholder="Select a font" />
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((font) => (
<SelectItem
key={font.value}
value={font.value}
className="text-xs"
style={{ fontFamily: font.value }}
>
{font.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

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

View File

@ -1,6 +1,5 @@
"use client";
import { GripVertical } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "../../lib/utils";
@ -29,17 +28,11 @@ const ResizableHandle = ({
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
"relative flex w-px items-center justify-center bg-transparent after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
/>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@ -3,6 +3,7 @@
import * as React from "react";
import { Select as SelectPrimitive } from "radix-ui";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
@ -12,6 +13,21 @@ const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const selectItemVariants = cva(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none transition-opacity data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
{
variants: {
variant: {
default: "focus:opacity-65 focus:text-accent-foreground",
destructive: "text-destructive focus:text-destructive/80",
},
},
defaultVariants: {
variant: "default",
},
}
);
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
@ -81,6 +97,10 @@ const SelectContent = React.forwardRef<
className
)}
position={position}
onCloseAutoFocus={(e) => {
e.preventDefault();
e.stopPropagation();
}}
{...props}
>
<SelectScrollUpButton />
@ -113,14 +133,13 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
variant?: VariantProps<typeof selectItemVariants>["variant"];
}
>(({ className, children, variant = "default", ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
className={cn(selectItemVariants({ variant }), className)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
@ -139,7 +158,7 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
className={cn("-mx-1 my-1 h-px bg-foreground/10", className)}
{...props}
/>
));

View File

@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-xs",
className
)}
ref={ref}

View File

@ -118,7 +118,7 @@ export function VideoPlayer({
ref={videoRef}
src={src}
poster={poster}
className={`w-full h-full object-cover ${className}`}
className={`max-w-full max-h-full object-contain ${className}`}
playsInline
preload="auto"
controls={false}

View File

@ -0,0 +1,79 @@
export interface FontOption {
value: string;
label: string;
category: "system" | "google" | "custom";
weights?: number[];
hasClassName?: boolean;
}
export const FONT_OPTIONS: FontOption[] = [
// System fonts (always available)
{ value: "Arial", label: "Arial", category: "system", hasClassName: false },
{
value: "Helvetica",
label: "Helvetica",
category: "system",
hasClassName: false,
},
{
value: "Times New Roman",
label: "Times New Roman",
category: "system",
hasClassName: false,
},
{
value: "Georgia",
label: "Georgia",
category: "system",
hasClassName: false,
},
// Google Fonts (loaded in layout.tsx)
{
value: "Inter",
label: "Inter",
category: "google",
weights: [400, 700],
hasClassName: true,
},
{
value: "Roboto",
label: "Roboto",
category: "google",
weights: [400, 700],
hasClassName: true,
},
{
value: "Open Sans",
label: "Open Sans",
category: "google",
hasClassName: true,
},
{
value: "Playfair Display",
label: "Playfair Display",
category: "google",
hasClassName: true,
},
{
value: "Comic Neue",
label: "Comic Neue",
category: "google",
hasClassName: false,
},
] as const;
export const DEFAULT_FONT = "Arial";
// Type-safe font family union
export type FontFamily = (typeof FONT_OPTIONS)[number]["value"];
// Helper functions
export const getFontByValue = (value: string): FontOption | undefined =>
FONT_OPTIONS.find((font) => font.value === value);
export const getGoogleFonts = (): FontOption[] =>
FONT_OPTIONS.filter((font) => font.category === "google");
export const getSystemFonts = (): FontOption[] =>
FONT_OPTIONS.filter((font) => font.category === "system");

View File

@ -0,0 +1,106 @@
import type { TrackType } from "@/types/timeline";
// Track color definitions
export const TRACK_COLORS: Record<
TrackType,
{ solid: string; background: string; border: string }
> = {
media: {
solid: "bg-blue-500",
background: "bg-blue-500/20",
border: "border-white/80",
},
text: {
solid: "bg-[#9C4937]",
background: "bg-[#9C4937]",
border: "border-white/80",
},
audio: {
solid: "bg-green-500",
background: "bg-green-500/20",
border: "border-white/80",
},
} as const;
// Utility functions
export function getTrackColors(type: TrackType) {
return TRACK_COLORS[type];
}
export function getTrackElementClasses(type: TrackType) {
const colors = getTrackColors(type);
return `${colors.background} ${colors.border}`;
}
// Track height definitions
export const TRACK_HEIGHTS: Record<TrackType, number> = {
media: 65,
text: 25,
audio: 50,
} as const;
// Utility function for track heights
export function getTrackHeight(type: TrackType): number {
return TRACK_HEIGHTS[type];
}
// Calculate cumulative height up to (but not including) a track index
export function getCumulativeHeightBefore(
tracks: Array<{ type: TrackType }>,
trackIndex: number
): number {
const GAP = 4; // 4px gap between tracks (equivalent to Tailwind's gap-1)
return tracks
.slice(0, trackIndex)
.reduce((sum, track) => sum + getTrackHeight(track.type) + GAP, 0);
}
// Calculate total height of all tracks
export function getTotalTracksHeight(
tracks: Array<{ type: TrackType }>
): number {
const GAP = 4; // 4px gap between tracks (equivalent to Tailwind's gap-1)
const tracksHeight = tracks.reduce(
(sum, track) => sum + getTrackHeight(track.type),
0
);
const gapsHeight = Math.max(0, tracks.length - 1) * GAP; // n-1 gaps for n tracks
return tracksHeight + gapsHeight;
}
// Other timeline constants
export const TIMELINE_CONSTANTS = {
ELEMENT_MIN_WIDTH: 80,
PIXELS_PER_SECOND: 50,
TRACK_HEIGHT: 60, // Default fallback
DEFAULT_TEXT_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;
}

244
apps/web/src/data/colors.ts Normal file
View File

@ -0,0 +1,244 @@
export const colors = [
"#ffffff",
"#000000",
"#ffe2e2",
"#ffc9c9",
"#ffa2a2",
"#ff6467",
"#fb2c36",
"#e7000b",
"#c10007",
"#9f0712",
"#82181a",
"#460809",
"#fff7ed",
"#ffedd4",
"#ffd6a7",
"#ffb86a",
"#ff8904",
"#ff6900",
"#f54900",
"#ca3500",
"#9f2d00",
"#7e2a0c",
"#441306",
"#fffbeb",
"#fef3c6",
"#fee685",
"#ffd230",
"#ffb900",
"#fe9a00",
"#e17100",
"#bb4d00",
"#973c00",
"#7b3306",
"#461901",
"#fefce8",
"#fef9c2",
"#fff085",
"#ffdf20",
"#fdc700",
"#f0b100",
"#d08700",
"#a65f00",
"#894b00",
"#733e0a",
"#432004",
"#f7fee7",
"#ecfcca",
"#d8f999",
"#bbf451",
"#9ae600",
"#7ccf00",
"#5ea500",
"#497d00",
"#3c6300",
"#35530e",
"#192e03",
"#f0fdf4",
"#dcfce7",
"#b9f8cf",
"#7bf1a8",
"#05df72",
"#00c950",
"#00a63e",
"#008236",
"#016630",
"#0d542b",
"#032e15",
"#ecfdf5",
"#d0fae5",
"#a4f4cf",
"#5ee9b5",
"#00d492",
"#00bc7d",
"#009966",
"#007a55",
"#006045",
"#004f3b",
"#002c22",
"#f0fdfa",
"#cbfbf1",
"#96f7e4",
"#46ecd5",
"#00d5be",
"#00bba7",
"#009689",
"#00786f",
"#005f5a",
"#0b4f4a",
"#022f2e",
"#ecfeff",
"#cefafe",
"#a2f4fd",
"#53eafd",
"#00d3f2",
"#00b8db",
"#0092b8",
"#007595",
"#005f78",
"#104e64",
"#053345",
"#f0f9ff",
"#dff2fe",
"#b8e6fe",
"#74d4ff",
"#00bcff",
"#00a6f4",
"#0084d1",
"#0069a8",
"#00598a",
"#024a70",
"#052f4a",
"#eff6ff",
"#dbeafe",
"#bedbff",
"#8ec5ff",
"#51a2ff",
"#2b7fff",
"#155dfc",
"#1447e6",
"#193cb8",
"#1c398e",
"#162456",
"#eef2ff",
"#e0e7ff",
"#c6d2ff",
"#a3b3ff",
"#7c86ff",
"#615fff",
"#4f39f6",
"#432dd7",
"#372aac",
"#312c85",
"#1e1a4d",
"#f5f3ff",
"#ede9fe",
"#ddd6ff",
"#c4b4ff",
"#a684ff",
"#8e51ff",
"#7f22fe",
"#7008e7",
"#5d0ec0",
"#4d179a",
"#2f0d68",
"#faf5ff",
"#f3e8ff",
"#e9d4ff",
"#dab2ff",
"#c27aff",
"#ad46ff",
"#9810fa",
"#8200db",
"#6e11b0",
"#59168b",
"#3c0366",
"#fdf4ff",
"#fae8ff",
"#f6cfff",
"#f4a8ff",
"#ed6aff",
"#e12afb",
"#c800de",
"#a800b7",
"#8a0194",
"#721378",
"#4b004f",
"#fdf2f8",
"#fce7f3",
"#fccee8",
"#fda5d5",
"#fb64b6",
"#f6339a",
"#e60076",
"#c6005c",
"#a3004c",
"#861043",
"#510424",
"#fff1f2",
"#ffe4e6",
"#ffccd3",
"#ffa1ad",
"#ff637e",
"#ff2056",
"#ec003f",
"#c70036",
"#a50036",
"#8b0836",
"#4d0218",
"#f8fafc",
"#f1f5f9",
"#e2e8f0",
"#cad5e2",
"#90a1b9",
"#62748e",
"#45556c",
"#314158",
"#1d293d",
"#0f172b",
"#020618",
"#f9fafb",
"#f3f4f6",
"#e5e7eb",
"#d1d5dc",
"#99a1af",
"#6a7282",
"#4a5565",
"#364153",
"#1e2939",
"#101828",
"#030712",
"#fafafa",
"#f4f4f5",
"#e4e4e7",
"#d4d4d8",
"#9f9fa9",
"#71717b",
"#52525c",
"#3f3f46",
"#27272a",
"#18181b",
"#09090b",
"#f5f5f5",
"#e5e5e5",
"#d4d4d4",
"#a1a1a1",
"#737373",
"#525252",
"#404040",
"#262626",
"#171717",
"#0a0a0a",
"#fafaf9",
"#f5f5f4",
"#e7e5e4",
"#d6d3d1",
"#a6a09b",
"#79716b",
"#57534d",
"#44403b",
"#292524",
"#1c1917",
"#0c0a09",
];

View File

@ -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.");

View File

@ -0,0 +1,92 @@
import { useEditorStore } from "@/stores/editor-store";
import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
export function useAspectRatio() {
const { canvasSize, canvasMode, canvasPresets } = useEditorStore();
const { mediaItems } = useMediaStore();
const { tracks } = useTimelineStore();
// Find the current preset based on canvas size
const currentPreset = canvasPresets.find(
(preset) =>
preset.width === canvasSize.width && preset.height === canvasSize.height
);
// Get the original aspect ratio from the first video/image in timeline
const getOriginalAspectRatio = (): number => {
// Find first video or image in timeline
for (const track of tracks) {
for (const element of track.elements) {
if (element.type === "media") {
const mediaItem = mediaItems.find(
(item) => item.id === element.mediaId
);
if (
mediaItem &&
(mediaItem.type === "video" || mediaItem.type === "image")
) {
return getMediaAspectRatio(mediaItem);
}
}
}
}
return 16 / 9; // Default aspect ratio
};
// Get current aspect ratio
const getCurrentAspectRatio = (): number => {
return canvasSize.width / canvasSize.height;
};
// Format aspect ratio as a readable string
const formatAspectRatio = (aspectRatio: number): string => {
// Check if it matches a common aspect ratio
const ratios = [
{ ratio: 16 / 9, label: "16:9" },
{ ratio: 9 / 16, label: "9:16" },
{ ratio: 1, label: "1:1" },
{ ratio: 4 / 3, label: "4:3" },
{ ratio: 3 / 4, label: "3:4" },
{ ratio: 21 / 9, label: "21:9" },
];
for (const { ratio, label } of ratios) {
if (Math.abs(aspectRatio - ratio) < 0.01) {
return label;
}
}
// If not a common ratio, format as decimal
return aspectRatio.toFixed(2);
};
// Check if current mode is "Original"
const isOriginal = canvasMode === "original";
// Get display name for current aspect ratio
const getDisplayName = (): string => {
// If explicitly set to original mode, always show "Original"
if (canvasMode === "original") {
return "Original";
}
if (currentPreset) {
return currentPreset.name;
}
return formatAspectRatio(getCurrentAspectRatio());
};
return {
currentPreset,
canvasMode,
isOriginal,
getCurrentAspectRatio,
getOriginalAspectRatio,
formatAspectRatio,
getDisplayName,
canvasSize,
canvasPresets,
};
}

View File

@ -1,226 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
interface DragState {
isDragging: boolean;
clipId: string | null;
trackId: string | null;
startMouseX: number;
startClipTime: number;
clickOffsetTime: number;
currentTime: number;
}
export function useDragClip(zoomLevel: number) {
const { tracks, updateClipStartTime, moveClipToTrack } = useTimelineStore();
const [dragState, setDragState] = useState<DragState>({
isDragging: false,
clipId: null,
trackId: null,
startMouseX: 0,
startClipTime: 0,
clickOffsetTime: 0,
currentTime: 0,
});
const timelineRef = useRef<HTMLDivElement>(null);
const dragStateRef = useRef(dragState);
// Keep ref in sync with state
dragStateRef.current = dragState;
const startDrag = useCallback(
(
e: React.MouseEvent,
clipId: string,
trackId: string,
clipStartTime: number,
clickOffsetTime: number
) => {
e.preventDefault();
e.stopPropagation();
setDragState({
isDragging: true,
clipId,
trackId,
startMouseX: e.clientX,
startClipTime: clipStartTime,
clickOffsetTime,
currentTime: clipStartTime,
});
},
[]
);
const updateDrag = useCallback(
(e: MouseEvent) => {
if (!dragState.isDragging || !timelineRef.current) {
return;
}
const timelineRect = timelineRef.current.getBoundingClientRect();
const mouseX = e.clientX - timelineRect.left;
const mouseTime = Math.max(0, mouseX / (50 * zoomLevel));
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
const snappedTime = Math.round(adjustedTime * 10) / 10;
setDragState((prev) => ({
...prev,
currentTime: snappedTime,
}));
},
[dragState.isDragging, dragState.clickOffsetTime, zoomLevel]
);
const endDrag = useCallback(
(targetTrackId?: string) => {
if (!dragState.isDragging || !dragState.clipId || !dragState.trackId)
return;
const finalTrackId = targetTrackId || dragState.trackId;
const finalTime = dragState.currentTime;
// Check for overlaps
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const targetTrack = tracks.find((t) => t.id === finalTrackId);
const movingClip = sourceTrack?.clips.find(
(c) => c.id === dragState.clipId
);
if (!movingClip || !targetTrack) {
setDragState((prev) => ({ ...prev, isDragging: false }));
return;
}
const movingClipDuration =
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
const movingClipEnd = finalTime + movingClipDuration;
const hasOverlap = targetTrack.clips.some((existingClip) => {
// Skip the clip being moved if it's on the same track
if (
dragState.trackId === finalTrackId &&
existingClip.id === dragState.clipId
) {
return false;
}
const existingStart = existingClip.startTime;
const existingEnd =
existingClip.startTime +
(existingClip.duration -
existingClip.trimStart -
existingClip.trimEnd);
return finalTime < existingEnd && movingClipEnd > existingStart;
});
if (!hasOverlap) {
if (dragState.trackId === finalTrackId) {
// Moving within same track
updateClipStartTime(finalTrackId, dragState.clipId!, finalTime);
} else {
// Moving to different track
moveClipToTrack(dragState.trackId!, finalTrackId, dragState.clipId!);
requestAnimationFrame(() => {
updateClipStartTime(finalTrackId, dragState.clipId!, finalTime);
});
}
}
setDragState({
isDragging: false,
clipId: null,
trackId: null,
startMouseX: 0,
startClipTime: 0,
clickOffsetTime: 0,
currentTime: 0,
});
},
[dragState, tracks, updateClipStartTime, moveClipToTrack]
);
const cancelDrag = useCallback(() => {
setDragState({
isDragging: false,
clipId: null,
trackId: null,
startMouseX: 0,
startClipTime: 0,
clickOffsetTime: 0,
currentTime: 0,
});
}, []);
// Global mouse events
useEffect(() => {
if (!dragState.isDragging) return;
const handleMouseMove = (e: MouseEvent) => updateDrag(e);
const handleMouseUp = () => endDrag();
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") cancelDrag();
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.removeEventListener("keydown", handleEscape);
};
}, [dragState.isDragging, updateDrag, endDrag, cancelDrag]);
const getDraggedClipPosition = useCallback(
(clipId: string) => {
// Use ref to get current state, not stale closure
const currentDragState = dragStateRef.current;
const isMatch =
currentDragState.isDragging && currentDragState.clipId === clipId;
if (isMatch) {
return currentDragState.currentTime;
}
return null;
},
[] // No dependencies needed since we use ref
);
const isValidDropTarget = useCallback(
(trackId: string) => {
if (!dragState.isDragging) return false;
const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
const targetTrack = tracks.find((t) => t.id === trackId);
if (!sourceTrack || !targetTrack) return false;
// For now, allow drops on same track type
return sourceTrack.type === targetTrack.type;
},
[dragState.isDragging, dragState.trackId, tracks]
);
return {
// State
isDragging: dragState.isDragging,
draggedClipId: dragState.clipId,
currentDragTime: dragState.currentTime,
clickOffsetTime: dragState.clickOffsetTime,
// Methods
startDrag,
endDrag,
cancelDrag,
getDraggedClipPosition,
isValidDropTarget,
// Refs
timelineRef,
};
}

View File

@ -7,106 +7,105 @@ export const usePlaybackControls = () => {
const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore();
const {
selectedClips,
selectedElements,
tracks,
splitClip,
splitElement,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
} = useTimelineStore();
const handleSplitSelectedClip = useCallback(() => {
if (selectedClips.length !== 1) {
toast.error("Select exactly one clip to split");
const handleSplitSelectedElement = useCallback(() => {
if (selectedElements.length !== 1) {
toast.error("Select exactly one element to split");
return;
}
const { trackId, clipId } = selectedClips[0];
const { trackId, elementId } = selectedElements[0];
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
const element = track?.elements.find((e) => e.id === elementId);
if (!clip) return;
if (!element) return;
const effectiveStart = clip.startTime;
const effectiveStart = element.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within selected clip");
toast.error("Playhead must be within selected element");
return;
}
splitClip(trackId, clipId, currentTime);
toast.success("Clip split at playhead");
}, [selectedClips, tracks, currentTime, splitClip]);
splitElement(trackId, elementId, currentTime);
}, [selectedElements, tracks, currentTime, splitElement]);
const handleSplitAndKeepLeftCallback = useCallback(() => {
if (selectedClips.length !== 1) {
toast.error("Select exactly one clip");
if (selectedElements.length !== 1) {
toast.error("Select exactly one element");
return;
}
const { trackId, clipId } = selectedClips[0];
const { trackId, elementId } = selectedElements[0];
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
const element = track?.elements.find((e) => e.id === elementId);
if (!clip) return;
if (!element) return;
const effectiveStart = clip.startTime;
const effectiveStart = element.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within selected clip");
toast.error("Playhead must be within selected element");
return;
}
splitAndKeepLeft(trackId, clipId, currentTime);
toast.success("Split and kept left portion");
}, [selectedClips, tracks, currentTime, splitAndKeepLeft]);
splitAndKeepLeft(trackId, elementId, currentTime);
}, [selectedElements, tracks, currentTime, splitAndKeepLeft]);
const handleSplitAndKeepRightCallback = useCallback(() => {
if (selectedClips.length !== 1) {
toast.error("Select exactly one clip");
if (selectedElements.length !== 1) {
toast.error("Select exactly one element");
return;
}
const { trackId, clipId } = selectedClips[0];
const { trackId, elementId } = selectedElements[0];
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
const element = track?.elements.find((e) => e.id === elementId);
if (!clip) return;
if (!element) return;
const effectiveStart = clip.startTime;
const effectiveStart = element.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within selected clip");
toast.error("Playhead must be within selected element");
return;
}
splitAndKeepRight(trackId, clipId, currentTime);
toast.success("Split and kept right portion");
}, [selectedClips, tracks, currentTime, splitAndKeepRight]);
splitAndKeepRight(trackId, elementId, currentTime);
}, [selectedElements, tracks, currentTime, splitAndKeepRight]);
const handleSeparateAudioCallback = useCallback(() => {
if (selectedClips.length !== 1) {
toast.error("Select exactly one video clip to separate audio");
if (selectedElements.length !== 1) {
toast.error("Select exactly one media element to separate audio");
return;
}
const { trackId, clipId } = selectedClips[0];
const { trackId, elementId } = selectedElements[0];
const track = tracks.find((t) => t.id === trackId);
if (!track || track.type !== "video") {
toast.error("Select a video clip to separate audio");
if (!track || track.type !== "media") {
toast.error("Select a media element to separate audio");
return;
}
separateAudio(trackId, clipId);
toast.success("Audio separated to audio track");
}, [selectedClips, tracks, separateAudio]);
separateAudio(trackId, elementId);
}, [selectedElements, tracks, separateAudio]);
const handleKeyPress = useCallback(
(e: KeyboardEvent) => {
@ -130,7 +129,7 @@ export const usePlaybackControls = () => {
case "s":
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
handleSplitSelectedClip();
handleSplitSelectedElement();
}
break;
@ -160,7 +159,7 @@ export const usePlaybackControls = () => {
isPlaying,
play,
pause,
handleSplitSelectedClip,
handleSplitSelectedElement,
handleSplitAndKeepLeftCallback,
handleSplitAndKeepRightCallback,
handleSeparateAudioCallback,

View File

@ -0,0 +1,199 @@
import { useState, useEffect, useCallback } from "react";
interface UseSelectionBoxProps {
containerRef: React.RefObject<HTMLElement>;
playheadRef?: React.RefObject<HTMLElement>;
onSelectionComplete: (
elements: { trackId: string; elementId: string }[]
) => void;
isEnabled?: boolean;
}
interface SelectionBoxState {
startPos: { x: number; y: number };
currentPos: { x: number; y: number };
isActive: boolean;
}
export function useSelectionBox({
containerRef,
playheadRef,
onSelectionComplete,
isEnabled = true,
}: UseSelectionBoxProps) {
const [selectionBox, setSelectionBox] = useState<SelectionBoxState | null>(
null
);
const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
// Mouse down handler to start selection
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!isEnabled) return;
// Only start selection on empty space clicks
if ((e.target as HTMLElement).closest(".timeline-element")) {
return;
}
if (playheadRef?.current?.contains(e.target as Node)) {
return;
}
if ((e.target as HTMLElement).closest("[data-track-labels]")) {
return;
}
// Don't start selection when clicking in the ruler area - this interferes with playhead dragging
if ((e.target as HTMLElement).closest("[data-ruler-area]")) {
return;
}
setSelectionBox({
startPos: { x: e.clientX, y: e.clientY },
currentPos: { x: e.clientX, y: e.clientY },
isActive: false, // Will become active when mouse moves
});
},
[isEnabled, playheadRef]
);
// Function to select elements within the selection box
const selectElementsInBox = useCallback(
(startPos: { x: number; y: number }, endPos: { x: number; y: number }) => {
if (!containerRef.current) return;
const container = containerRef.current;
const containerRect = container.getBoundingClientRect();
// Calculate selection rectangle in container coordinates
const startX = startPos.x - containerRect.left;
const startY = startPos.y - containerRect.top;
const endX = endPos.x - containerRect.left;
const endY = endPos.y - containerRect.top;
const selectionRect = {
left: Math.min(startX, endX),
top: Math.min(startY, endY),
right: Math.max(startX, endX),
bottom: Math.max(startY, endY),
};
// Find all timeline elements within the selection rectangle
const timelineElements = container.querySelectorAll(".timeline-element");
const selectedElements: { trackId: string; elementId: string }[] = [];
timelineElements.forEach((element) => {
const elementRect = element.getBoundingClientRect();
// Use absolute coordinates for more accurate intersection detection
const elementAbsolute = {
left: elementRect.left,
top: elementRect.top,
right: elementRect.right,
bottom: elementRect.bottom,
};
const selectionAbsolute = {
left: startPos.x,
top: startPos.y,
right: endPos.x,
bottom: endPos.y,
};
// Normalize selection rectangle (handle dragging in any direction)
const normalizedSelection = {
left: Math.min(selectionAbsolute.left, selectionAbsolute.right),
top: Math.min(selectionAbsolute.top, selectionAbsolute.bottom),
right: Math.max(selectionAbsolute.left, selectionAbsolute.right),
bottom: Math.max(selectionAbsolute.top, selectionAbsolute.bottom),
};
const elementId = element.getAttribute("data-element-id");
const trackId = element.getAttribute("data-track-id");
// Check if element intersects with selection rectangle (any overlap)
// Using absolute coordinates for more precise detection
const intersects = !(
elementAbsolute.right < normalizedSelection.left ||
elementAbsolute.left > normalizedSelection.right ||
elementAbsolute.bottom < normalizedSelection.top ||
elementAbsolute.top > normalizedSelection.bottom
);
if (intersects && elementId && trackId) {
selectedElements.push({ trackId, elementId });
}
});
// Always call the callback - with elements or empty array to clear selection
console.log(
JSON.stringify({ selectElementsInBox: selectedElements.length })
);
onSelectionComplete(selectedElements);
},
[containerRef, onSelectionComplete]
);
// Effect to track selection box movement
useEffect(() => {
if (!selectionBox) return;
const handleMouseMove = (e: MouseEvent) => {
const deltaX = Math.abs(e.clientX - selectionBox.startPos.x);
const deltaY = Math.abs(e.clientY - selectionBox.startPos.y);
// Start selection if mouse moved more than 5px
const shouldActivate = deltaX > 5 || deltaY > 5;
const newSelectionBox = {
...selectionBox,
currentPos: { x: e.clientX, y: e.clientY },
isActive: shouldActivate || selectionBox.isActive,
};
setSelectionBox(newSelectionBox);
// Real-time visual feedback: update selection as we drag
if (newSelectionBox.isActive) {
selectElementsInBox(
newSelectionBox.startPos,
newSelectionBox.currentPos
);
}
};
const handleMouseUp = () => {
console.log(
JSON.stringify({ mouseUp: { wasActive: selectionBox?.isActive } })
);
// If we had an active selection, mark that we just finished selecting
if (selectionBox?.isActive) {
console.log(JSON.stringify({ settingJustFinishedSelecting: true }));
setJustFinishedSelecting(true);
// Clear the flag after a short delay to allow click events to check it
setTimeout(() => {
console.log(JSON.stringify({ clearingJustFinishedSelecting: true }));
setJustFinishedSelecting(false);
}, 50);
}
// Don't call selectElementsInBox again - real-time selection already handled it
// Just clean up the selection box visual
setSelectionBox(null);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [selectionBox, selectElementsInBox]);
return {
selectionBox,
handleMouseDown,
isSelecting: selectionBox?.isActive || false,
justFinishedSelecting,
};
}

View File

@ -0,0 +1,164 @@
import { useState, useEffect } from "react";
import { ResizeState, TimelineElement, TimelineTrack } from "@/types/timeline";
import { useMediaStore } from "@/stores/media-store";
interface UseTimelineElementResizeProps {
element: TimelineElement;
track: TimelineTrack;
zoomLevel: number;
onUpdateTrim: (
trackId: string,
elementId: string,
trimStart: number,
trimEnd: number
) => void;
onUpdateDuration: (
trackId: string,
elementId: string,
duration: number
) => void;
}
export function useTimelineElementResize({
element,
track,
zoomLevel,
onUpdateTrim,
onUpdateDuration,
}: UseTimelineElementResizeProps) {
const [resizing, setResizing] = useState<ResizeState | null>(null);
const { mediaItems } = useMediaStore();
// Set up document-level mouse listeners during resize (like proper drag behavior)
useEffect(() => {
if (!resizing) return;
const handleDocumentMouseMove = (e: MouseEvent) => {
updateTrimFromMouseMove({ clientX: e.clientX });
};
const handleDocumentMouseUp = () => {
handleResizeEnd();
};
// Add document-level listeners for proper drag behavior
document.addEventListener("mousemove", handleDocumentMouseMove);
document.addEventListener("mouseup", handleDocumentMouseUp);
return () => {
document.removeEventListener("mousemove", handleDocumentMouseMove);
document.removeEventListener("mouseup", handleDocumentMouseUp);
};
}, [resizing]); // Re-run when resizing state changes
const handleResizeStart = (
e: React.MouseEvent,
elementId: string,
side: "left" | "right"
) => {
e.stopPropagation();
e.preventDefault();
setResizing({
elementId,
side,
startX: e.clientX,
initialTrimStart: element.trimStart,
initialTrimEnd: element.trimEnd,
});
};
const canExtendElementDuration = () => {
// Text elements can always be extended
if (element.type === "text") {
return true;
}
// Media elements - check the media type
if (element.type === "media") {
const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
if (!mediaItem) return false;
// Images can be extended (static content)
if (mediaItem.type === "image") {
return true;
}
// Videos and audio cannot be extended beyond their natural duration
// (no additional content exists)
return false;
}
return false;
};
const updateTrimFromMouseMove = (e: { clientX: number }) => {
if (!resizing) return;
const deltaX = e.clientX - resizing.startX;
// Reasonable sensitivity for resize operations - similar to timeline scale
const deltaTime = deltaX / (50 * zoomLevel);
if (resizing.side === "left") {
// Left resize - only trim within original duration
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);
} else {
// Right resize - can extend duration for supported element types
const calculated = resizing.initialTrimEnd - deltaTime;
if (calculated < 0) {
// We're trying to extend beyond original duration
if (canExtendElementDuration()) {
// Extend the duration instead of reducing trimEnd further
const extensionNeeded = Math.abs(calculated);
const newDuration = element.duration + extensionNeeded;
const newTrimEnd = 0; // Reset trimEnd to 0 since we're extending
// Update duration first, then trim
onUpdateDuration(track.id, element.id, newDuration);
onUpdateTrim(
track.id,
element.id,
resizing.initialTrimStart,
newTrimEnd
);
} else {
// Can't extend - just set trimEnd to 0 (maximum possible extension)
onUpdateTrim(track.id, element.id, resizing.initialTrimStart, 0);
}
} 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(
track.id,
element.id,
resizing.initialTrimStart,
newTrimEnd
);
}
}
};
const handleResizeMove = (e: React.MouseEvent) => {
updateTrimFromMouseMove(e);
};
const handleResizeEnd = () => {
setResizing(null);
};
return {
resizing,
isResizing: resizing !== null,
handleResizeStart,
// Return empty handlers since we use document listeners now
handleResizeMove: () => {}, // Not used anymore
handleResizeEnd: () => {}, // Not used anymore
};
}

View File

@ -0,0 +1,157 @@
import { snapTimeToFrame } from "@/constants/timeline-constants";
import { useProjectStore } from "@/stores/project-store";
import { useState, useEffect, useCallback } from "react";
interface UseTimelinePlayheadProps {
currentTime: number;
duration: number;
zoomLevel: number;
seek: (time: number) => void;
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>;
}
export function useTimelinePlayhead({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
}: UseTimelinePlayheadProps) {
// Playhead scrubbing state
const [isScrubbing, setIsScrubbing] = useState(false);
const [scrubTime, setScrubTime] = useState<number | null>(null);
// Ruler drag detection state
const [isDraggingRuler, setIsDraggingRuler] = useState(false);
const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
const playheadPosition =
isScrubbing && scrubTime !== null ? scrubTime : currentTime;
// --- Playhead Scrubbing Handlers ---
const handlePlayheadMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation(); // Prevent ruler drag from triggering
setIsScrubbing(true);
handleScrub(e);
},
[duration, zoomLevel]
);
// Ruler mouse down handler
const handleRulerMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only handle left mouse button
if (e.button !== 0) return;
// Don't interfere if clicking on the playhead itself
if (playheadRef?.current?.contains(e.target as Node)) return;
e.preventDefault();
setIsDraggingRuler(true);
setHasDraggedRuler(false);
// Start scrubbing immediately
setIsScrubbing(true);
handleScrub(e);
},
[duration, zoomLevel]
);
const handleScrub = useCallback(
(e: MouseEvent | React.MouseEvent) => {
const ruler = rulerRef.current;
if (!ruler) return;
const rect = ruler.getBoundingClientRect();
const x = e.clientX - rect.left;
const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
// Use frame snapping for playhead scrubbing
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
const time = snapTimeToFrame(rawTime, projectFps);
setScrubTime(time);
seek(time); // update video preview in real time
},
[duration, zoomLevel, seek, rulerRef]
);
// Mouse move/up event handlers
useEffect(() => {
if (!isScrubbing) return;
const onMouseMove = (e: MouseEvent) => {
handleScrub(e);
// Mark that we've dragged if ruler drag is active
if (isDraggingRuler) {
setHasDraggedRuler(true);
}
};
const onMouseUp = (e: MouseEvent) => {
setIsScrubbing(false);
if (scrubTime !== null) seek(scrubTime); // finalize seek
setScrubTime(null);
// Handle ruler click vs drag
if (isDraggingRuler) {
setIsDraggingRuler(false);
// If we didn't drag, treat it as a click-to-seek
if (!hasDraggedRuler) {
handleScrub(e);
}
setHasDraggedRuler(false);
}
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
return () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, [
isScrubbing,
scrubTime,
seek,
handleScrub,
isDraggingRuler,
hasDraggedRuler,
]);
// --- Playhead auto-scroll effect ---
useEffect(() => {
const rulerViewport = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
if (!rulerViewport || !tracksViewport) return;
const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
const viewportWidth = rulerViewport.clientWidth;
const scrollMin = 0;
const scrollMax = rulerViewport.scrollWidth - viewportWidth;
// Center the playhead if it's not visible (100px buffer)
const desiredScroll = Math.max(
scrollMin,
Math.min(scrollMax, playheadPx - viewportWidth / 2)
);
if (
playheadPx < rulerViewport.scrollLeft + 100 ||
playheadPx > rulerViewport.scrollLeft + viewportWidth - 100
) {
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
}
}, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]);
return {
playheadPosition,
handlePlayheadMouseDown,
handleRulerMouseDown,
isDraggingRuler,
};
}

View File

@ -0,0 +1,54 @@
import { useState, useCallback, useEffect, RefObject } from "react";
interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement>;
isInTimeline?: boolean;
}
interface UseTimelineZoomReturn {
zoomLevel: number;
setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
handleWheel: (e: React.WheelEvent) => void;
}
export function useTimelineZoom({
containerRef,
isInTimeline = false,
}: UseTimelineZoomProps): UseTimelineZoomReturn {
const [zoomLevel, setZoomLevel] = useState(1);
const handleWheel = useCallback((e: React.WheelEvent) => {
// Only zoom if user is using pinch gesture (ctrlKey or metaKey is true)
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.15 : 0.15;
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta)));
}
// Otherwise, allow normal scrolling
}, []);
// Prevent browser zooming in/out when in timeline
useEffect(() => {
const preventZoom = (e: WheelEvent) => {
if (
isInTimeline &&
(e.ctrlKey || e.metaKey) &&
containerRef.current?.contains(e.target as Node)
) {
e.preventDefault();
}
};
document.addEventListener("wheel", preventZoom, { passive: false });
return () => {
document.removeEventListener("wheel", preventZoom);
};
}, [isInTimeline, containerRef]);
return {
zoomLevel,
setZoomLevel,
handleWheel,
};
}

View File

@ -10,7 +10,7 @@ export async function getStars(): Promise<string> {
if (!res.ok) {
throw new Error(`GitHub API error: ${res.status} ${res.statusText}`);
}
const data = await res.json();
const data = (await res.json()) as { stargazers_count: number };
const count = data.stargazers_count;
if (typeof count !== "number") {

View File

@ -0,0 +1,39 @@
import {
Inter,
Roboto,
Open_Sans,
Playfair_Display,
Comic_Neue,
} from "next/font/google";
// Configure all fonts
const inter = Inter({ subsets: ["latin"] });
const roboto = Roboto({ subsets: ["latin"], weight: ["400", "700"] });
const openSans = Open_Sans({ subsets: ["latin"] });
const playfairDisplay = Playfair_Display({ subsets: ["latin"] });
const comicNeue = Comic_Neue({ subsets: ["latin"], weight: ["400", "700"] });
// Export font class mapping for use in components
export const FONT_CLASS_MAP = {
Inter: inter.className,
Roboto: roboto.className,
"Open Sans": openSans.className,
"Playfair Display": playfairDisplay.className,
"Comic Neue": comicNeue.className,
Arial: "",
Helvetica: "",
"Times New Roman": "",
Georgia: "",
} as const;
// Export individual fonts for use in layout
export const fonts = {
inter,
roboto,
openSans,
playfairDisplay,
comicNeue,
};
// Default font for the body
export const defaultFont = inter;

View File

@ -1,81 +1,101 @@
import { toast } from "sonner";
import {
getFileType,
generateVideoThumbnail,
getMediaDuration,
getImageAspectRatio,
type MediaItem,
} from "@/stores/media-store";
// import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils"; // Temporarily disabled
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
export async function processMediaFiles(
files: FileList | File[],
onProgress?: (progress: number) => void
): Promise<ProcessedMediaItem[]> {
const fileArray = Array.from(files);
const processedItems: ProcessedMediaItem[] = [];
const total = fileArray.length;
let completed = 0;
for (const file of fileArray) {
const fileType = getFileType(file);
if (!fileType) {
toast.error(`Unsupported file type: ${file.name}`);
continue;
}
const url = URL.createObjectURL(file);
let thumbnailUrl: string | undefined;
let duration: number | undefined;
let aspectRatio: number = 16 / 9; // Default fallback
try {
if (fileType === "image") {
// Get image aspect ratio
aspectRatio = await getImageAspectRatio(file);
} else if (fileType === "video") {
// Use basic thumbnail generation for now
const videoResult = await generateVideoThumbnail(file);
thumbnailUrl = videoResult.thumbnailUrl;
aspectRatio = videoResult.aspectRatio;
} else if (fileType === "audio") {
// For audio, use a square aspect ratio
aspectRatio = 1;
}
// Get duration for videos and audio (if not already set by FFmpeg)
if ((fileType === "video" || fileType === "audio") && !duration) {
duration = await getMediaDuration(file);
}
processedItems.push({
name: file.name,
type: fileType,
file,
url,
thumbnailUrl,
duration,
aspectRatio,
});
// Yield back to the event loop to keep the UI responsive
await new Promise((resolve) => setTimeout(resolve, 0));
completed += 1;
if (onProgress) {
const percent = Math.round((completed / total) * 100);
onProgress(percent);
}
} catch (error) {
console.error("Error processing file:", file.name, error);
toast.error(`Failed to process ${file.name}`);
URL.revokeObjectURL(url); // Clean up on error
}
}
return processedItems;
}
import { toast } from "sonner";
import {
getFileType,
generateVideoThumbnail,
getMediaDuration,
getImageDimensions,
type MediaItem,
} from "@/stores/media-store";
import { generateThumbnail, getVideoInfo } from "./ffmpeg-utils";
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
export async function processMediaFiles(
files: FileList | File[],
onProgress?: (progress: number) => void
): Promise<ProcessedMediaItem[]> {
const fileArray = Array.from(files);
const processedItems: ProcessedMediaItem[] = [];
const total = fileArray.length;
let completed = 0;
for (const file of fileArray) {
const fileType = getFileType(file);
if (!fileType) {
toast.error(`Unsupported file type: ${file.name}`);
continue;
}
const url = URL.createObjectURL(file);
let thumbnailUrl: string | undefined;
let duration: number | undefined;
let width: number | undefined;
let height: number | undefined;
let fps: number | undefined;
try {
if (fileType === "image") {
// Get image dimensions
const dimensions = await getImageDimensions(file);
width = dimensions.width;
height = dimensions.height;
} else if (fileType === "video") {
try {
// Use FFmpeg for comprehensive video info extraction
const videoInfo = await getVideoInfo(file);
duration = videoInfo.duration;
width = videoInfo.width;
height = videoInfo.height;
fps = videoInfo.fps;
// Generate thumbnail using FFmpeg
thumbnailUrl = await generateThumbnail(file, 1);
} catch (error) {
console.warn(
"FFmpeg processing failed, falling back to basic processing:",
error
);
// Fallback to basic processing
const videoResult = await generateVideoThumbnail(file);
thumbnailUrl = videoResult.thumbnailUrl;
width = videoResult.width;
height = videoResult.height;
duration = await getMediaDuration(file);
// FPS will remain undefined for fallback
}
} else if (fileType === "audio") {
// For audio, we don't set width/height/fps (they'll be undefined)
duration = await getMediaDuration(file);
}
processedItems.push({
name: file.name,
type: fileType,
file,
url,
thumbnailUrl,
duration,
width,
height,
fps,
});
// Yield back to the event loop to keep the UI responsive
await new Promise((resolve) => setTimeout(resolve, 0));
completed += 1;
if (onProgress) {
const percent = Math.round((completed / total) * 100);
onProgress(percent);
}
} catch (error) {
console.error("Error processing file:", file.name, error);
toast.error(`Failed to process ${file.name}`);
URL.revokeObjectURL(url); // Clean up on error
}
}
return processedItems;
}

View File

@ -0,0 +1,89 @@
import { StorageAdapter } from "./types";
export class IndexedDBAdapter<T> implements StorageAdapter<T> {
private dbName: string;
private storeName: string;
private version: number;
constructor(dbName: string, storeName: string, version: number = 1) {
this.dbName = dbName;
this.storeName = storeName;
this.version = version;
}
private async getDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: "id" });
}
};
});
}
async get(key: string): Promise<T | null> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.get(key);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || null);
});
}
async set(key: string, value: T): Promise<void> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.put({ id: key, ...value });
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
async remove(key: string): Promise<void> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.delete(key);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
async list(): Promise<string[]> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.getAllKeys();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result as string[]);
});
}
async clear(): Promise<void> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.clear();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
}

View File

@ -0,0 +1,73 @@
import { StorageAdapter } from "./types";
export class OPFSAdapter implements StorageAdapter<File> {
private directoryName: string;
constructor(directoryName: string = "media") {
this.directoryName = directoryName;
}
private async getDirectory(): Promise<FileSystemDirectoryHandle> {
const opfsRoot = await navigator.storage.getDirectory();
return await opfsRoot.getDirectoryHandle(this.directoryName, {
create: true,
});
}
async get(key: string): Promise<File | null> {
try {
const directory = await this.getDirectory();
const fileHandle = await directory.getFileHandle(key);
return await fileHandle.getFile();
} catch (error) {
if ((error as Error).name === "NotFoundError") {
return null;
}
throw error;
}
}
async set(key: string, file: File): Promise<void> {
const directory = await this.getDirectory();
const fileHandle = await directory.getFileHandle(key, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(file);
await writable.close();
}
async remove(key: string): Promise<void> {
try {
const directory = await this.getDirectory();
await directory.removeEntry(key);
} catch (error) {
if ((error as Error).name !== "NotFoundError") {
throw error;
}
}
}
async list(): Promise<string[]> {
const directory = await this.getDirectory();
const keys: string[] = [];
for await (const name of directory.keys()) {
keys.push(name);
}
return keys;
}
async clear(): Promise<void> {
const directory = await this.getDirectory();
for await (const name of directory.keys()) {
await directory.removeEntry(name);
}
}
// Helper method to check OPFS support
static isSupported(): boolean {
return "storage" in navigator && "getDirectory" in navigator.storage;
}
}

View File

@ -0,0 +1,279 @@
import { TProject } from "@/types/project";
import { MediaItem } from "@/stores/media-store";
import { IndexedDBAdapter } from "./indexeddb-adapter";
import { OPFSAdapter } from "./opfs-adapter";
import {
MediaFileData,
StorageConfig,
SerializedProject,
TimelineData,
} from "./types";
import { TimelineTrack } from "@/types/timeline";
class StorageService {
private projectsAdapter: IndexedDBAdapter<SerializedProject>;
private config: StorageConfig;
constructor() {
this.config = {
projectsDb: "video-editor-projects",
mediaDb: "video-editor-media",
timelineDb: "video-editor-timelines",
version: 1,
};
this.projectsAdapter = new IndexedDBAdapter<SerializedProject>(
this.config.projectsDb,
"projects",
this.config.version
);
}
// Helper to get project-specific media adapters
private getProjectMediaAdapters(projectId: string) {
const mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>(
`${this.config.mediaDb}-${projectId}`,
"media-metadata",
this.config.version
);
const mediaFilesAdapter = new OPFSAdapter(`media-files-${projectId}`);
return { mediaMetadataAdapter, mediaFilesAdapter };
}
// Helper to get project-specific timeline adapter
private getProjectTimelineAdapter(projectId: string) {
return new IndexedDBAdapter<TimelineData>(
`${this.config.timelineDb}-${projectId}`,
"timeline",
this.config.version
);
}
// Project operations
async saveProject(project: TProject): Promise<void> {
// Convert TProject to serializable format
const serializedProject: SerializedProject = {
id: project.id,
name: project.name,
thumbnail: project.thumbnail,
createdAt: project.createdAt.toISOString(),
updatedAt: project.updatedAt.toISOString(),
backgroundColor: project.backgroundColor,
backgroundType: project.backgroundType,
blurIntensity: project.blurIntensity,
};
await this.projectsAdapter.set(project.id, serializedProject);
}
async loadProject(id: string): Promise<TProject | null> {
const serializedProject = await this.projectsAdapter.get(id);
if (!serializedProject) return null;
// Convert back to TProject format
return {
id: serializedProject.id,
name: serializedProject.name,
thumbnail: serializedProject.thumbnail,
createdAt: new Date(serializedProject.createdAt),
updatedAt: new Date(serializedProject.updatedAt),
backgroundColor: serializedProject.backgroundColor,
backgroundType: serializedProject.backgroundType,
blurIntensity: serializedProject.blurIntensity,
};
}
async loadAllProjects(): Promise<TProject[]> {
const projectIds = await this.projectsAdapter.list();
const projects: TProject[] = [];
for (const id of projectIds) {
const project = await this.loadProject(id);
if (project) {
projects.push(project);
}
}
// Sort by last updated (most recent first)
return projects.sort(
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
);
}
async deleteProject(id: string): Promise<void> {
await this.projectsAdapter.remove(id);
}
// Media operations - now project-specific
async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise<void> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId);
// Save file to project-specific OPFS
await mediaFilesAdapter.set(mediaItem.id, mediaItem.file);
// Save metadata to project-specific IndexedDB
const metadata: MediaFileData = {
id: mediaItem.id,
name: mediaItem.name,
type: mediaItem.type,
size: mediaItem.file.size,
lastModified: mediaItem.file.lastModified,
width: mediaItem.width,
height: mediaItem.height,
duration: mediaItem.duration,
};
await mediaMetadataAdapter.set(mediaItem.id, metadata);
}
async loadMediaItem(
projectId: string,
id: string
): Promise<MediaItem | null> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId);
const [file, metadata] = await Promise.all([
mediaFilesAdapter.get(id),
mediaMetadataAdapter.get(id),
]);
if (!file || !metadata) return null;
// Create new object URL for the file
const url = URL.createObjectURL(file);
return {
id: metadata.id,
name: metadata.name,
type: metadata.type,
file,
url,
width: metadata.width,
height: metadata.height,
duration: metadata.duration,
// thumbnailUrl would need to be regenerated or cached separately
};
}
async loadAllMediaItems(projectId: string): Promise<MediaItem[]> {
const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId);
const mediaIds = await mediaMetadataAdapter.list();
const mediaItems: MediaItem[] = [];
for (const id of mediaIds) {
const item = await this.loadMediaItem(projectId, id);
if (item) {
mediaItems.push(item);
}
}
return mediaItems;
}
async deleteMediaItem(projectId: string, id: string): Promise<void> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId);
await Promise.all([
mediaFilesAdapter.remove(id),
mediaMetadataAdapter.remove(id),
]);
}
async deleteProjectMedia(projectId: string): Promise<void> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId);
await Promise.all([
mediaMetadataAdapter.clear(),
mediaFilesAdapter.clear(),
]);
}
// Timeline operations - now project-specific
async saveTimeline(
projectId: string,
tracks: TimelineTrack[]
): Promise<void> {
const timelineAdapter = this.getProjectTimelineAdapter(projectId);
const timelineData: TimelineData = {
tracks,
lastModified: new Date().toISOString(),
};
await timelineAdapter.set("timeline", timelineData);
}
async loadTimeline(projectId: string): Promise<TimelineTrack[] | null> {
const timelineAdapter = this.getProjectTimelineAdapter(projectId);
const timelineData = await timelineAdapter.get("timeline");
return timelineData ? timelineData.tracks : null;
}
async deleteProjectTimeline(projectId: string): Promise<void> {
const timelineAdapter = this.getProjectTimelineAdapter(projectId);
await timelineAdapter.remove("timeline");
}
// Utility methods
async clearAllData(): Promise<void> {
// Clear all projects
await this.projectsAdapter.clear();
// Note: Project-specific media and timelines will be cleaned up when projects are deleted
}
async getStorageInfo(): Promise<{
projects: number;
isOPFSSupported: boolean;
isIndexedDBSupported: boolean;
}> {
const projectIds = await this.projectsAdapter.list();
return {
projects: projectIds.length,
isOPFSSupported: this.isOPFSSupported(),
isIndexedDBSupported: this.isIndexedDBSupported(),
};
}
async getProjectStorageInfo(projectId: string): Promise<{
mediaItems: number;
hasTimeline: boolean;
}> {
const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId);
const timelineAdapter = this.getProjectTimelineAdapter(projectId);
const [mediaIds, timelineData] = await Promise.all([
mediaMetadataAdapter.list(),
timelineAdapter.get("timeline"),
]);
return {
mediaItems: mediaIds.length,
hasTimeline: !!timelineData,
};
}
// Check browser support
isOPFSSupported(): boolean {
return OPFSAdapter.isSupported();
}
isIndexedDBSupported(): boolean {
return "indexedDB" in window;
}
isFullySupported(): boolean {
return this.isIndexedDBSupported() && this.isOPFSSupported();
}
}
// Export singleton instance
export const storageService = new StorageService();
export { StorageService };

View File

@ -0,0 +1,49 @@
import { TProject } from "@/types/project";
import { TimelineTrack } from "@/types/timeline";
export interface StorageAdapter<T> {
get(key: string): Promise<T | null>;
set(key: string, value: T): Promise<void>;
remove(key: string): Promise<void>;
list(): Promise<string[]>;
clear(): Promise<void>;
}
export interface MediaFileData {
id: string;
name: string;
type: "image" | "video" | "audio";
size: number;
lastModified: number;
width?: number;
height?: number;
duration?: number;
// File will be stored separately in OPFS
}
export interface TimelineData {
tracks: TimelineTrack[];
lastModified: string;
}
export interface StorageConfig {
projectsDb: string;
mediaDb: string;
timelineDb: string;
version: number;
}
// Helper type for serialization - converts Date objects to strings
export type SerializedProject = Omit<TProject, "createdAt" | "updatedAt"> & {
createdAt: string;
updatedAt: string;
};
// Extend FileSystemDirectoryHandle with missing async iterator methods
declare global {
interface FileSystemDirectoryHandle {
keys(): AsyncIterableIterator<string>;
values(): AsyncIterableIterator<FileSystemHandle>;
entries(): AsyncIterableIterator<[string, FileSystemHandle]>;
}
}

25
apps/web/src/lib/time.ts Normal file
View File

@ -0,0 +1,25 @@
// Time-related utility functions
// Helper function to format time in various formats (MM:SS, HH:MM:SS, HH:MM:SS:CS, HH:MM:SS:FF)
export const formatTimeCode = (
timeInSeconds: number,
format: "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF" = "HH:MM:SS:CS",
fps: number = 30
): string => {
const hours = Math.floor(timeInSeconds / 3600);
const minutes = Math.floor((timeInSeconds % 3600) / 60);
const seconds = Math.floor(timeInSeconds % 60);
const centiseconds = Math.floor((timeInSeconds % 1) * 100);
const frames = Math.floor((timeInSeconds % 1) * fps);
switch (format) {
case "MM:SS":
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
case "HH:MM:SS":
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
case "HH:MM:SS:CS":
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${centiseconds.toString().padStart(2, "0")}`;
case "HH:MM:SS:FF":
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}:${frames.toString().padStart(2, "0")}`;
}
};

View File

@ -5,4 +5,34 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Generates a UUID v4 string
* Uses crypto.randomUUID() if available, otherwise falls back to a custom implementation
*/
export function generateUUID(): string {
// Use the native crypto.randomUUID if available
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// Secure fallback using crypto.getRandomValues
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
// Set version 4 (UUIDv4)
bytes[6] = (bytes[6] & 0x0f) | 0x40;
// Set variant 10xxxxxx
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = [...bytes].map(b => b.toString(16).padStart(2, '0'));
return (
hex.slice(0, 4).join('') + '-' +
hex.slice(4, 6).join('') + '-' +
hex.slice(6, 8).join('') + '-' +
hex.slice(8, 10).join('') + '-' +
hex.slice(10, 16).join('')
);
}

View File

@ -1,15 +1,18 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;
const session = getSessionCookie(request);
// Handle fuckcapcut.com domain redirect
if (request.headers.get("host") === "fuckcapcut.com") {
return NextResponse.redirect("https://opencut.app/why-not-capcut", 301);
}
if (path === "/editor" && !session && process.env.NODE_ENV === "production") {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", request.url);
return NextResponse.redirect(loginUrl);
const path = request.nextUrl.pathname;
if (path === "/editor" && process.env.NODE_ENV === "production") {
const homeUrl = new URL("/", request.url);
homeUrl.searchParams.set("redirect", request.url);
return NextResponse.redirect(homeUrl);
}
return NextResponse.next();

View File

@ -1,20 +1,76 @@
import { create } from "zustand";
import { CanvasSize, CanvasPreset } from "@/types/editor";
type CanvasMode = "preset" | "original" | "custom";
interface EditorState {
// Loading states
isInitializing: boolean;
isPanelsReady: boolean;
// Canvas/Project settings
canvasSize: CanvasSize;
canvasMode: CanvasMode;
canvasPresets: CanvasPreset[];
// Actions
setInitializing: (loading: boolean) => void;
setPanelsReady: (ready: boolean) => void;
initializeApp: () => Promise<void>;
setCanvasSize: (size: CanvasSize) => void;
setCanvasSizeToOriginal: (aspectRatio: number) => void;
setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
}
const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [
{ name: "16:9", width: 1920, height: 1080 },
{ name: "9:16", width: 1080, height: 1920 },
{ name: "1:1", width: 1080, height: 1080 },
{ name: "4:3", width: 1440, height: 1080 },
];
// Helper function to find the best matching canvas preset for an aspect ratio
const findBestCanvasPreset = (aspectRatio: number): CanvasSize => {
// Calculate aspect ratio for each preset and find the closest match
let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
let smallestDifference = Math.abs(
aspectRatio - bestMatch.width / bestMatch.height
);
for (const preset of DEFAULT_CANVAS_PRESETS) {
const presetAspectRatio = preset.width / preset.height;
const difference = Math.abs(aspectRatio - presetAspectRatio);
if (difference < smallestDifference) {
smallestDifference = difference;
bestMatch = preset;
}
}
// If the difference is still significant (> 0.1), create a custom size
// based on the media aspect ratio with a reasonable resolution
const bestAspectRatio = bestMatch.width / bestMatch.height;
if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
// Create custom dimensions based on the aspect ratio
if (aspectRatio > 1) {
// Landscape - use 1920 width
return { width: 1920, height: Math.round(1920 / aspectRatio) };
} else {
// Portrait or square - use 1080 height
return { width: Math.round(1080 * aspectRatio), height: 1080 };
}
}
return { width: bestMatch.width, height: bestMatch.height };
};
export const useEditorStore = create<EditorState>((set, get) => ({
// Initial states
isInitializing: true,
isPanelsReady: false,
canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
canvasMode: "preset" as CanvasMode,
canvasPresets: DEFAULT_CANVAS_PRESETS,
// Actions
setInitializing: (loading) => {
@ -32,4 +88,18 @@ export const useEditorStore = create<EditorState>((set, get) => ({
set({ isPanelsReady: true, isInitializing: false });
console.log("Video editor ready");
},
setCanvasSize: (size) => {
set({ canvasSize: size, canvasMode: "preset" });
},
setCanvasSizeToOriginal: (aspectRatio) => {
const newCanvasSize = findBestCanvasPreset(aspectRatio);
set({ canvasSize: newCanvasSize, canvasMode: "original" });
},
setCanvasSizeFromAspectRatio: (aspectRatio) => {
const newCanvasSize = findBestCanvasPreset(aspectRatio);
set({ canvasSize: newCanvasSize, canvasMode: "custom" });
},
}));

View File

@ -1,170 +1,267 @@
import { create } from "zustand";
export interface MediaItem {
id: string;
name: string;
type: "image" | "video" | "audio";
file: File;
url: string; // Object URL for preview
thumbnailUrl?: string; // For video thumbnails
duration?: number; // For video/audio duration
aspectRatio: number; // width / height
}
interface MediaStore {
mediaItems: MediaItem[];
// Actions
addMediaItem: (item: Omit<MediaItem, "id">) => void;
removeMediaItem: (id: string) => void;
clearAllMedia: () => void;
}
// Helper function to determine file type
export const getFileType = (file: File): "image" | "video" | "audio" | null => {
const { type } = file;
if (type.startsWith("image/")) {
return "image";
}
if (type.startsWith("video/")) {
return "video";
}
if (type.startsWith("audio/")) {
return "audio";
}
return null;
};
// Helper function to get image aspect ratio
export const getImageAspectRatio = (file: File): Promise<number> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.addEventListener("load", () => {
const aspectRatio = img.naturalWidth / img.naturalHeight;
resolve(aspectRatio);
img.remove();
});
img.addEventListener("error", () => {
reject(new Error("Could not load image"));
img.remove();
});
img.src = URL.createObjectURL(file);
});
};
// Helper function to generate video thumbnail and get aspect ratio
export const generateVideoThumbnail = (
file: File
): Promise<{ thumbnailUrl: string; aspectRatio: number }> => {
return new Promise((resolve, reject) => {
const video = document.createElement("video");
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Could not get canvas context"));
return;
}
video.addEventListener("loadedmetadata", () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Seek to 1 second or 10% of duration, whichever is smaller
video.currentTime = Math.min(1, video.duration * 0.1);
});
video.addEventListener("seeked", () => {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8);
const aspectRatio = video.videoWidth / video.videoHeight;
resolve({ thumbnailUrl, aspectRatio });
// Cleanup
video.remove();
canvas.remove();
});
video.addEventListener("error", () => {
reject(new Error("Could not load video"));
video.remove();
canvas.remove();
});
video.src = URL.createObjectURL(file);
video.load();
});
};
// Helper function to get media duration
export const getMediaDuration = (file: File): Promise<number> => {
return new Promise((resolve, reject) => {
const element = document.createElement(
file.type.startsWith("video/") ? "video" : "audio"
) as HTMLVideoElement | HTMLAudioElement;
element.addEventListener("loadedmetadata", () => {
resolve(element.duration);
element.remove();
});
element.addEventListener("error", () => {
reject(new Error("Could not load media"));
element.remove();
});
element.src = URL.createObjectURL(file);
element.load();
});
};
export const useMediaStore = create<MediaStore>((set, get) => ({
mediaItems: [],
addMediaItem: (item) => {
const newItem: MediaItem = {
...item,
id: crypto.randomUUID(),
};
set((state) => ({
mediaItems: [...state.mediaItems, newItem],
}));
},
removeMediaItem: (id) => {
const state = get();
const item = state.mediaItems.find((item) => item.id === id);
// Cleanup object URLs to prevent memory leaks
if (item) {
URL.revokeObjectURL(item.url);
if (item.thumbnailUrl) {
URL.revokeObjectURL(item.thumbnailUrl);
}
}
set((state) => ({
mediaItems: state.mediaItems.filter((item) => item.id !== id),
}));
},
clearAllMedia: () => {
const state = get();
// Cleanup all object URLs
state.mediaItems.forEach((item) => {
URL.revokeObjectURL(item.url);
if (item.thumbnailUrl) {
URL.revokeObjectURL(item.thumbnailUrl);
}
});
set({ mediaItems: [] });
},
}));
import { create } from "zustand";
import { storageService } from "@/lib/storage/storage-service";
import { useTimelineStore } from "./timeline-store";
import { generateUUID } from "@/lib/utils";
export type MediaType = "image" | "video" | "audio";
export interface MediaItem {
id: string;
name: string;
type: MediaType;
file: File;
url?: string; // Object URL for preview
thumbnailUrl?: string; // For video thumbnails
duration?: number; // For video/audio duration
width?: number; // For video/image width
height?: number; // For video/image height
fps?: number; // For video frame rate
// Text-specific properties
content?: string; // Text content
fontSize?: number; // Font size
fontFamily?: string; // Font family
color?: string; // Text color
backgroundColor?: string; // Background color
textAlign?: "left" | "center" | "right"; // Text alignment
}
interface MediaStore {
mediaItems: MediaItem[];
isLoading: boolean;
// Actions - now require projectId
addMediaItem: (
projectId: string,
item: Omit<MediaItem, "id">
) => Promise<void>;
removeMediaItem: (projectId: string, id: string) => Promise<void>;
loadProjectMedia: (projectId: string) => Promise<void>;
clearProjectMedia: (projectId: string) => Promise<void>;
clearAllMedia: () => void; // Clear local state only
}
// Helper function to determine file type
export const getFileType = (file: File): MediaType | null => {
const { type } = file;
if (type.startsWith("image/")) {
return "image";
}
if (type.startsWith("video/")) {
return "video";
}
if (type.startsWith("audio/")) {
return "audio";
}
return null;
};
// Helper function to get image dimensions
export const getImageDimensions = (
file: File
): Promise<{ width: number; height: number }> => {
return new Promise((resolve, reject) => {
const img = new window.Image();
img.addEventListener("load", () => {
const width = img.naturalWidth;
const height = img.naturalHeight;
resolve({ width, height });
img.remove();
});
img.addEventListener("error", () => {
reject(new Error("Could not load image"));
img.remove();
});
img.src = URL.createObjectURL(file);
});
};
// Helper function to generate video thumbnail and get dimensions
export const generateVideoThumbnail = (
file: File
): Promise<{ thumbnailUrl: string; width: number; height: number }> => {
return new Promise((resolve, reject) => {
const video = document.createElement("video") as HTMLVideoElement;
const canvas = document.createElement("canvas") as HTMLCanvasElement;
const ctx = canvas.getContext("2d");
if (!ctx) {
reject(new Error("Could not get canvas context"));
return;
}
video.addEventListener("loadedmetadata", () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Seek to 1 second or 10% of duration, whichever is smaller
video.currentTime = Math.min(1, video.duration * 0.1);
});
video.addEventListener("seeked", () => {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const thumbnailUrl = canvas.toDataURL("image/jpeg", 0.8);
const width = video.videoWidth;
const height = video.videoHeight;
resolve({ thumbnailUrl, width, height });
// Cleanup
video.remove();
canvas.remove();
});
video.addEventListener("error", () => {
reject(new Error("Could not load video"));
video.remove();
canvas.remove();
});
video.src = URL.createObjectURL(file);
video.load();
});
};
// Helper function to get media duration
export const getMediaDuration = (file: File): Promise<number> => {
return new Promise((resolve, reject) => {
const element = document.createElement(
file.type.startsWith("video/") ? "video" : "audio"
) as HTMLVideoElement;
element.addEventListener("loadedmetadata", () => {
resolve(element.duration);
element.remove();
});
element.addEventListener("error", () => {
reject(new Error("Could not load media"));
element.remove();
});
element.src = URL.createObjectURL(file);
element.load();
});
};
// Helper to get aspect ratio from MediaItem
export const getMediaAspectRatio = (item: MediaItem): number => {
if (item.width && item.height) {
return item.width / item.height;
}
return 16 / 9; // Default aspect ratio
};
export const useMediaStore = create<MediaStore>((set, get) => ({
mediaItems: [],
isLoading: false,
addMediaItem: async (projectId, item) => {
const newItem: MediaItem = {
...item,
id: generateUUID(),
};
// Add to local state immediately for UI responsiveness
set((state) => ({
mediaItems: [...state.mediaItems, newItem],
}));
// Save to persistent storage in background
try {
await storageService.saveMediaItem(projectId, newItem);
} catch (error) {
console.error("Failed to save media item:", error);
// Remove from local state if save failed
set((state) => ({
mediaItems: state.mediaItems.filter((media) => media.id !== newItem.id),
}));
}
},
removeMediaItem: async (projectId, id: string) => {
const state = get();
const item = state.mediaItems.find((media) => media.id === id);
// Cleanup object URLs to prevent memory leaks
if (item && item.url) {
URL.revokeObjectURL(item.url);
if (item.thumbnailUrl) {
URL.revokeObjectURL(item.thumbnailUrl);
}
}
// Remove from local state immediately
set((state) => ({
mediaItems: state.mediaItems.filter((media) => media.id !== id),
}));
// Remove from persistent storage
try {
await storageService.deleteMediaItem(projectId, id);
} catch (error) {
console.error("Failed to delete media item:", error);
}
},
loadProjectMedia: async (projectId) => {
set({ isLoading: true });
try {
const mediaItems = await storageService.loadAllMediaItems(projectId);
set({ mediaItems });
} catch (error) {
console.error("Failed to load media items:", error);
} finally {
set({ isLoading: false });
}
},
clearProjectMedia: async (projectId) => {
const state = get();
// Cleanup all object URLs
state.mediaItems.forEach((item) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
if (item.thumbnailUrl) {
URL.revokeObjectURL(item.thumbnailUrl);
}
});
// Clear local state
set({ mediaItems: [] });
// Clear persistent storage
try {
const mediaIds = state.mediaItems.map((item) => item.id);
await Promise.all(
mediaIds.map((id) => storageService.deleteMediaItem(projectId, id))
);
} catch (error) {
console.error("Failed to clear media items from storage:", error);
}
},
clearAllMedia: () => {
const state = get();
// Cleanup all object URLs
state.mediaItems.forEach((item) => {
if (item.url) {
URL.revokeObjectURL(item.url);
}
if (item.thumbnailUrl) {
URL.revokeObjectURL(item.thumbnailUrl);
}
});
// Clear local state
set({ mediaItems: [] });
},
}));

View File

@ -1,6 +1,14 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
const DEFAULT_PANEL_SIZES = {
toolsPanel: 45,
previewPanel: 75,
propertiesPanel: 20,
mainContent: 70,
timeline: 30,
} as const;
interface PanelState {
// Panel sizes as percentages
toolsPanel: number;
@ -21,11 +29,7 @@ export const usePanelStore = create<PanelState>()(
persist(
(set) => ({
// Default sizes - optimized for responsiveness
toolsPanel: 25,
previewPanel: 75,
propertiesPanel: 20,
mainContent: 70,
timeline: 30,
...DEFAULT_PANEL_SIZES,
// Actions
setToolsPanel: (size) => set({ toolsPanel: size }),

View File

@ -1,41 +1,320 @@
import { TProject } from "@/types/project";
import { create } from "zustand";
import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner";
import { useMediaStore } from "./media-store";
import { useTimelineStore } from "./timeline-store";
import { generateUUID } from "@/lib/utils";
interface ProjectStore {
activeProject: TProject | null;
savedProjects: TProject[];
isLoading: boolean;
isInitialized: boolean;
// Actions
createNewProject: (name: string) => void;
createNewProject: (name: string) => Promise<string>;
loadProject: (id: string) => Promise<void>;
saveCurrentProject: () => Promise<void>;
loadAllProjects: () => Promise<void>;
deleteProject: (id: string) => Promise<void>;
closeProject: () => void;
updateProjectName: (name: string) => void;
renameProject: (projectId: string, name: string) => Promise<void>;
duplicateProject: (projectId: string) => Promise<string>;
updateProjectBackground: (backgroundColor: string) => Promise<void>;
updateBackgroundType: (
type: "color" | "blur",
options?: { backgroundColor?: string; blurIntensity?: number }
) => Promise<void>;
updateProjectFps: (fps: number) => Promise<void>;
}
export const useProjectStore = create<ProjectStore>((set) => ({
export const useProjectStore = create<ProjectStore>((set, get) => ({
activeProject: null,
savedProjects: [],
isLoading: true,
isInitialized: false,
createNewProject: (name: string) => {
createNewProject: async (name: string) => {
const newProject: TProject = {
id: crypto.randomUUID(),
id: generateUUID(),
name,
thumbnail: "",
createdAt: new Date(),
updatedAt: new Date(),
backgroundColor: "#000000",
backgroundType: "color",
blurIntensity: 8,
};
set({ activeProject: newProject });
try {
await storageService.saveProject(newProject);
// Reload all projects to update the list
await get().loadAllProjects();
return newProject.id;
} catch (error) {
toast.error("Failed to save new project");
throw error;
}
},
loadProject: async (id: string) => {
if (!get().isInitialized) {
set({ isLoading: true });
}
// Clear media and timeline immediately to prevent flickering when switching projects
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
try {
const project = await storageService.loadProject(id);
if (project) {
set({ activeProject: project });
// Load project-specific data in parallel
await Promise.all([
mediaStore.loadProjectMedia(id),
timelineStore.loadProjectTimeline(id),
]);
} else {
throw new Error(`Project with id ${id} not found`);
}
} catch (error) {
console.error("Failed to load project:", error);
throw error; // Re-throw so the editor page can handle it
} finally {
set({ isLoading: false });
}
},
saveCurrentProject: async () => {
const { activeProject } = get();
if (!activeProject) return;
try {
// Save project metadata and timeline data in parallel
const timelineStore = useTimelineStore.getState();
await Promise.all([
storageService.saveProject(activeProject),
timelineStore.saveProjectTimeline(activeProject.id),
]);
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to save project:", error);
}
},
loadAllProjects: async () => {
if (!get().isInitialized) {
set({ isLoading: true });
}
try {
const projects = await storageService.loadAllProjects();
set({ savedProjects: projects });
} catch (error) {
console.error("Failed to load projects:", error);
} finally {
set({ isLoading: false, isInitialized: true });
}
},
deleteProject: async (id: string) => {
try {
// Delete project data in parallel
await Promise.all([
storageService.deleteProjectMedia(id),
storageService.deleteProjectTimeline(id),
storageService.deleteProject(id),
]);
await get().loadAllProjects(); // Refresh the list
// If we deleted the active project, close it and clear data
const { activeProject } = get();
if (activeProject?.id === id) {
set({ activeProject: null });
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
}
} catch (error) {
console.error("Failed to delete project:", error);
}
},
closeProject: () => {
set({ activeProject: null });
// Clear data from stores when closing project
const mediaStore = useMediaStore.getState();
const timelineStore = useTimelineStore.getState();
mediaStore.clearAllMedia();
timelineStore.clearTimeline();
},
updateProjectName: (name: string) => {
set((state) => ({
activeProject: state.activeProject
? {
...state.activeProject,
name,
updatedAt: new Date(),
}
: null,
}));
renameProject: async (id: string, name: string) => {
const { savedProjects } = get();
// Find the project to rename
const projectToRename = savedProjects.find((p) => p.id === id);
if (!projectToRename) {
toast.error("Project not found", {
description: "Please try again",
});
return;
}
const updatedProject = {
...projectToRename,
name,
updatedAt: new Date(),
};
try {
// Save to storage
await storageService.saveProject(updatedProject);
await get().loadAllProjects();
// Update activeProject if it's the same project
const { activeProject } = get();
if (activeProject?.id === id) {
set({ activeProject: updatedProject });
}
} catch (error) {
console.error("Failed to rename project:", error);
toast.error("Failed to rename project", {
description:
error instanceof Error ? error.message : "Please try again",
});
}
},
duplicateProject: async (projectId: string) => {
try {
const project = await storageService.loadProject(projectId);
if (!project) {
toast.error("Project not found", {
description: "Please try again",
});
throw new Error("Project not found");
}
const { savedProjects } = get();
// Extract the base name (remove any existing numbering)
const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/);
const baseName = numberMatch ? numberMatch[2] : project.name;
const existingNumbers: number[] = [];
// Check for pattern "(number) baseName" in existing projects
savedProjects.forEach((p) => {
const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
if (match && match[2] === baseName) {
existingNumbers.push(parseInt(match[1], 10));
}
});
const nextNumber =
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
const newProject: TProject = {
id: generateUUID(),
name: `(${nextNumber}) ${baseName}`,
thumbnail: project.thumbnail,
createdAt: new Date(),
updatedAt: new Date(),
};
await storageService.saveProject(newProject);
await get().loadAllProjects();
return newProject.id;
} catch (error) {
console.error("Failed to duplicate project:", error);
toast.error("Failed to duplicate project", {
description:
error instanceof Error ? error.message : "Please try again",
});
throw error;
}
},
updateProjectBackground: async (backgroundColor: string) => {
const { activeProject } = get();
if (!activeProject) return;
const updatedProject = {
...activeProject,
backgroundColor,
updatedAt: new Date(),
};
try {
await storageService.saveProject(updatedProject);
set({ activeProject: updatedProject });
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to update project background:", error);
toast.error("Failed to update background", {
description: "Please try again",
});
}
},
updateBackgroundType: async (
type: "color" | "blur",
options?: { backgroundColor?: string; blurIntensity?: number }
) => {
const { activeProject } = get();
if (!activeProject) return;
const updatedProject = {
...activeProject,
backgroundType: type,
...(options?.backgroundColor && {
backgroundColor: options.backgroundColor,
}),
...(options?.blurIntensity && { blurIntensity: options.blurIntensity }),
updatedAt: new Date(),
};
try {
await storageService.saveProject(updatedProject);
set({ activeProject: updatedProject });
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to update background type:", error);
toast.error("Failed to update background", {
description: "Please try again",
});
}
},
updateProjectFps: async (fps: number) => {
const { activeProject } = get();
if (!activeProject) return;
const updatedProject = {
...activeProject,
fps,
updatedAt: new Date(),
};
try {
await storageService.saveProject(updatedProject);
set({ activeProject: updatedProject });
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to update project FPS:", error);
toast.error("Failed to update project FPS", {
description: "Please try again",
});
}
},
}));

File diff suppressed because it is too large Load Diff

View File

@ -1 +1,12 @@
export type BackgroundType = "blur" | "mirror" | "color";
export interface CanvasSize {
width: number;
height: number;
}
export interface CanvasPreset {
name: string;
width: number;
height: number;
}

View File

@ -1,6 +1,12 @@
export interface TProject {
id: string;
name: string;
createdAt: Date;
updatedAt: Date;
}
export interface TProject {
id: string;
name: string;
thumbnail: string;
createdAt: Date;
updatedAt: Date;
mediaItems?: string[];
backgroundColor?: string;
backgroundType?: "color" | "blur";
blurIntensity?: number; // in pixels (4, 8, 18)
fps?: number;
}

View File

@ -1,29 +1,157 @@
import { TimelineTrack, TimelineClip } from "@/stores/timeline-store";
export type TrackType = "video" | "audio" | "effects";
export interface TimelineClipProps {
clip: TimelineClip;
track: TimelineTrack;
zoomLevel: number;
isSelected: boolean;
onContextMenu: (e: React.MouseEvent, clipId: string) => void;
onClipMouseDown: (e: React.MouseEvent, clip: TimelineClip) => void;
onClipClick: (e: React.MouseEvent, clip: TimelineClip) => void;
}
export interface ResizeState {
clipId: string;
side: "left" | "right";
startX: number;
initialTrimStart: number;
initialTrimEnd: number;
}
export interface ContextMenuState {
type: "track" | "clip";
trackId: string;
clipId?: string;
x: number;
y: number;
}
import { MediaType } from "@/stores/media-store";
import { generateUUID } from "@/lib/utils";
export type TrackType = "media" | "text" | "audio";
// Base element properties
interface BaseTimelineElement {
id: string;
name: string;
duration: number;
startTime: number;
trimStart: number;
trimEnd: number;
}
// Media element that references MediaStore
export interface MediaElement extends BaseTimelineElement {
type: "media";
mediaId: string;
}
// Text element with embedded text data
export interface TextElement extends BaseTimelineElement {
type: "text";
content: string;
fontSize: number;
fontFamily: string;
color: string;
backgroundColor: string;
textAlign: "left" | "center" | "right";
fontWeight: "normal" | "bold";
fontStyle: "normal" | "italic";
textDecoration: "none" | "underline" | "line-through";
x: number; // Position relative to canvas center
y: number; // Position relative to canvas center
rotation: number; // in degrees
opacity: number; // 0-1
}
// Typed timeline elements
export type TimelineElement = MediaElement | TextElement;
// Creation types (without id, for addElementToTrack)
export type CreateMediaElement = Omit<MediaElement, "id">;
export type CreateTextElement = Omit<TextElement, "id">;
export type CreateTimelineElement = CreateMediaElement | CreateTextElement;
export interface TimelineElementProps {
element: TimelineElement;
track: TimelineTrack;
zoomLevel: number;
isSelected: boolean;
onElementMouseDown: (e: React.MouseEvent, element: TimelineElement) => void;
onElementClick: (e: React.MouseEvent, element: TimelineElement) => void;
}
export interface ResizeState {
elementId: string;
side: "left" | "right";
startX: number;
initialTrimStart: number;
initialTrimEnd: number;
}
// Drag data types for type-safe drag and drop
export interface MediaItemDragData {
id: string;
type: MediaType;
name: string;
}
export interface TextItemDragData {
id: string;
type: "text";
name: string;
content: string;
}
export type DragData = MediaItemDragData | TextItemDragData;
export interface TimelineTrack {
id: string;
name: string;
type: TrackType;
elements: TimelineElement[];
muted?: boolean;
isMain?: boolean;
}
export function sortTracksByOrder(tracks: TimelineTrack[]): TimelineTrack[] {
return [...tracks].sort((a, b) => {
// Audio tracks always go to bottom
if (a.type === "audio" && b.type !== "audio") return 1;
if (b.type === "audio" && a.type !== "audio") return -1;
// Main track goes above audio but below other tracks
if (a.isMain && !b.isMain && b.type !== "audio") return 1;
if (b.isMain && !a.isMain && a.type !== "audio") return -1;
// Within same category, maintain creation order
return 0;
});
}
export function getMainTrack(tracks: TimelineTrack[]): TimelineTrack | null {
return tracks.find((track) => track.isMain) || null;
}
export function ensureMainTrack(tracks: TimelineTrack[]): TimelineTrack[] {
const hasMainTrack = tracks.some((track) => track.isMain);
if (!hasMainTrack) {
// Create main track if it doesn't exist
const mainTrack: TimelineTrack = {
id: generateUUID(),
name: "Main Track",
type: "media",
elements: [],
muted: false,
isMain: true,
};
return [mainTrack, ...tracks];
}
return tracks;
}
// Timeline validation utilities
export function canElementGoOnTrack(
elementType: "text" | "media",
trackType: TrackType
): boolean {
if (elementType === "text") {
return trackType === "text";
} else if (elementType === "media") {
return trackType === "media" || trackType === "audio";
}
return false;
}
export function validateElementTrackCompatibility(
element: { type: "text" | "media" },
track: { type: TrackType }
): { isValid: boolean; errorMessage?: string } {
const isValid = canElementGoOnTrack(element.type, track.type);
if (!isValid) {
const errorMessage =
element.type === "text"
? "Text elements can only be placed on text tracks"
: "Media elements can only be placed on media or audio tracks";
return { isValid: false, errorMessage };
}
return { isValid: true };
}

View File

@ -5,11 +5,17 @@ export default {
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
"./src/lib/**/*.{js,ts,jsx,tsx,mdx}",
"./src/constants/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
screens: {
xs: "480px",
},
fontSize: {
base: "0.95rem",
xs: "0.80rem",
},
fontFamily: {
sans: ["var(--font-inter)", "sans-serif"],
@ -65,11 +71,15 @@ export default {
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
panel: {
DEFAULT: "hsl(var(--panel-background))",
accent: "hsl(var(--panel-accent))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 6px)",
sm: "calc(var(--radius) - 8px)",
},
keyframes: {
"accordion-down": {
@ -95,7 +105,38 @@ export default {
},
},
},
plugins: [require("tailwindcss-animate")],
plugins: [
require("tailwindcss-animate"),
function ({
addUtilities,
}: {
addUtilities: (utilities: Record<string, any>) => void;
}) {
addUtilities({
".scrollbar-hidden": {
"-ms-overflow-style": "none",
"scrollbar-width": "none",
"&::-webkit-scrollbar": {
display: "none",
},
},
".scrollbar-x-hidden": {
"-ms-overflow-style": "none",
"scrollbar-width": "none",
"&::-webkit-scrollbar:horizontal": {
display: "none",
},
},
".scrollbar-y-hidden": {
"-ms-overflow-style": "none",
"scrollbar-width": "none",
"&::-webkit-scrollbar:vertical": {
display: "none",
},
},
});
},
],
future: {
hoverOnlyWhenSupported: true,
},

View File

@ -2,6 +2,7 @@
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "opencut",
"dependencies": {
"next": "^15.3.4",
"wavesurfer.js": "^7.9.8",
@ -21,7 +22,6 @@
"@hookform/resolvers": "^3.9.1",
"@opencut/auth": "workspace:*",
"@opencut/db": "workspace:*",
"@types/pg": "^8.15.4",
"@upstash/ratelimit": "^2.0.5",
"@upstash/redis": "^1.35.0",
"@vercel/analytics": "^1.4.1",
@ -39,6 +39,7 @@
"motion": "^12.18.1",
"next": "^15.3.4",
"next-themes": "^0.4.4",
"ollama": "^0.5.16",
"pg": "^8.16.2",
"radix-ui": "^1.4.2",
"react": "^18.2.0",
@ -58,6 +59,7 @@
},
"devDependencies": {
"@types/bun": "latest",
"@types/pg": "^8.15.4",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"cross-env": "^7.0.3",
@ -103,6 +105,8 @@
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.18", "", {}, "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA=="],
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250701.0", "", {}, "sha512-q1bHwe5P7FGy9RkLYOY1kwoZrqUe2Q6XhCPscaxzQc0N7+2pwIZzZzY5iMTTfvmf65UNsadoVxuF+vPVXoAkkQ=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="],
@ -411,7 +415,7 @@
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="],
"@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
"@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="],
@ -689,6 +693,8 @@
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"ollama": ["ollama@0.5.16", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-OEbxxOIUZtdZgOaTPAULo051F5y+Z1vosxEYOoABPnQKeW7i4O8tJNlxCB+xioyoorVqgjkdj+TA1f1Hy2ug/w=="],
"opencut": ["opencut@workspace:apps/web"],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
@ -905,6 +911,8 @@
"wavesurfer.js": ["wavesurfer.js@7.9.8", "", {}, "sha512-Mxz6qRwkSmuWVxLzp0XQ6EzSv1FTvQgMEUJTirLN1Ox76sn0YeyQlI99WuE+B0IuxShPHXIhvEuoBSJdaQs7tA=="],
"whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
@ -921,6 +929,8 @@
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@types/bun/bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],

View File

@ -52,7 +52,7 @@ services:
dockerfile: ./apps/web/Dockerfile
restart: unless-stopped
ports:
- "3000:3000"
- "3100:3000" # app is running on 3000 so we run this at 3100
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://opencut:opencutthegoat@db:5432/opencut

View File

@ -1,6 +1,6 @@
{
"name": "opencut",
"packageManager": "bun@1.2.17",
"packageManager": "bun@1.2.18",
"devDependencies": {
"turbo": "^2.5.4"
},

View File

@ -7,7 +7,7 @@ export const auth = betterAuth({
provider: "pg",
usePlural: true,
}),
secret: process.env.BETTER_AUTH_SECRET!,
secret: process.env.BETTER_AUTH_SECRET,
user: {
deleteUser: {
enabled: true,

View File

@ -19,11 +19,7 @@ function getDb() {
}
// Export a proxy that forwards all calls to the actual db instance
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
get(target, prop) {
return getDb()[prop as keyof typeof _db];
},
});
export const db = getDb();
// Re-export schema for convenience
export * from "./schema";

View File

@ -5,7 +5,7 @@ export const users = pgTable("users", {
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified")
.$defaultFn(() => false)
.default(false)
.notNull(),
image: text("image"),
createdAt: timestamp("created_at")