167 Commits

Author SHA1 Message Date
8433324f5a Revert "fix(issue): Adress the issue(#109)" 2025-06-28 14:43:25 +02:00
306c2885f1 Merge pull request #141 from sinanptm/code-refactoring
fix(issue): Adress the issue(#109)
2025-06-28 12:44:01 +02:00
5a6872537e feat: dedicated footer 2025-06-28 12:38:31 +02:00
8fe09c83b1 fix: DATABASE_URL error on setup 2025-06-27 16:56:59 +02:00
901d0baafd added memoization 2025-06-27 10:58:21 +05:30
8934cc610f refactor: added auth hooks for handling sign and signup logics 2025-06-27 10:46:35 +05:30
iza
cdfb5ea7b0 Merge pull request #138 from karansingh21202/feat/timeline-scroll-sync-minimal-v2
fix: Updated synchronize timeline ruler and tracks scrolling for long videos
2025-06-27 07:04:12 +03:00
iza
dc99516a3f Merge pull request #125 from sinanptm/code-refactoring
Removed unused File apps/web/src/components/auth-form.tsx
2025-06-27 07:03:32 +03:00
iza
1f257d30dc Merge pull request #137 from dipanshurdev/patch-01
added: twitter with good looking icons
2025-06-27 07:02:51 +03:00
iza
f53f2802eb Merge pull request #135 from DevloperAmanSingh/feature/player-controls
Feature: More Players Controls are added now .
2025-06-27 07:02:28 +03:00
c32daa4f2e refactor: enhance timeline component by improving drag-and-drop functionality and clip selection handling 2025-06-27 07:56:18 +05:30
87e90a5e24 fix dragging clips in timeline 2025-06-27 03:50:39 +02:00
089f7f8d71 Merge branch 'fix-issue-#131' 2025-06-27 01:28:23 +02:00
01140fd7bb Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-06-27 01:02:03 +02:00
dfde7592bb refactor: separate timeline toolbar into new component and add new TrackType type 2025-06-27 01:02:01 +02:00
6391147a96 Merge pull request #134 from dipanshurdev/minor-feat
fix(style): README.md header section
2025-06-27 00:57:17 +02:00
679ebc02b5 Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-06-27 00:55:03 +02:00
d5db470150 style: update dark theme color variables 2025-06-27 00:55:00 +02:00
f3c45ee892 refactor: Update playhead auto-scroll buffer and remove conflicting sync 2025-06-27 00:50:37 +05:30
d9809c70c3 add: noopener noreferrer 2025-06-27 00:30:35 +05:30
17ef810074 feat: Improve timeline playhead smooth scrolling and sync 2025-06-27 00:30:32 +05:30
ebb3bc89c3 remove: unused icon 2025-06-27 00:23:34 +05:30
6d98c2af24 add: twitter 2025-06-27 00:17:58 +05:30
0bdbd7e2b3 refactor: streamline audio track handling by consolidating clip addition logic in timeline store 2025-06-27 00:14:29 +05:30
6262f2b379 refactor: introduce helper function for consistent clip naming with suffixes in timeline store 2025-06-27 00:07:47 +05:30
3f0fe9d20e refactor: improve playback controls by replacing inline functions with useCallback for clip manipulation and audio separation 2025-06-27 00:07:41 +05:30
0383000ada refactor: remove unused drag clip hook import in timeline clip component 2025-06-27 00:07:36 +05:30
a816cc503f feat: add detailed comments for clip splitting and audio separation methods in timeline store 2025-06-26 23:46:43 +05:30
f74bebeb8b feat: add comment for clip editing operations in timeline component 2025-06-26 23:46:38 +05:30
7ca5bcfa50 feat: add comments for drag handling and resizing in timeline clip component 2025-06-26 23:46:33 +05:30
efdd2aa6ed feat: add dropdown menu for clip options including split and audio separation in timeline component 2025-06-26 23:41:07 +05:30
12a2ec59fd feat: implement clip splitting and audio separation features with keyboard shortcuts in timeline component 2025-06-26 23:41:01 +05:30
b799615654 feat: enhance playback controls with keyboard shortcuts for clip manipulation and audio separation 2025-06-26 23:40:56 +05:30
3224dd974a feat: add clip splitting and audio separation functionality to timeline store 2025-06-26 23:40:50 +05:30
49787bdfe5 style(doc): README.md 2025-06-26 22:40:25 +05:30
fff95afbc6 fix: Only remove track if it becomes empty and has no other clips 2025-06-26 21:41:26 +05:30
f76555dae5 fix: remmove video track when media is deleted 2025-06-26 21:29:03 +05:30
53184217bf fix: remove unwanted clip option menu 2025-06-26 20:28:03 +05:30
1284c232a3 .gitignore modified 2025-06-26 20:26:10 +05:30
b428a28aea removed unused auth-auth-form 2025-06-26 15:44:19 +05:30
23f118969b Merge branch 'OpenCut-app:main' into main 2025-06-26 15:16:53 +05:30
iza
b10bb8ed55 Merge pull request #123 from sinanptm/fix-preview-placeholder-text
Changed Preview Empty placeholder to No media added to timeline
2025-06-26 12:46:02 +03:00
iza
d4048772ad Merge pull request #122 from DevloperAmanSingh/fix/media-panel-syntax
Fix: Media panel component
2025-06-26 12:45:43 +03:00
063094e70a changed empty placeholder to a better one 2025-06-26 14:54:49 +05:30
cd88a92c6d changed empty placeholder to a better one 2025-06-26 14:53:02 +05:30
70352ced92 refactor: update authentication imports in auth-form component to use signIn and signUp from OpenCut 2025-06-26 14:40:35 +05:30
896f2ee554 refactor: remove unnecessary wrapper from MediaPanel component 2025-06-26 14:29:24 +05:30
iza
b66e85cf4d Merge pull request #48 from fathisiddiqi/fix/google-login
fix: set redirect uri for google login and prevent router push when login by google
2025-06-26 11:28:58 +03:00
iza
46614d4008 Merge pull request #111 from Harshitshukla0208/fix/ai-solution-109-1750881048867
fix(issue): Address issue #109 - [FEATURE]  Refactor Component Logic by Using Custom Hooks for Cleaner Code
2025-06-26 11:28:02 +03:00
iza
2d7f2f8503 Merge pull request #112 from Harshitshukla0208/fix/ai-solution-87-1750881155536
fix(issue): Address issue #87 - [BUG] split and delete background not visible
2025-06-26 11:27:43 +03:00
iza
449673b79e Merge pull request #85 from priyankarpal/split-issue-83
fix: split text visible
2025-06-26 11:27:08 +03:00
iza
4f74018648 Merge pull request #117 from anagobabatunde/fix/SQL-unknown
refactor: update imports in waitlist API and library to ensure consistent usage of drizzle-orm functions
2025-06-26 11:25:58 +03:00
4ed9858725 Merge branch 'main' into split-issue-83 2025-06-26 11:35:55 +05:30
2f5bde1051 fix: remove GOOGLE_REDIRECT_URI env due to better auth set the default of that 2025-06-26 09:55:30 +07:00
6626a8c413 fix: set redirect uri for google login and prevent router push when login by google 2025-06-26 09:33:47 +07:00
ebb3456c10 refactor: update imports in waitlist API and library to ensure consistent usage of drizzle-orm functions 2025-06-26 02:51:17 +02:00
a8d6bbd03a Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-06-26 01:36:58 +02:00
0f470ba7a7 fix: clip being deselected after dragging it 2025-06-26 01:36:49 +02:00
029152dd5b Merge pull request #115 from letyassine/fixed-landing-page
fix: responsiveness of landing page
2025-06-26 01:27:47 +02:00
d6a2de21d0 refactor: change /public/logo.svg to exclude text and update header to use it 2025-06-26 01:08:25 +02:00
53e88df0d7 refactor: remove unused imports 2025-06-26 01:02:15 +02:00
dd80064be6 chore: update .gitignore to include cursor files 2025-06-26 00:38:51 +02:00
e225272ec3 fix: some timeline issues 2025-06-26 00:37:35 +02:00
b01421f115 fix: responsiveness of landing page 2025-06-25 22:58:16 +01:00
75eede20af docs: update README formatting 2025-06-25 23:47:14 +02:00
e23cf66373 style: introduce preview panel and move play button there 2025-06-25 23:43:10 +02:00
2f3a148dd4 refactor: move debug component to separate component and place absolutely 2025-06-25 23:22:59 +02:00
e5fc3f9bbb style: adjust height in editor header 2025-06-25 23:09:40 +02:00
0c97bc8c3f refactor: remov eunused things 2025-06-25 23:08:31 +02:00
3eac1bcb0b refactor: move timeline clip into its separate component and type into /types 2025-06-25 23:05:14 +02:00
3ea6b00254 fix: lock aspect ratio in preview 2025-06-25 22:55:25 +02:00
e7dabd1444 style: change hover effect for consistency 2025-06-25 22:52:28 +02:00
389a546478 fix(issue): Address issue #87 - [BUG] split and delete background not visible 2025-06-26 01:22:40 +05:30
4fb0d014af fix: hard-coded tailwind colors 2025-06-25 21:51:19 +02:00
c0684ca62d fix(issue): Address issue #109 - [FEATURE] Refactor Component Logic by Using Custom Hooks for Cleaner Code 2025-06-26 01:20:53 +05:30
ef806ceab8 refactor: use badge component from shadcn 2025-06-25 21:49:02 +02:00
515be65bc4 fix: github icon being black on dark mode 2025-06-25 21:48:02 +02:00
4fe0ff3010 style: update spacing in header 2025-06-25 21:44:54 +02:00
e1481391c7 refactor: update contributors display to show top two contributors 2025-06-25 21:43:34 +02:00
777b0f7000 refactor: improved type-safety, removal of any from all instances 2025-06-25 21:40:54 +02:00
926aebe004 fix: make whole clip draggable 2025-06-25 21:22:08 +02:00
2775ac427d fix: remove hover effects on clip drag 2025-06-25 21:10:40 +02:00
f5c546d416 fix: clip getting deselected after a drag 2025-06-25 21:08:52 +02:00
d1e313450d fix: show clip trim handles on focus rather than hover 2025-06-25 21:06:34 +02:00
27b0e51265 Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-06-25 21:02:40 +02:00
ebcb898e4e feat: use mouse-based dragging for clips
fixes an issue where dragging a clip would create a duplicate instead of dragging clip directly, and gives us more control

moved the drag to a hook for cleaner code
2025-06-25 21:02:34 +02:00
ffb6a35e20 Merge pull request #82 from ga1az/fix-docker-setup
Fix docker setup
2025-06-25 20:28:05 +02:00
2ddd8cb0d7 Merge pull request #84 from ILoveScratch2/main
docs: change commands in README adapted to different platforms
2025-06-25 20:26:46 +02:00
baa5c43e7c feat: improve clips dragging experience 2025-06-25 19:51:50 +02:00
bf64ec1133 style: make join waitlist responsive 2025-06-25 19:24:05 +02:00
de3d3e210e fix: update heading in hero for clarity about first oss video editor 2025-06-25 19:23:01 +02:00
3bb640c453 oh god (netlify) 2025-06-25 19:06:41 +02:00
0f343dfbda i hate netlify 2025-06-25 18:57:52 +02:00
ba14d65605 Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-06-25 18:46:26 +02:00
94d39dfa7e fix: netlify config 2025-06-25 18:46:22 +02:00
iza
00d7b3ba8d Merge pull request #89 from YaoSiQian/patch-1
Changed form style of ISSUE_TEMPLATEs
2025-06-25 19:30:40 +03:00
iza
266d3bc0a2 Merge pull request #94 from Zaid-maker/bump-bun-version
update: bump Bun version to 1.2.17 in CI workflow
2025-06-25 19:30:09 +03:00
iza
b94cc121d2 Merge pull request #107 from DevloperAmanSingh/feature/progress-bar
Feature: Add progress indicator for large file imports
2025-06-25 19:29:46 +03:00
181d3fca06 fix: improve UI responsiveness during media file processing 2025-06-25 21:50:12 +05:30
57bfb7610f Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-06-25 18:19:33 +02:00
5977a5d253 fix: netlify config 2025-06-25 18:19:28 +02:00
0723623eaf feat: add progress callback to media file processing function 2025-06-25 21:41:23 +05:30
f9d0be20d0 feat: implement progress tracking for media file processing in timeline 2025-06-25 21:41:16 +05:30
a511c57729 feat: enhance media processing with progress tracking 2025-06-25 21:41:10 +05:30
iza
76c3293ac4 Merge pull request #103 from letyassine/new-landing-page
update: landing-page
2025-06-25 18:59:25 +03:00
iza
a969f3df86 Merge pull request #105 from sinanptm/pwa-seo
feat: Add PWA Support, metadata Improvements.
2025-06-25 18:55:26 +03:00
cca25a6103 Reduce code duplication by extracting common metadata values. 2025-06-25 20:22:17 +05:30
c322ad1124 Merge branch 'main' into split-issue-83 2025-06-25 20:16:15 +05:30
2e5b623d90 metadata declared in layout file 2025-06-25 20:14:55 +05:30
e786d437a5 made the project app PWA and moved metadata into a sperate file app folder 2025-06-25 19:57:35 +05:30
0ae852c185 Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-06-25 16:18:00 +02:00
db60354349 fix: move netlify config file to root 2025-06-25 16:17:53 +02:00
ba9870a908 update: landing-page 2025-06-25 15:00:35 +01:00
e7d35c667f feat: add star count back to header 2025-06-25 15:36:50 +02:00
iza
067b6e3d73 Merge pull request #96 from sinanptm/main
Pull Request: Move Type Definitions to devDependencies
2025-06-25 16:03:25 +03:00
iza
2326faf526 Merge pull request #97 from creotove/Feat/audio-waveform
Feat: basic audio waveform added.
2025-06-25 16:01:34 +03:00
iza
9ba57d86b6 Merge pull request #100 from DevloperAmanSingh/fix/timeline-redbar-fix
Fix: Reset playhead to start after video ends
2025-06-25 16:01:02 +03:00
iza
92a8466368 Merge pull request #101 from GeorgeCaoJ/fix/drag-drop-new-tracks
fix(timeline): drag and drop to create new track
2025-06-25 16:00:45 +03:00
56a8098442 fix(issue_template): fix tags 2025-06-25 20:50:33 +08:00
6a666efa39 Merge branch 'OpenCut-app:main' into patch-1 2025-06-25 20:40:50 +08:00
07351ca7e6 fix(timeline): drag and drop to create new track 2025-06-25 20:04:21 +08:00
d9fcef9385 fix(playback-store): reset playhead and notify video elements on completion 2025-06-25 16:34:12 +05:30
7f0bcea4ca Feat: basic audio waveform added. 2025-06-25 15:29:20 +05:30
903cdf5fff Merge branch 'OpenCut-app:main' into main 2025-06-25 15:01:05 +05:30
354dbbd0f9 Moved @types/pg into devDependencies 2025-06-25 14:59:49 +05:30
51a83f9f21 fix(ci): update cache key to include Bun version for consistency 2025-06-25 14:16:35 +05:00
e2a0f745c8 Merge branch 'main' into bump-bun-version 2025-06-25 14:12:45 +05:00
iza
c18b173bc9 Merge pull request #86 from Shubbu03/main
UI fixes to the media-panel component
2025-06-25 12:09:47 +03:00
iza
a4201c8be6 Merge pull request #93 from DevloperAmanSingh/feat/global-mute-control
Feature - Global Mute Control
2025-06-25 12:09:05 +03:00
1722055a72 update: bump Bun version to 1.2.17 in CI workflow 2025-06-25 13:53:51 +05:00
01b9dbccc2 fix(playback-store): improve mute functionality to retain previous volume state 2025-06-25 14:17:26 +05:30
d39edd8521 style(issue_template): form style 2025-06-25 16:03:36 +08:00
3a6cfd73e4 feat(playback-types): add muted state and mute controls to PlaybackState and PlaybackControls interfaces 2025-06-25 13:28:04 +05:30
e14e137212 feat(playback-store): implement mute and unmute functionality with improved volume handling 2025-06-25 13:27:55 +05:30
fe1a2542cc feat(video-player): enhance video player with mute functionality and improved event handling 2025-06-25 13:27:48 +05:30
66eb6f13d5 feat(preview-panel): add volume control and mute functionality to the video player 2025-06-25 13:27:39 +05:30
4db515df51 code rabbit suggested fix 2025-06-25 12:29:06 +05:30
992b0cbf23 Update apps/web/src/components/editor/timeline.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-06-25 12:16:47 +05:30
7444ab2894 UI fixes to the media-panel component 2025-06-25 12:13:16 +05:30
fd2cf591b7 fix: text visible 2025-06-25 12:01:30 +05:30
d8494185e0 docs: change commands in README adapted to different platforms 2025-06-25 14:28:56 +08:00
iza
3989da93c3 Merge pull request #81 from pulkitgarg04/feature/project-name-editing
feature: add project name editing functionality
2025-06-25 08:31:24 +03:00
10d11a5baa Merge branch 'main' of github.com:ga1az/OpenCut into fix-docker-setup 2025-06-25 01:22:09 -04:00
998ee086e5 chore(docker): update Dockerfile to set NODE_ENV to production and ensure telemetry is disabled 2025-06-25 01:21:09 -04:00
iza
402b004ef3 Merge pull request #71 from Harshitshukla0208/fix/ai-solution-67-1750807491754
fix(issue): Address issue #67 - [BUG] buttons jitter left and right
2025-06-25 08:02:04 +03:00
iza
affa239f7b Merge pull request #72 from Harshitshukla0208/fix/ai-solution-65-1750807569933
fix(issue): Address issue #65 - [BUG]
2025-06-25 08:00:57 +03:00
iza
151b3dfc51 Merge pull request #74 from letyassine/fix-og-image
fix: metadata for og image
2025-06-25 08:00:07 +03:00
iza
4a8ff91ae3 Merge pull request #76 from creotove/main
Fix: [BUG] buttons jitter left and right #67
2025-06-25 07:59:11 +03:00
411a9a5f1c feature: add project name editing functionality 2025-06-25 10:26:29 +05:30
iza
b8057cdd30 Merge pull request #77 from ga1az/fix-docker-setup
refactor(docker): update Dockerfile and docker-compose
2025-06-25 07:56:14 +03:00
iza
e01142b798 Merge pull request #80 from pulkitgarg04/fix/drizzle-schema-path-configuration
fix: drizzle schema path configuration in web app
2025-06-25 07:55:40 +03:00
7461b763b1 fix: drizzle schema path configuration in web app 2025-06-25 10:09:42 +05:30
1f2c505efe chore(docker): enhance Dockerfile by adding dependency installation step and removing redundant NODE_ENV variable 2025-06-24 23:59:42 -04:00
547f566fc2 refactor(docker): update Dockerfile and docker-compose for improved build process and add health check API 2025-06-24 23:44:06 -04:00
7de09adce6 Fix: unwanted seperator when there is a clip on track. 2025-06-25 08:51:17 +05:30
84d206c01e Fix: [BUG] buttons jitter left and right #67 2025-06-25 08:36:27 +05:30
22ae5d0e37 fix: metadata for og image 2025-06-25 01:16:37 +01:00
bc28ce09cb Merge pull request #69 from letyassine/og
add: opengraph image
2025-06-25 01:31:11 +02:00
a9293ffb48 fix(issue): Address issue #65 - [BUG] 2025-06-25 04:56:15 +05:30
274d03185b fix(issue): Address issue #67 - [BUG] buttons jitter left and right 2025-06-25 04:54:56 +05:30
9bb19712e5 add: opengraph image 2025-06-24 23:27:00 +01:00
bc3fbabfb2 fix(header): remove target attribute from 'Editor' link for consistency 2025-06-24 22:49:19 +02:00
9354d69acf feat(header): conditionally render 'Editor' or 'GitHub' button based on environment 2025-06-24 22:48:55 +02:00
f8a4cd4ac6 fix(hero): remove padding from hero waitlist count 2025-06-24 22:43:43 +02:00
5fc205fc57 fix(timeline): update drag overlay styling for better visibility 2025-06-24 22:23:02 +02:00
8f583c25e0 feat(timeline): add context menu handling to TimelineTrackContent component 2025-06-24 22:20:17 +02:00
iza
27471ba532 Merge pull request #63 from OpenCut-app/izadoesdev-patch-1
Update README.md
2025-06-24 22:40:35 +03:00
85 changed files with 3729 additions and 1837 deletions

View File

@ -1,31 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: '[BUG] '
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

70
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@ -0,0 +1,70 @@
name: Bug report
description: Create a report to help us improve
title: '[BUG] '
labels: bug
body:
- type: input
id: Platform
attributes:
label: Platform
description: Please enter the platform on which you encountered the bug.
placeholder: e.g. Windows 11, Ubuntu 14.04
validations:
required: true
- type: input
id: Browser
attributes:
label: Browser
description: Please enter the browser on which you encountered the bug.
placeholder: e.g. Chrome 137, Firefox 137, Safari 17
validations:
required: true
- type: textarea
id: current-behavior
attributes:
label: Current Behavior
description: A concise description of what you're experiencing.
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: false
- type: dropdown
id: recurrence-probability
attributes:
label: Recurrence Probability
description: How often does this bug occur?
options:
- Always
- Usually
- Sometimes
- Seldom
default: 0
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false

View File

@ -1,19 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: '[FEATURE] '
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@ -0,0 +1,42 @@
name: Feature request
description: Suggest an idea for OpenCut
title: '[FEATURE] '
labels: enhancement
body:
- type: markdown
attributes:
value: Please make sure that no duplicated issues has already been delivered.
- type: textarea
id: problem
attributes:
label: Problem
placeholder: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
validations:
required: true
- type: textarea
id: solution
attributes:
label: Solution
placeholder: Describe the solution you'd like.
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternative
attributes:
label: Alternative
placeholder: Describe alternatives you've considered.
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false

View File

@ -31,13 +31,13 @@ jobs:
- name: Install Bun - name: Install Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
with: with:
bun-version: 1.2.2 bun-version: 1.2.17
- name: Cache Bun modules - name: Cache Bun modules
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('apps/web/bun.lock') }} key: ${{ runner.os }}-bun-1.2.17-${{ hashFiles('apps/web/bun.lock') }}
- name: Install dependencies - name: Install dependencies
working-directory: apps/web working-directory: apps/web

7
.gitignore vendored
View File

@ -27,4 +27,9 @@ node_modules
.cursorignore .cursorignore
.turbo .turbo
*.env *.env
# cursor
.cursor/
bun.lockb

View File

@ -1,12 +1,18 @@
<img src="apps/web/public/logo.png" align="left" width="130" height="130"> <table width="100%">
<tr>
<div align="right"> <td align="left" width="120">
<img src="apps/web/public/logo.png" alt="OpenCut Logo" width="100" />
</td>
<td align="right">
<h1>OpenCut <span style="font-size: 0.7em; font-weight: normal;">(prev AppCut)</span></h1>
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
</td>
</tr>
</table>
# OpenCut (prev AppCut)
### A free, open-source video editor for web, desktop, and mobile.
</div>
## Why? ## Why?
@ -59,7 +65,16 @@ Before you begin, ensure you have the following installed on your system:
Navigate into the web app's directory and create a `.env` file from the example: Navigate into the web app's directory and create a `.env` file from the example:
```bash ```bash
cd apps/web cd apps/web
cp .env.example .env
# Unix/Linux/Mac
cp .env.example .env.local
# 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.* *The default values in the `.env` file should work for local development.*
@ -94,13 +109,13 @@ Before you begin, ensure you have the following installed on your system:
The application will be available at [http://localhost:3000](http://localhost:3000). The application will be available at [http://localhost:3000](http://localhost:3000).
======= ---
## Contributing ## Contributing
Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md) Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
======= ---
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines. We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
Quick start for contributors: Quick start for contributors:

View File

@ -1,30 +1,45 @@
FROM oven/bun:latest AS base FROM oven/bun:alpine AS base
# Install dependencies # Install dependencies and build the application
FROM base AS deps
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Build the application
FROM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY package.json package.json
COPY bun.lock bun.lock
COPY turbo.json turbo.json
COPY apps/web/package.json apps/web/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/auth/package.json packages/auth/package.json
RUN bun install
COPY apps/web/ apps/web/
COPY packages/db/ packages/db/
COPY packages/auth/ packages/auth/
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
WORKDIR /app/apps/web
RUN bun run build RUN bun run build
# Production image # Production image
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
RUN chown nextjs:nodejs apps
USER nextjs USER nextjs
@ -33,4 +48,4 @@ EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME="0.0.0.0"
CMD ["bun", "server.js"] CMD ["bun", "apps/web/server.js"]

View File

@ -13,7 +13,7 @@ if (!process.env.DATABASE_URL) {
} }
export default { export default {
schema: "./src/lib/db/schema.ts", schema: "../../packages/db/src/schema.ts",
dialect: "postgresql", dialect: "postgresql",
dbCredentials: { dbCredentials: {
url: process.env.DATABASE_URL, url: process.env.DATABASE_URL,

View File

@ -6,6 +6,7 @@ const nextConfig: NextConfig = {
}, },
reactStrictMode: true, reactStrictMode: true,
productionBrowserSourceMaps: true, productionBrowserSourceMaps: true,
output: "standalone",
}; };
export default nextConfig; export default nextConfig;

View File

@ -21,7 +21,6 @@
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@opencut/auth": "workspace:*", "@opencut/auth": "workspace:*",
"@opencut/db": "workspace:*", "@opencut/db": "workspace:*",
"@types/pg": "^8.15.4",
"@upstash/ratelimit": "^2.0.5", "@upstash/ratelimit": "^2.0.5",
"@upstash/redis": "^1.35.0", "@upstash/redis": "^1.35.0",
"@vercel/analytics": "^1.4.1", "@vercel/analytics": "^1.4.1",
@ -57,6 +56,7 @@
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/pg": "^8.15.4",
"@types/bun": "latest", "@types/bun": "latest",
"@types/react": "^18.2.48", "@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
@ -68,4 +68,4 @@
"tsx": "^4.7.1", "tsx": "^4.7.1",
"typescript": "^5" "typescript": "^5"
} }
} }

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="/icons/ms-icon-70x70.png"/>
<square150x150logo src="/icons/ms-icon-150x150.png"/>
<square310x310logo src="/icons/ms-icon-310x310.png"/>
<TileColor>#ffffff</TileColor>
</tile>
</msapplication>
</browserconfig>

BIN
apps/web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

10
apps/web/public/frame.svg Normal file
View File

@ -0,0 +1,10 @@
<svg width="459" height="77" viewBox="0 0 459 77" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="1.15625" width="458" height="75" rx="15.5" fill="#101010"/>
<rect x="0.5" y="1.15625" width="458" height="75" rx="15.5" stroke="#FFCC00"/>
<rect x="0.5" y="1.15625" width="31" height="75" rx="15.5" fill="#2A2A2A"/>
<rect x="0.5" y="1.15625" width="31" height="75" rx="15.5" stroke="#FFCC00"/>
<rect x="13" y="20.6562" width="6" height="36" rx="3" fill="#FFCC00"/>
<rect x="427.5" y="1.15625" width="31" height="75" rx="15.5" fill="#2A2A2A"/>
<rect x="427.5" y="1.15625" width="31" height="75" rx="15.5" stroke="#FFCC00"/>
<rect x="440" y="20.6562" width="6" height="36" rx="3" fill="#FFCC00"/>
</svg>

After

Width:  |  Height:  |  Size: 716 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

10
apps/web/public/logo.svg Normal file
View File

@ -0,0 +1,10 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_10_2)">
<path d="M32 9.37305V22.627L22.627 32H9.37305L0 22.627V9.37305L9.37305 0H22.627L32 9.37305ZM8 8V24H24V8H8Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_10_2">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 362 B

View File

@ -0,0 +1,44 @@
{
"name": "OpenCut",
"description": "A simple but powerful video editor that gets the job done. In your browser.",
"display": "standalone",
"start_url": "/",
"icons": [
{
"src": "/icons/android-icon-36x36.png",
"sizes": "36x36",
"type": "image\/png",
"density": "0.75"
},
{
"src": "/icons/android-icon-48x48.png",
"sizes": "48x48",
"type": "image\/png",
"density": "1.0"
},
{
"src": "/icons/android-icon-72x72.png",
"sizes": "72x72",
"type": "image\/png",
"density": "1.5"
},
{
"src": "/icons/android-icon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
},
{
"src": "/icons/android-icon-144x144.png",
"sizes": "144x144",
"type": "image\/png",
"density": "3.0"
},
{
"src": "/icons/android-icon-192x192.png",
"sizes": "192x192",
"type": "image\/png",
"density": "4.0"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@ -52,8 +52,8 @@ function LoginForm() {
try { try {
await signIn.social({ await signIn.social({
provider: "google", provider: "google",
callbackURL: "/editor",
}); });
router.push("/editor");
} catch (error) { } catch (error) {
setError("Failed to sign in with Google. Please try again."); setError("Failed to sign in with Google. Please try again.");
setIsGoogleLoading(false); setIsGoogleLoading(false);

View File

@ -0,0 +1,5 @@
import { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
return new Response("OK", { status: 200 });
}

View File

@ -1,7 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { db } from "@opencut/db"; import { db, eq } from "@opencut/db";
import { waitlist } from "@opencut/db/schema"; import { waitlist } from "@opencut/db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { waitlistRateLimit } from "@/lib/rate-limit"; import { waitlistRateLimit } from "@/lib/rate-limit";
import { z } from "zod"; import { z } from "zod";

View File

@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { GithubIcon } from "@/components/icons"; import { GithubIcon } from "@/components/icons";
import { Badge } from "@/components/ui/badge";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Contributors - OpenCut", title: "Contributors - OpenCut",
@ -49,7 +50,7 @@ async function getContributors(): Promise<Contributor[]> {
const contributors = await response.json(); const contributors = await response.json();
const filteredContributors = contributors.filter( const filteredContributors = contributors.filter(
(contributor: any) => contributor.type === "User" (contributor: Contributor) => contributor.type === "User"
); );
return filteredContributors; return filteredContributors;
@ -61,8 +62,8 @@ async function getContributors(): Promise<Contributor[]> {
export default async function ContributorsPage() { export default async function ContributorsPage() {
const contributors = await getContributors(); const contributors = await getContributors();
const topContributor = contributors[0]; const topContributors = contributors.slice(0, 2);
const otherContributors = contributors.slice(1); const otherContributors = contributors.slice(2);
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
@ -77,10 +78,10 @@ export default async function ContributorsPage() {
<div className="relative container mx-auto px-4 py-16"> <div className="relative container mx-auto px-4 py-16">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="text-center mb-20"> <div className="text-center mb-20">
<div className="inline-flex items-center gap-2 bg-muted/50 text-muted-foreground px-3 py-1 rounded-full text-sm mb-6"> <Badge variant="secondary" className="gap-2 mb-6">
<GithubIcon className="h-3 w-3" /> <GithubIcon className="h-3 w-3" />
Open Source Open Source
</div> </Badge>
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6"> <h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-6">
Contributors Contributors
</h1> </h1>
@ -105,54 +106,59 @@ export default async function ContributorsPage() {
</div> </div>
</div> </div>
{topContributor && ( {topContributors.length > 0 && (
<div className="mb-20"> <div className="mb-20">
<div className="text-center mb-12"> <div className="text-center mb-12">
<h2 className="text-2xl font-semibold mb-2"> <h2 className="text-2xl font-semibold mb-2">
Top Contributor Top Contributors
</h2> </h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Leading the way in contributions Leading the way in contributions
</p> </p>
</div> </div>
<Link <div className="flex flex-col md:flex-row gap-6 justify-center max-w-4xl mx-auto">
href={topContributor.html_url} {topContributors.map((contributor, index) => (
target="_blank" <Link
rel="noopener noreferrer" key={contributor.id}
className="group block" href={contributor.html_url}
> target="_blank"
<div className="relative mx-auto max-w-md"> rel="noopener noreferrer"
<div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" /> className="group block flex-1"
<Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl"> >
<CardContent className="p-8 text-center"> <div className="relative mx-auto max-w-md">
<div className="relative mb-6"> <div className="absolute inset-0 bg-gradient-to-r from-muted/50 to-muted/30 rounded-2xl blur group-hover:blur-md transition-all duration-300" />
<Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl"> <Card className="relative bg-background/80 backdrop-blur-sm border-2 group-hover:border-muted-foreground/20 transition-all duration-300 group-hover:shadow-xl">
<AvatarImage <CardContent className="p-8 text-center">
src={topContributor.avatar_url} <div className="relative mb-6">
alt={`${topContributor.login}'s avatar`} <Avatar className="h-24 w-24 mx-auto ring-4 ring-background shadow-2xl">
/> <AvatarImage
<AvatarFallback className="text-lg font-semibold"> src={contributor.avatar_url}
{topContributor.login.charAt(0).toUpperCase()} alt={`${contributor.login}'s avatar`}
</AvatarFallback> />
</Avatar> <AvatarFallback className="text-lg font-semibold">
<div className="absolute -top-2 -right-2 bg-foreground text-background rounded-full w-8 h-8 flex items-center justify-center text-sm font-bold"> {contributor.login.charAt(0).toUpperCase()}
1 </AvatarFallback>
</div> </Avatar>
</div> <div 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">
<h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors"> {index + 1}
{topContributor.login} </div>
</h3> </div>
<div className="flex items-center justify-center gap-2 text-muted-foreground"> <h3 className="text-xl font-semibold mb-2 group-hover:text-foreground/80 transition-colors">
<span className="font-medium text-foreground"> {contributor.login}
{topContributor.contributions} </h3>
</span> <div className="flex items-center justify-center gap-2 text-muted-foreground">
<span>contributions</span> <span className="font-medium text-foreground">
</div> {contributor.contributions}
</CardContent> </span>
</Card> <span>contributions</span>
</div> </div>
</Link> </CardContent>
</Card>
</div>
</Link>
))}
</div>
</div> </div>
)} )}
@ -167,7 +173,7 @@ export default async function ContributorsPage() {
</p> </p>
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
{otherContributors.map((contributor, index) => ( {otherContributors.map((contributor, index) => (
<Link <Link
key={contributor.id} key={contributor.id}
@ -179,8 +185,8 @@ export default async function ContributorsPage() {
animationDelay: `${index * 50}ms`, animationDelay: `${index * 50}ms`,
}} }}
> >
<div className="text-center p-4 rounded-xl hover:bg-muted/50 transition-all duration-300 group-hover:scale-105"> <div className="text-center p-2 rounded-xl transition-all duration-300 hover:opacity-50">
<Avatar className="h-16 w-16 mx-auto mb-3 ring-2 ring-transparent group-hover:ring-muted-foreground/20 transition-all duration-300"> <Avatar className="h-16 w-16 mx-auto mb-3">
<AvatarImage <AvatarImage
src={contributor.avatar_url} src={contributor.avatar_url}
alt={`${contributor.login}'s avatar`} alt={`${contributor.login}'s avatar`}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -55,7 +55,7 @@
--accent-foreground: 0 0% 98%; --accent-foreground: 0 0% 98%;
--destructive: 0 100% 60%; --destructive: 0 100% 60%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%; --border: 0 0% 17%;
--input: 0 0% 14.9%; --input: 0 0% 14.9%;
--ring: 0 0% 83.1%; --ring: 0 0% 83.1%;
--chart-1: 220 70% 50%; --chart-1: 220 70% 50%;

View File

@ -1,4 +1,3 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import { Analytics } from "@vercel/analytics/react"; import { Analytics } from "@vercel/analytics/react";
@ -6,17 +5,15 @@ import Script from "next/script";
import "./globals.css"; import "./globals.css";
import { Toaster } from "../components/ui/sonner"; import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip"; import { TooltipProvider } from "../components/ui/tooltip";
import { DevelopmentDebug } from "../components/development-debug";
import { baseMetaData } from "./metadata";
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-inter", variable: "--font-inter",
}); });
export const metadata: Metadata = { export const metadata = baseMetaData;
title: "OpenCut",
description:
"A simple but powerful video editor that gets the job done. In your browser.",
};
export default function RootLayout({ export default function RootLayout({
children, children,
@ -31,6 +28,7 @@ export default function RootLayout({
{children} {children}
<Analytics /> <Analytics />
<Toaster /> <Toaster />
<DevelopmentDebug />
<Script <Script
src="https://app.databuddy.cc/databuddy.js" src="https://app.databuddy.cc/databuddy.js"
strategy="afterInteractive" strategy="afterInteractive"

View File

@ -0,0 +1,66 @@
import { Metadata } from "next";
const title = "OpenCut";
const description = "A simple but powerful video editor that gets the job done. In your browser.";
const openGraphImageUrl = "https://opencut.app/opengraph-image.jpg";
const twitterImageUrl = "/opengraph-image.jpg";
export const baseMetaData: Metadata = {
title: title,
description: description,
openGraph: {
title: title,
description: description,
url: "https://opencut.app",
siteName: "OpenCut",
locale: "en_US",
type: "website",
images: [
{
url: openGraphImageUrl,
width: 1200,
height: 630,
alt: "OpenCut",
},
],
},
twitter: {
card: "summary_large_image",
title: title,
description: description,
creator: "@opencutapp",
images: [twitterImageUrl],
},
robots: {
index: true,
follow: true,
},
icons: {
icon: [
{ url: "/favicon.ico" },
{ url: "/icons/favicon-16x16.png", sizes: "16x16", type: "image/png" },
{ url: "/icons/favicon-32x32.png", sizes: "32x32", type: "image/png" },
{ url: "/icons/favicon-96x96.png", sizes: "96x96", type: "image/png" },
],
apple: [
{ url: "/icons/apple-icon-57x57.png", sizes: "57x57", type: "image/png" },
{ url: "/icons/apple-icon-60x60.png", sizes: "60x60", type: "image/png" },
{ url: "/icons/apple-icon-72x72.png", sizes: "72x72", type: "image/png" },
{ url: "/icons/apple-icon-76x76.png", sizes: "76x76", type: "image/png" },
{ url: "/icons/apple-icon-114x114.png", sizes: "114x114", type: "image/png" },
{ url: "/icons/apple-icon-120x120.png", sizes: "120x120", type: "image/png" },
{ url: "/icons/apple-icon-144x144.png", sizes: "144x144", type: "image/png" },
{ url: "/icons/apple-icon-152x152.png", sizes: "152x152", type: "image/png" },
{ url: "/icons/apple-icon-180x180.png", sizes: "180x180", type: "image/png" },
],
shortcut: ["/favicon.ico"]
},
appleWebApp: {
capable: true,
title: title,
},
manifest: "/manifest.json",
other: {
"msapplication-config": "/browserconfig.xml"
}
};

View File

@ -1,17 +1,27 @@
import { Hero } from "@/components/landing/hero"; import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header"; import { Header } from "@/components/header";
import { getWaitlistCount } from "@/lib/waitlist"; import { Footer } from "@/components/footer";
import { getWaitlistCount } from "@/lib/waitlist";
// Force dynamic rendering so waitlist count updates in real-time import Image from "next/image";
export const dynamic = "force-dynamic";
// Force dynamic rendering so waitlist count updates in real-time
export default async function Home() { export const dynamic = "force-dynamic";
const signupCount = await getWaitlistCount();
export default async function Home() {
return ( const signupCount = await getWaitlistCount();
<div>
<Header /> return (
<Hero signupCount={signupCount} /> <div>
</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 />
</div>
);
}

View File

@ -1,398 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { signUp, signIn } from "@opencut/auth/client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ArrowLeft, Loader2 } from "lucide-react";
import { GoogleIcon } from "@/components/icons";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
// Zod schemas
const loginSchema = z.object({
email: z.string().email("Please enter a valid email address"),
password: z.string().min(1, "Password is required"),
});
const signupSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Please enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
type LoginFormData = z.infer<typeof loginSchema>;
type SignupFormData = z.infer<typeof signupSchema>;
interface AuthFormProps {
mode: "login" | "signup";
}
const authConfig = {
login: {
title: "Welcome back",
description: "Sign in to your account to continue",
buttonText: "Sign in",
linkText: "Don't have an account?",
linkHref: "/signup",
linkLabel: "Sign up",
successRedirect: "/editor",
},
signup: {
title: "Create your account",
description: "Get started with your free account today",
buttonText: "Create account",
linkText: "Already have an account?",
linkHref: "/login",
linkLabel: "Sign in",
successRedirect: "/login",
},
} as const;
interface AuthFormContentProps {
error: string | null;
setError: (error: string | null) => void;
isGoogleLoading: boolean;
config: typeof authConfig.login | typeof authConfig.signup;
router: ReturnType<typeof useRouter>;
}
function LoginFormContent({
error,
setError,
isGoogleLoading,
config,
router,
}: AuthFormContentProps) {
const form = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
});
const { isSubmitting } = form.formState;
const isAnyLoading = isSubmitting || isGoogleLoading;
const onSubmit = async (data: LoginFormData) => {
setError(null);
try {
const { error } = await signIn.email({
email: data.email,
password: data.password,
});
if (error) {
setError(error.message || "An unexpected error occurred.");
return;
}
router.push(config.successRedirect);
} catch (error) {
setError("An unexpected error occurred. Please try again.");
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="m@example.com"
disabled={isAnyLoading}
className="h-11"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
disabled={isAnyLoading}
className="h-11"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isAnyLoading}
className="w-full h-11"
size="lg"
>
{isSubmitting ? (
<Loader2 className="animate-spin" />
) : (
config.buttonText
)}
</Button>
</form>
</Form>
);
}
function SignupFormContent({
error,
setError,
isGoogleLoading,
config,
router,
}: AuthFormContentProps) {
const form = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
defaultValues: { email: "", password: "", name: "" },
});
const { isSubmitting } = form.formState;
const isAnyLoading = isSubmitting || isGoogleLoading;
const onSubmit = async (data: SignupFormData) => {
setError(null);
try {
const { error } = await signUp.email({
name: data.name,
email: data.email,
password: data.password,
});
if (error) {
setError(error.message || "An unexpected error occurred.");
return;
}
router.push(config.successRedirect);
} catch (error) {
setError("An unexpected error occurred. Please try again.");
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input
placeholder="John Doe"
disabled={isAnyLoading}
className="h-11"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="m@example.com"
disabled={isAnyLoading}
className="h-11"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Create a strong password"
disabled={isAnyLoading}
className="h-11"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isAnyLoading}
className="w-full h-11"
size="lg"
>
{isSubmitting ? (
<Loader2 className="animate-spin" />
) : (
config.buttonText
)}
</Button>
</form>
</Form>
);
}
export function AuthForm({ mode }: AuthFormProps) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const config = authConfig[mode];
const handleGoogleAuth = async () => {
setError(null);
setIsGoogleLoading(true);
try {
await signIn.social({
provider: "google",
});
router.push(config.successRedirect);
} catch (error) {
setError(
`Failed to ${mode === "login" ? "sign in" : "sign up"} with Google. Please try again.`
);
setIsGoogleLoading(false);
}
};
return (
<div className="flex h-screen items-center justify-center relative">
<Button
variant="text"
onClick={() => router.back()}
className="absolute top-6 left-6"
>
<ArrowLeft className="h-5 w-5" /> Back
</Button>
<Card className="w-[400px] shadow-lg border-0">
<CardHeader className="text-center pb-4">
<CardTitle className="text-2xl font-semibold">
{config.title}
</CardTitle>
<CardDescription className="text-base">
{config.description}
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-col space-y-6">
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
onClick={handleGoogleAuth}
variant="outline"
size="lg"
disabled={isGoogleLoading}
>
{isGoogleLoading ? (
<Loader2 className="animate-spin" />
) : (
<GoogleIcon />
)}
Continue with Google
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator className="w-full" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
{mode === "login" ? (
<LoginFormContent
error={error}
setError={setError}
isGoogleLoading={isGoogleLoading}
config={config}
router={router}
/>
) : (
<SignupFormContent
error={error}
setError={setError}
isGoogleLoading={isGoogleLoading}
config={config}
router={router}
/>
)}
</div>
<div className="mt-6 text-center text-sm">
{config.linkText}{" "}
<Link
href={config.linkHref}
className="font-medium text-primary underline-offset-4 hover:underline"
>
{config.linkLabel}
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,107 @@
"use client";
import {
useTimelineStore,
type TimelineClip,
type TimelineTrack,
} from "@/stores/timeline-store";
import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { Button } from "@/components/ui/button";
import { useState } from "react";
// Only show in development
const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
interface ActiveClip {
clip: TimelineClip;
track: TimelineTrack;
mediaItem: MediaItem | null;
}
export function DevelopmentDebug() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
const { currentTime } = usePlaybackStore();
const [showDebug, setShowDebug] = useState(false);
// Don't render anything in production
if (!SHOW_DEBUG_INFO) return null;
// Get active clips at current time
const getActiveClips = (): ActiveClip[] => {
const activeClips: ActiveClip[] = [];
tracks.forEach((track) => {
track.clips.forEach((clip) => {
const clipStart = clip.startTime;
const clipEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.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;
activeClips.push({ clip, track, mediaItem });
}
});
});
return activeClips;
};
const activeClips = getActiveClips();
return (
<div className="fixed bottom-4 right-4 z-50">
<div className="flex flex-col items-end gap-2">
{/* Toggle Button */}
<Button
variant="outline"
size="sm"
onClick={() => setShowDebug(!showDebug)}
className="text-xs backdrop-blur-md bg-background/80 border-border/50"
>
Debug {showDebug ? "ON" : "OFF"}
</Button>
{/* Debug Info Panel */}
{showDebug && (
<div className="backdrop-blur-md bg-background/90 border border-border/50 rounded-lg p-3 max-w-sm">
<div className="text-xs font-medium mb-2 text-foreground">
Active Clips ({activeClips.length})
</div>
<div className="space-y-1 max-h-40 overflow-y-auto">
{activeClips.map((clipData, index) => (
<div
key={clipData.clip.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="text-muted-foreground text-[10px]">
{clipData.mediaItem?.type || "test"}
</div>
</div>
</div>
))}
{activeClips.length === 0 && (
<div className="text-muted-foreground text-xs py-2 text-center">
No active clips
</div>
)}
</div>
<div className="mt-2 pt-2 border-t border-border/30 text-[10px] text-muted-foreground">
Time: {currentTime.toFixed(2)}s
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -3,12 +3,11 @@
import Link from "next/link"; import Link from "next/link";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { ChevronLeft, Download } from "lucide-react"; import { ChevronLeft, Download } from "lucide-react";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
import { HeaderBase } from "./header-base"; import { HeaderBase } from "./header-base";
import { ProjectNameEditor } from "./editor/project-name-editor";
export function EditorHeader() { export function EditorHeader() {
const { activeProject } = useProjectStore();
const { getTotalDuration } = useTimelineStore(); const { getTotalDuration } = useTimelineStore();
const handleExport = () => { const handleExport = () => {
@ -24,13 +23,15 @@ export function EditorHeader() {
}; };
const leftContent = ( const leftContent = (
<Link <div className="flex items-center gap-2">
href="/" <Link
className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity" href="/"
> className="font-medium tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity"
<ChevronLeft className="h-4 w-4" /> >
<span className="text-sm">{activeProject?.name || "Loading..."}</span> <ChevronLeft className="h-4 w-4" />
</Link> </Link>
<ProjectNameEditor />
</div>
); );
const centerContent = ( const centerContent = (

View File

@ -0,0 +1,115 @@
import React, { useEffect, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
interface AudioWaveformProps {
audioUrl: string;
height?: number;
className?: string;
}
const AudioWaveform: React.FC<AudioWaveformProps> = ({
audioUrl,
height = 32,
className = ''
}) => {
const waveformRef = useRef<HTMLDivElement>(null);
const wavesurfer = useRef<WaveSurfer | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let mounted = true;
const initWaveSurfer = async () => {
if (!waveformRef.current || !audioUrl) return;
try {
// Clean up any existing instance
if (wavesurfer.current) {
try {
wavesurfer.current.destroy();
} catch (e) {
// Silently ignore destroy errors
}
wavesurfer.current = null;
}
wavesurfer.current = WaveSurfer.create({
container: waveformRef.current,
waveColor: 'rgba(255, 255, 255, 0.6)',
progressColor: 'rgba(255, 255, 255, 0.9)',
cursorColor: 'transparent',
barWidth: 2,
barGap: 1,
height: height,
normalize: true,
interact: false,
});
// Event listeners
wavesurfer.current.on('ready', () => {
if (mounted) {
setIsLoading(false);
setError(false);
}
});
wavesurfer.current.on('error', (err) => {
console.error('WaveSurfer error:', err);
if (mounted) {
setError(true);
setIsLoading(false);
}
});
await wavesurfer.current.load(audioUrl);
} catch (err) {
console.error('Failed to initialize WaveSurfer:', err);
if (mounted) {
setError(true);
setIsLoading(false);
}
}
};
initWaveSurfer();
return () => {
mounted = false;
if (wavesurfer.current) {
try {
wavesurfer.current.destroy();
} catch (e) {
// Silently ignore destroy errors
}
wavesurfer.current = null;
}
};
}, [audioUrl, height]);
if (error) {
return (
<div className={`flex items-center justify-center ${className}`} style={{ height }}>
<span className="text-xs text-foreground/60">Audio unavailable</span>
</div>
);
}
return (
<div className={`relative ${className}`}>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs text-foreground/60">Loading...</span>
</div>
)}
<div
ref={waveformRef}
className={`w-full transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`}
style={{ height }}
/>
</div>
);
};
export default AudioWaveform;

View File

@ -1,14 +1,15 @@
"use client"; "use client";
import { Button } from "../ui/button";
import { AspectRatio } from "../ui/aspect-ratio";
import { DragOverlay } from "../ui/drag-overlay";
import { useMediaStore } from "@/stores/media-store";
import { processMediaFiles } from "@/lib/media-processing";
import { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react";
import { useDragDrop } from "@/hooks/use-drag-drop"; import { 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 { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; 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. // 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. // You can upload files or drag them from your computer. Dragging from here to the timeline adds them to your video project.
@ -17,27 +18,28 @@ export function MediaPanel() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [mediaFilter, setMediaFilter] = useState("all"); const [mediaFilter, setMediaFilter] = useState("all");
const processFiles = async (files: FileList | File[]) => { const processFiles = async (files: FileList | File[]) => {
// If no files, do nothing if (!files || files.length === 0) return;
if (!files?.length) return;
setIsProcessing(true); setIsProcessing(true);
setProgress(0);
try { try {
// Process files (extract metadata, generate thumbnails, etc.) // Process files (extract metadata, generate thumbnails, etc.)
const items = await processMediaFiles(files); const processedItems = await processMediaFiles(files, (p) =>
setProgress(p)
);
// Add each processed media item to the store // Add each processed media item to the store
items.forEach((item) => { processedItems.forEach((item) => addMediaItem(item));
addMediaItem(item);
});
} catch (error) { } catch (error) {
// Show error if processing fails // Show error toast if processing fails
console.error("File processing failed:", error); console.error("Error processing files:", error);
toast.error("Failed to process files"); toast.error("Failed to process files");
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
setProgress(0);
} }
}; };
@ -57,6 +59,21 @@ export function MediaPanel() {
const handleRemove = (e: React.MouseEvent, id: string) => { const handleRemove = (e: React.MouseEvent, id: string) => {
// Remove a media item from the store // Remove a media item from the store
e.stopPropagation(); e.stopPropagation();
// 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); removeMediaItem(id);
}; };
@ -67,7 +84,7 @@ export function MediaPanel() {
return `${min}:${sec.toString().padStart(2, "0")}`; return `${min}:${sec.toString().padStart(2, "0")}`;
}; };
const startDrag = (e: React.DragEvent, item: any) => { const startDrag = (e: React.DragEvent, item: MediaItem) => {
// When dragging a media item, set drag data for timeline to read // When dragging a media item, set drag data for timeline to read
e.dataTransfer.setData( e.dataTransfer.setData(
"application/x-media-item", "application/x-media-item",
@ -84,21 +101,24 @@ export function MediaPanel() {
useEffect(() => { useEffect(() => {
const filtered = mediaItems.filter((item) => { const filtered = mediaItems.filter((item) => {
if (mediaFilter && mediaFilter !== 'all' && item.type !== mediaFilter) { if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
return false; return false;
} }
if (searchQuery && !item.name.toLowerCase().includes(searchQuery.toLowerCase())) { if (
searchQuery &&
!item.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false; return false;
} }
return true; return true;
}); });
setFilteredMediaItems(filtered); setFilteredMediaItems(filtered);
}, [mediaItems, mediaFilter, searchQuery]); }, [mediaItems, mediaFilter, searchQuery]);
const renderPreview = (item: any) => { const renderPreview = (item: MediaItem) => {
// Render a preview for each media type (image, video, audio, unknown) // Render a preview for each media type (image, video, audio, unknown)
// Each preview is draggable to the timeline // Each preview is draggable to the timeline
const baseDragProps = { const baseDragProps = {
@ -209,23 +229,23 @@ export function MediaPanel() {
{/* Button to add/upload media */} {/* Button to add/upload media */}
<div className="flex gap-2"> <div className="flex gap-2">
{/* Search and filter controls */} {/* Search and filter controls */}
<select <select
value={mediaFilter} value={mediaFilter}
onChange={(e) => setMediaFilter(e.target.value)} onChange={(e) => setMediaFilter(e.target.value)}
className="px-2 py-1 text-xs border rounded bg-background" className="px-2 py-1 text-xs border rounded bg-background"
> >
<option value="all">All</option> <option value="all">All</option>
<option value="video">Video</option> <option value="video">Video</option>
<option value="audio">Audio</option> <option value="audio">Audio</option>
<option value="image">Image</option> <option value="image">Image</option>
</select> </select>
<input <input
type="text" type="text"
placeholder="Search media..." placeholder="Search media..."
className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background" className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
{/* Add media button */} {/* Add media button */}
<Button <Button
@ -233,21 +253,23 @@ export function MediaPanel() {
size="sm" size="sm"
onClick={handleFileSelect} onClick={handleFileSelect}
disabled={isProcessing} disabled={isProcessing}
className="flex-none min-w-[80px] whitespace-nowrap" className="flex-none min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
> >
{isProcessing ? ( {isProcessing ? (
<> <>
<Upload className="h-4 w-4 mr-2 animate-spin" /> <Upload className="h-4 w-4 animate-spin" />
Processing... <span className="hidden md:inline ml-2">{progress}%</span>
</> </>
) : ( ) : (
<> <>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add <span className="hidden sm:inline ml-2" aria-label="Add file">
Add
</span>
</> </>
)} )}
</Button> </Button>
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-y-auto p-2">
@ -276,7 +298,15 @@ export function MediaPanel() {
<AspectRatio ratio={item.aspectRatio}> <AspectRatio ratio={item.aspectRatio}>
{renderPreview(item)} {renderPreview(item)}
</AspectRatio> </AspectRatio>
<span className="text-xs truncate px-1">{item.name}</span> <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> </Button>
{/* Show remove button on hover */} {/* Show remove button on hover */}

View File

@ -1,213 +1,283 @@
"use client"; "use client";
import { useTimelineStore } from "@/stores/timeline-store"; import {
import { useMediaStore } from "@/stores/media-store"; useTimelineStore,
import { usePlaybackStore } from "@/stores/playback-store"; type TimelineClip,
import { VideoPlayer } from "@/components/ui/video-player"; type TimelineTrack,
import { Button } from "@/components/ui/button"; } from "@/stores/timeline-store";
import { Play, Pause } from "lucide-react"; import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { useState, useRef } from "react"; import { usePlaybackStore } from "@/stores/playback-store";
import { VideoPlayer } from "@/components/ui/video-player";
// Debug flag - set to false to hide active clips info import { Button } from "@/components/ui/button";
const SHOW_DEBUG_INFO = process.env.NODE_ENV === 'development'; import { Play, Pause, Volume2, VolumeX, Plus } from "lucide-react";
import { useState, useRef, useEffect } from "react";
export function PreviewPanel() {
const { tracks } = useTimelineStore(); interface ActiveClip {
const { mediaItems } = useMediaStore(); clip: TimelineClip;
const { isPlaying, toggle, currentTime } = usePlaybackStore(); track: TimelineTrack;
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 }); mediaItem: MediaItem | null;
const [showDebug, setShowDebug] = useState(SHOW_DEBUG_INFO); }
const previewRef = useRef<HTMLDivElement>(null);
export function PreviewPanel() {
// Get active clips at current time const { tracks } = useTimelineStore();
const getActiveClips = () => { const { mediaItems } = useMediaStore();
const activeClips: Array<{ const { currentTime, muted, toggleMute, volume } = usePlaybackStore();
clip: any; const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
track: any; const previewRef = useRef<HTMLDivElement>(null);
mediaItem: any; const containerRef = useRef<HTMLDivElement>(null);
}> = []; const [previewDimensions, setPreviewDimensions] = useState({
width: 0,
tracks.forEach((track) => { height: 0,
track.clips.forEach((clip) => { });
const clipStart = clip.startTime;
const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); // Calculate optimal preview size that fits in container while maintaining aspect ratio
useEffect(() => {
if (currentTime >= clipStart && currentTime < clipEnd) { const updatePreviewSize = () => {
const mediaItem = clip.mediaId === "test" if (!containerRef.current) return;
? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
: mediaItems.find((item) => item.id === clip.mediaId); const container = containerRef.current.getBoundingClientRect();
const computedStyle = getComputedStyle(containerRef.current);
if (mediaItem || clip.mediaId === "test") {
activeClips.push({ clip, track, mediaItem }); // Get padding values
} const paddingTop = parseFloat(computedStyle.paddingTop);
} const paddingBottom = parseFloat(computedStyle.paddingBottom);
}); const paddingLeft = parseFloat(computedStyle.paddingLeft);
}); const paddingRight = parseFloat(computedStyle.paddingRight);
return activeClips; // Get gap value (gap-4 = 1rem = 16px)
}; const gap = parseFloat(computedStyle.gap) || 16;
const activeClips = getActiveClips(); // Get toolbar height if it exists
const aspectRatio = canvasSize.width / canvasSize.height; const toolbar = containerRef.current.querySelector("[data-toolbar]");
const toolbarHeight = toolbar
// Render a clip ? toolbar.getBoundingClientRect().height
const renderClip = (clipData: any, index: number) => { : 0;
const { clip, mediaItem } = clipData;
// Calculate available space after accounting for padding, gap, and toolbar
// Test clips const availableWidth = container.width - paddingLeft - paddingRight;
if (!mediaItem || clip.mediaId === "test") { const availableHeight =
return ( container.height -
<div paddingTop -
key={clip.id} paddingBottom -
className="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center" toolbarHeight -
> (toolbarHeight > 0 ? gap : 0);
<div className="text-center">
<div className="text-2xl mb-2">🎬</div> const targetRatio = canvasSize.width / canvasSize.height;
<p className="text-xs text-white">{clip.name}</p> const containerRatio = availableWidth / availableHeight;
</div>
</div> let width, height;
);
} if (containerRatio > targetRatio) {
// Container is wider - constrain by height
// Video clips height = availableHeight;
if (mediaItem.type === "video") { width = height * targetRatio;
return ( } else {
<div key={clip.id} className="absolute inset-0"> // Container is taller - constrain by width
<VideoPlayer width = availableWidth;
src={mediaItem.url} height = width / targetRatio;
poster={mediaItem.thumbnailUrl} }
clipStartTime={clip.startTime}
trimStart={clip.trimStart} setPreviewDimensions({ width, height });
trimEnd={clip.trimEnd} };
clipDuration={clip.duration}
/> updatePreviewSize();
</div>
); const resizeObserver = new ResizeObserver(updatePreviewSize);
} if (containerRef.current) {
resizeObserver.observe(containerRef.current);
// Image clips }
if (mediaItem.type === "image") {
return ( return () => resizeObserver.disconnect();
<div key={clip.id} className="absolute inset-0"> }, [canvasSize.width, canvasSize.height]);
<img
src={mediaItem.url} // Get active clips at current time
alt={mediaItem.name} const getActiveClips = (): ActiveClip[] => {
className="w-full h-full object-cover" const activeClips: ActiveClip[] = [];
draggable={false}
/> tracks.forEach((track) => {
</div> track.clips.forEach((clip) => {
); const clipStart = clip.startTime;
} const clipEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
// Audio clips (visual representation)
if (mediaItem.type === "audio") { if (currentTime >= clipStart && currentTime < clipEnd) {
return ( const mediaItem =
<div clip.mediaId === "test"
key={clip.id} ? null // Test clips don't have a real media item
className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center" : mediaItems.find((item) => item.id === clip.mediaId) || null;
>
<div className="text-center"> activeClips.push({ clip, track, mediaItem });
<div className="text-2xl mb-2">🎵</div> }
<p className="text-xs text-white">{mediaItem.name}</p> });
</div> });
</div>
); return activeClips;
} };
return null; const activeClips = getActiveClips();
};
// Render a clip
// Canvas presets const renderClip = (clipData: ActiveClip, index: number) => {
const canvasPresets = [ const { clip, mediaItem } = clipData;
{ name: "16:9 HD", width: 1920, height: 1080 },
{ name: "16:9 4K", width: 3840, height: 2160 }, // Test clips
{ name: "9:16 Mobile", width: 1080, height: 1920 }, if (!mediaItem || clip.mediaId === "test") {
{ name: "1:1 Square", width: 1080, height: 1080 }, return (
{ name: "4:3 Standard", width: 1440, height: 1080 }, <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"
return ( >
<div className="h-full w-full flex flex-col min-h-0 min-w-0"> <div className="text-center">
{/* Controls */} <div className="text-2xl mb-2">🎬</div>
<div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0"> <p className="text-xs text-white">{clip.name}</p>
<span className="text-muted-foreground">Canvas:</span> </div>
<select </div>
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 }); // Video clips
}} if (mediaItem.type === "video") {
className="bg-background border rounded px-2 py-1 text-xs" return (
> <div key={clip.id} className="absolute inset-0">
{canvasPresets.map(preset => ( <VideoPlayer
<option key={preset.name} value={`${preset.width}x${preset.height}`}> src={mediaItem.url}
{preset.name} ({preset.width}×{preset.height}) poster={mediaItem.thumbnailUrl}
</option> clipStartTime={clip.startTime}
))} trimStart={clip.trimStart}
</select> trimEnd={clip.trimEnd}
clipDuration={clip.duration}
{/* Debug Toggle - Only show in development */} />
{SHOW_DEBUG_INFO && ( </div>
<Button );
variant="text" }
size="sm"
onClick={() => setShowDebug(!showDebug)} // Image clips
className="text-xs" if (mediaItem.type === "image") {
> return (
Debug {showDebug ? 'ON' : 'OFF'} <div key={clip.id} className="absolute inset-0">
</Button> <img
)} src={mediaItem.url}
alt={mediaItem.name}
<Button variant="outline" size="sm" onClick={toggle} className="ml-auto"> className="w-full h-full object-cover"
{isPlaying ? <Pause className="h-3 w-3 mr-1" /> : <Play className="h-3 w-3 mr-1" />} draggable={false}
{isPlaying ? "Pause" : "Play"} />
</Button> </div>
</div> );
}
{/* Preview Area */}
<div className="flex-1 flex items-center justify-center p-2 sm:p-4 bg-gray-900 min-h-0 min-w-0"> // Audio clips (visual representation)
<div if (mediaItem.type === "audio") {
ref={previewRef} return (
className="relative overflow-hidden rounded-sm max-w-full max-h-full bg-black border border-gray-600" <div
style={{ key={clip.id}
aspectRatio: aspectRatio.toString(), className="absolute inset-0 bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex items-center justify-center"
width: "100%", >
height: "100%", <div className="text-center">
}} <div className="text-2xl mb-2">🎵</div>
> <p className="text-xs text-white">{mediaItem.name}</p>
{activeClips.length === 0 ? ( </div>
<div className="absolute inset-0 flex items-center justify-center text-white/50"> </div>
{tracks.length === 0 ? "Drop media to start editing" : "No clips at current time"} );
</div> }
) : (
activeClips.map((clipData, index) => renderClip(clipData, index)) return null;
)} };
</div>
</div> // Canvas presets
const canvasPresets = [
{/* Debug Info Panel - Conditionally rendered */} { name: "16:9 HD", width: 1920, height: 1080 },
{showDebug && ( { name: "16:9 4K", width: 3840, height: 2160 },
<div className="border-t bg-background p-2 flex-shrink-0"> { name: "9:16 Mobile", width: 1080, height: 1920 },
<div className="text-xs font-medium mb-1">Debug: Active Clips ({activeClips.length})</div> { name: "1:1 Square", width: 1080, height: 1080 },
<div className="flex gap-2 overflow-x-auto"> { name: "4:3 Standard", width: 1440, height: 1080 },
{activeClips.map((clipData, index) => ( ];
<div
key={clipData.clip.id} return (
className="flex items-center gap-1 px-2 py-1 bg-muted rounded text-xs whitespace-nowrap" <div className="h-full w-full flex flex-col min-h-0 min-w-0">
> {/* Controls */}
<span className="w-4 h-4 bg-primary/20 rounded text-center text-xs leading-4"> <div className="border-b p-2 flex items-center gap-2 text-xs flex-shrink-0">
{index + 1} <span className="text-muted-foreground">Canvas:</span>
</span> <select
<span>{clipData.clip.name}</span> value={`${canvasSize.width}x${canvasSize.height}`}
<span className="text-muted-foreground">({clipData.mediaItem?.type || 'test'})</span> onChange={(e) => {
</div> const preset = canvasPresets.find(
))} (p) => `${p.width}x${p.height}` === e.target.value
{activeClips.length === 0 && ( );
<span className="text-muted-foreground">No active clips</span> if (preset)
)} setCanvasSize({ width: preset.width, height: preset.height });
</div> }}
</div> className="bg-background border rounded px-2 py-1 text-xs"
)} >
</div> {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
ref={containerRef}
className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4"
>
<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>
<PreviewToolbar />
</div>
</div>
);
}
function PreviewToolbar() {
const { isPlaying, toggle } = usePlaybackStore();
return (
<div
data-toolbar
className="flex items-center justify-center gap-2 px-4 pt-2 bg-background-500 w-full"
>
<Button variant="text" size="icon" onClick={toggle}>
{isPlaying ? (
<Pause className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
</div>
);
}

View File

@ -0,0 +1,110 @@
"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

@ -17,13 +17,12 @@ import { useMediaStore } from "@/stores/media-store";
import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment"; import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
import { useState } from "react"; import { useState } from "react";
import { SpeedControl } from "./speed-control"; import { SpeedControl } from "./speed-control";
import type { BackgroundType } from "@/types/editor";
export function PropertiesPanel() { export function PropertiesPanel() {
const { tracks } = useTimelineStore(); const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore(); const { mediaItems } = useMediaStore();
const [backgroundType, setBackgroundType] = useState< const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur");
"blur" | "mirror" | "color"
>("blur");
const [backgroundColor, setBackgroundColor] = useState("#000000"); const [backgroundColor, setBackgroundColor] = useState("#000000");
// Get the first video clip for preview (simplified) // Get the first video clip for preview (simplified)
@ -78,7 +77,9 @@ export function PropertiesPanel() {
<Label htmlFor="bg-type">Background Type</Label> <Label htmlFor="bg-type">Background Type</Label>
<Select <Select
value={backgroundType} value={backgroundType}
onValueChange={(value: any) => setBackgroundType(value)} onValueChange={(value: BackgroundType) =>
setBackgroundType(value)
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select background type" /> <SelectValue placeholder="Select background type" />

View File

@ -0,0 +1,380 @@
"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,219 @@
"use client";
import type { TrackType } from "@/types/timeline";
import {
ArrowLeftToLine,
ArrowRightToLine,
Copy,
Pause,
Play,
Scissors,
Snowflake,
SplitSquareHorizontal,
Trash2,
} from "lucide-react";
import { Button } from "../ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
interface TimelineToolbarProps {
isPlaying: boolean;
currentTime: number;
duration: number;
speed: number;
tracks: any[];
toggle: () => void;
setSpeed: (speed: number) => void;
addTrack: (type: TrackType) => string;
addClipToTrack: (trackId: string, clip: any) => void;
handleSplitSelected: () => void;
handleDuplicateSelected: () => void;
handleFreezeSelected: () => void;
handleDeleteSelected: () => void;
}
export function TimelineToolbar({
isPlaying,
currentTime,
duration,
speed,
tracks,
toggle,
setSpeed,
addTrack,
addClipToTrack,
handleSplitSelected,
handleDuplicateSelected,
handleFreezeSelected,
handleDeleteSelected,
}: TimelineToolbarProps) {
return (
<div className="border-b flex items-center px-2 py-1 gap-1">
<TooltipProvider delayDuration={500}>
{/* Play/Pause Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={toggle}
className="mr-2"
>
{isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isPlaying ? "Pause (Space)" : "Play (Space)"}
</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
{/* Time Display */}
<div
className="text-xs text-muted-foreground font-mono px-2"
style={{ minWidth: "18ch", textAlign: "center" }}
>
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
</div>
{/* Test Clip Button - for debugging */}
{tracks.length === 0 && (
<>
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => {
const trackId = addTrack("video");
addClipToTrack(trackId, {
mediaId: "test",
name: "Test Clip",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
});
}}
className="text-xs"
>
Add Test Clip
</Button>
</TooltipTrigger>
<TooltipContent>Add a test clip to try playback</TooltipContent>
</Tooltip>
</>
)}
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleSplitSelected}>
<Scissors className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split clip (S)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon">
<ArrowLeftToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep left (A)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon">
<ArrowRightToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep right (D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon">
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Separate audio (E)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleDuplicateSelected}
>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicate clip (Ctrl+D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleFreezeSelected}>
<Snowflake className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Freeze frame (F)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleDeleteSelected}>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete clip (Delete)</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
{/* Speed Control */}
<Tooltip>
<TooltipTrigger asChild>
<Select
value={speed.toFixed(1)}
onValueChange={(value) => setSpeed(parseFloat(value))}
>
<SelectTrigger className="w-[90px] h-8">
<SelectValue placeholder="1.0x" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.5">0.5x</SelectItem>
<SelectItem value="1.0">1.0x</SelectItem>
<SelectItem value="1.5">1.5x</SelectItem>
<SelectItem value="2.0">2.0x</SelectItem>
</SelectContent>
</Select>
</TooltipTrigger>
<TooltipContent>Playback Speed</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,124 @@
"use client";
import { motion } from "motion/react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { RiGithubLine, RiTwitterXLine } from "react-icons/ri";
import { getStars } from "@/lib/fetchGhStars";
import Image from "next/image";
export function Footer() {
const [star, setStar] = useState<string>();
useEffect(() => {
const fetchStars = async () => {
try {
const data = await getStars();
setStar(data);
} catch (err) {
console.error("Failed to fetch GitHub stars", err);
}
};
fetchStars();
}, []);
return (
<motion.footer
className="bg-background/80 backdrop-blur-sm border mt-16 m-6 rounded-sm"
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="grid grid-cols-1 md:grid-cols-2 gap-12 mb-8">
{/* Brand Section */}
<div className="md:col-span-1 max-w-sm">
<div className="flex items-center gap-2 mb-4">
<Image src="/logo.svg" alt="OpenCut" width={24} height={24} />
<span className="font-bold text-lg">OpenCut</span>
</div>
<p className="text-sm text-muted-foreground mb-5">
The open source video editor that gets the job done. Simple,
powerful, and works on any platform.
</p>
<div className="flex gap-3">
<Link
href="https://github.com/OpenCut-app/OpenCut"
className="text-muted-foreground hover:text-foreground transition-colors"
target="_blank"
rel="noopener noreferrer"
>
<RiGithubLine className="h-5 w-5" />
</Link>
<Link
href="https://x.com/OpenCutApp"
className="text-muted-foreground hover:text-foreground transition-colors"
target="_blank"
rel="noopener noreferrer"
>
<RiTwitterXLine className="h-5 w-5" />
</Link>
</div>
</div>
<div className="flex gap-12 justify-end items-start py-2">
<div>
<h3 className="font-semibold text-foreground mb-4">Resources</h3>
<ul className="space-y-2 text-sm">
<li>
<Link
href="/privacy"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Privacy policy
</Link>
</li>
<li>
<Link
href="/terms"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Terms of use
</Link>
</li>
</ul>
</div>
{/* Company Links */}
<div>
<h3 className="font-semibold text-foreground mb-4">Company</h3>
<ul className="space-y-2 text-sm">
<li>
<Link
href="/contributors"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Contributors
</Link>
</li>
<li>
<Link
href="https://github.com/OpenCut-app/OpenCut/blob/main/README.md"
className="text-muted-foreground hover:text-foreground transition-colors"
target="_blank"
rel="noopener noreferrer"
>
About
</Link>
</li>
</ul>
</div>
</div>
</div>
{/* Bottom Section */}
<div className="pt-2 flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>© 2025 OpenCut, All Rights Reserved</span>
</div>
</div>
</div>
</motion.footer>
);
}

View File

@ -29,7 +29,7 @@ export function HeaderBase({
return ( return (
<header <header
className={cn("px-6 h-16 flex justify-between items-center", className)} className={cn("px-6 h-14 flex justify-between items-center", className)}
> >
{leftContent && <div className="flex items-center">{leftContent}</div>} {leftContent && <div className="flex items-center">{leftContent}</div>}
{centerContent && ( {centerContent && (

View File

@ -1,14 +1,13 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { HeaderBase } from "./header-base"; import { HeaderBase } from "./header-base";
import { useSession } from "@opencut/auth/client"; import { useSession } from "@opencut/auth/client";
import { getStars } from "@/lib/fetchGhStars"; import { getStars } from "@/lib/fetchGhStars";
import { Star } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Image from "next/image";
export function Header() { export function Header() {
const { data: session } = useSession(); const { data: session } = useSession();
@ -29,26 +28,43 @@ export function Header() {
const leftContent = ( const leftContent = (
<Link href="/" className="flex items-center gap-3"> <Link href="/" className="flex items-center gap-3">
<Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} /> <Image src="/logo.svg" alt="OpenCut Logo" width={32} height={32} />
<span className="font-medium tracking-tight">OpenCut</span> <span className="text-xl font-medium hidden md:block">OpenCut</span>
</Link> </Link>
); );
const rightContent = ( const rightContent = (
<nav className="flex items-center"> <nav className="flex items-center gap-3">
<Link href="/contributors"> <Link href="/contributors">
<Button variant="text" className="text-sm"> <Button variant="text" className="text-sm p-0">
Contributors Contributors
</Button> </Button>
</Link> </Link>
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank"> {process.env.NODE_ENV === "development" ? (
<Button size="sm" className="text-sm ml-4"> <Link href="/editor">
GitHub <Button size="sm" className="text-sm ml-4">
<ArrowRight className="h-4 w-4" /> Editor
</Button> <ArrowRight className="h-4 w-4" />
</Link> </Button>
</Link>
) : (
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
<Button size="sm" className="text-sm ml-4">
GitHub {star}+
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
)}
</nav> </nav>
); );
return <HeaderBase leftContent={leftContent} rightContent={rightContent} />; return (
<div className="mx-4 md:mx-0">
<HeaderBase
className="bg-[#1D1D1D] border border-white/10 rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
leftContent={leftContent}
rightContent={rightContent}
/>
</div>
);
} }

View File

@ -29,7 +29,7 @@ export function GithubIcon({ className }: { className?: string }) {
viewBox="0 -3.5 256 256" viewBox="0 -3.5 256 256"
preserveAspectRatio="xMinYMin meet" preserveAspectRatio="xMinYMin meet"
> >
<g fill="#161614"> <g fill="currentColor">
<path d="M127.505 0C57.095 0 0 57.085 0 127.505c0 56.336 36.534 104.13 87.196 120.99 6.372 1.18 8.712-2.766 8.712-6.134 0-3.04-.119-13.085-.173-23.739-35.473 7.713-42.958-15.044-42.958-15.044-5.8-14.738-14.157-18.656-14.157-18.656-11.568-7.914.872-7.752.872-7.752 12.804.9 19.546 13.14 19.546 13.14 11.372 19.493 29.828 13.857 37.104 10.6 1.144-8.242 4.449-13.866 8.095-17.05-28.32-3.225-58.092-14.158-58.092-63.014 0-13.92 4.981-25.295 13.138-34.224-1.324-3.212-5.688-16.18 1.235-33.743 0 0 10.707-3.427 35.073 13.07 10.17-2.826 21.078-4.242 31.914-4.29 10.836.048 21.752 1.464 31.942 4.29 24.337-16.497 35.029-13.07 35.029-13.07 6.94 17.563 2.574 30.531 1.25 33.743 8.175 8.929 13.122 20.303 13.122 34.224 0 48.972-29.828 59.756-58.22 62.912 4.573 3.957 8.648 11.717 8.648 23.612 0 17.06-.148 30.791-.148 34.991 0 3.393 2.295 7.369 8.759 6.117 50.634-16.879 87.122-64.656 87.122-120.973C255.009 57.085 197.922 0 127.505 0" /> <path d="M127.505 0C57.095 0 0 57.085 0 127.505c0 56.336 36.534 104.13 87.196 120.99 6.372 1.18 8.712-2.766 8.712-6.134 0-3.04-.119-13.085-.173-23.739-35.473 7.713-42.958-15.044-42.958-15.044-5.8-14.738-14.157-18.656-14.157-18.656-11.568-7.914.872-7.752.872-7.752 12.804.9 19.546 13.14 19.546 13.14 11.372 19.493 29.828 13.857 37.104 10.6 1.144-8.242 4.449-13.866 8.095-17.05-28.32-3.225-58.092-14.158-58.092-63.014 0-13.92 4.981-25.295 13.138-34.224-1.324-3.212-5.688-16.18 1.235-33.743 0 0 10.707-3.427 35.073 13.07 10.17-2.826 21.078-4.242 31.914-4.29 10.836.048 21.752 1.464 31.942 4.29 24.337-16.497 35.029-13.07 35.029-13.07 6.94 17.563 2.574 30.531 1.25 33.743 8.175 8.929 13.122 20.303 13.122 34.224 0 48.972-29.828 59.756-58.22 62.912 4.573 3.957 8.648 11.717 8.648 23.612 0 17.06-.148 30.791-.148 34.991 0 3.393 2.295 7.369 8.759 6.117 50.634-16.879 87.122-64.656 87.122-120.973C255.009 57.085 197.922 0 127.505 0" />
<path d="M47.755 181.634c-.28.633-1.278.823-2.185.389-.925-.416-1.445-1.28-1.145-1.916.275-.652 1.273-.834 2.196-.396.927.415 1.455 1.287 1.134 1.923M54.027 187.23c-.608.564-1.797.302-2.604-.589-.834-.889-.99-2.077-.373-2.65.627-.563 1.78-.3 2.616.59.834.899.996 2.08.36 2.65M58.33 194.39c-.782.543-2.06.034-2.849-1.1-.781-1.133-.781-2.493.017-3.038.792-.545 2.05-.055 2.85 1.07.78 1.153.78 2.513-.019 3.069M65.606 202.683c-.699.77-2.187.564-3.277-.488-1.114-1.028-1.425-2.487-.724-3.258.707-.772 2.204-.555 3.302.488 1.107 1.026 1.445 2.496.7 3.258M75.01 205.483c-.307.998-1.741 1.452-3.185 1.028-1.442-.437-2.386-1.607-2.095-2.616.3-1.005 1.74-1.478 3.195-1.024 1.44.435 2.386 1.596 2.086 2.612M85.714 206.67c.036 1.052-1.189 1.924-2.705 1.943-1.525.033-2.758-.818-2.774-1.852 0-1.062 1.197-1.926 2.721-1.951 1.516-.03 2.758.815 2.758 1.86M96.228 206.267c.182 1.026-.872 2.08-2.377 2.36-1.48.27-2.85-.363-3.039-1.38-.184-1.052.89-2.105 2.367-2.378 1.508-.262 2.857.355 3.049 1.398" /> <path d="M47.755 181.634c-.28.633-1.278.823-2.185.389-.925-.416-1.445-1.28-1.145-1.916.275-.652 1.273-.834 2.196-.396.927.415 1.455 1.287 1.134 1.923M54.027 187.23c-.608.564-1.797.302-2.604-.589-.834-.889-.99-2.077-.373-2.65.627-.563 1.78-.3 2.616.59.834.899.996 2.08.36 2.65M58.33 194.39c-.782.543-2.06.034-2.849-1.1-.781-1.133-.781-2.493.017-3.038.792-.545 2.05-.055 2.85 1.07.78 1.153.78 2.513-.019 3.069M65.606 202.683c-.699.77-2.187.564-3.277-.488-1.114-1.028-1.425-2.487-.724-3.258.707-.772 2.204-.555 3.302.488 1.107 1.026 1.445 2.496.7 3.258M75.01 205.483c-.307.998-1.741 1.452-3.185 1.028-1.442-.437-2.386-1.607-2.095-2.616.3-1.005 1.74-1.478 3.195-1.024 1.44.435 2.386 1.596 2.086 2.612M85.714 206.67c.036 1.052-1.189 1.924-2.705 1.943-1.525.033-2.758-.818-2.774-1.852 0-1.062 1.197-1.926 2.721-1.951 1.516-.03 2.758.815 2.758 1.86M96.228 206.267c.182 1.026-.872 2.08-2.377 2.36-1.48.27-2.85-.363-3.039-1.38-.184-1.052.89-2.105 2.367-2.378 1.508-.262 2.857.355 3.049 1.398" />

View File

@ -1,175 +1,153 @@
"use client"; "use client";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useState } from "react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { getStars } from "@/lib/fetchGhStars";
import Image from "next/image";
interface HeroProps {
signupCount: number; interface HeroProps {
} signupCount: number;
}
export function Hero({ signupCount }: HeroProps) {
const [star, setStar] = useState<string>(); export function Hero({ signupCount }: HeroProps) {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
useEffect(() => { const handleSubmit = async (e: React.FormEvent) => {
const fetchStars = async () => { e.preventDefault();
try {
const data = await getStars(); if (!email.trim()) {
setStar(data); toast({
} catch (err) { title: "Email required",
console.error("Failed to fetch GitHub stars", err); description: "Please enter your email address.",
} variant: "destructive",
}; });
return;
fetchStars(); }
}, []);
setIsSubmitting(true);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); try {
const response = await fetch("/api/waitlist", {
if (!email.trim()) { method: "POST",
toast({ headers: {
title: "Email required", "Content-Type": "application/json",
description: "Please enter your email address.", },
variant: "destructive", body: JSON.stringify({ email: email.trim() }),
}); });
return;
} const data = await response.json();
setIsSubmitting(true); if (response.ok) {
toast({
try { title: "Welcome to the waitlist! 🎉",
const response = await fetch("/api/waitlist", { description: "You'll be notified when we launch.",
method: "POST", });
headers: { setEmail("");
"Content-Type": "application/json", } else {
}, toast({
body: JSON.stringify({ email: email.trim() }), title: "Oops!",
}); description: data.error || "Something went wrong. Please try again.",
variant: "destructive",
const data = await response.json(); });
}
if (response.ok) { } catch (error) {
toast({ toast({
title: "Welcome to the waitlist! 🎉", title: "Network error",
description: "You'll be notified when we launch.", description: "Please check your connection and try again.",
}); variant: "destructive",
setEmail(""); });
} else { } finally {
toast({ setIsSubmitting(false);
title: "Oops!", }
description: data.error || "Something went wrong. Please try again.", };
variant: "destructive",
}); 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">
} catch (error) { <motion.div
toast({ initial={{ opacity: 0 }}
title: "Network error", animate={{ opacity: 1 }}
description: "Please check your connection and try again.", transition={{ duration: 1 }}
variant: "destructive", className="max-w-3xl mx-auto w-full flex-1 flex flex-col justify-center"
}); >
} finally { <motion.div
setIsSubmitting(false); initial={{ opacity: 0, y: 20 }}
} animate={{ opacity: 1, y: 0 }}
}; transition={{ delay: 0.2, duration: 0.8 }}
className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
return ( >
<div className="min-h-[calc(100vh-4rem)] flex flex-col justify-between items-center text-center px-4"> <h1>The Open Source</h1>
<motion.div <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
initial={{ opacity: 0 }} <div className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2">
animate={{ opacity: 1 }} <Image src="/frame.svg" height={79} width={459} alt="frame" />
transition={{ duration: 1 }} <span className="absolute inset-0 flex items-center justify-center">
className="max-w-3xl mx-auto w-full flex-1 flex flex-col justify-center" Video Editor
> </span>
<motion.div </div>
initial={{ opacity: 0, y: 20 }} </div>
animate={{ opacity: 1, y: 0 }} </motion.div>
transition={{ delay: 0.2, duration: 0.8 }}
className="inline-block" <motion.p
> className="mt-10 text-base sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto"
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter"> initial={{ opacity: 0 }}
The open source animate={{ opacity: 1 }}
</h1> transition={{ delay: 0.4, duration: 0.8 }}
<h1 className="text-5xl sm:text-7xl font-bold tracking-tighter mt-2"> >
video editor A simple but powerful video editor that gets the job done. Works on
</h1> any platform.
</motion.div> </motion.p>
<motion.p <motion.div
className="mt-10 text-lg sm:text-xl text-muted-foreground font-light tracking-wide max-w-xl mx-auto" className="mt-12 flex gap-8 justify-center"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.4, duration: 0.8 }} transition={{ delay: 0.6, duration: 0.8 }}
> >
A simple but powerful video editor that gets the job done. Works on <form
any platform. onSubmit={handleSubmit}
</motion.p> className="flex gap-3 w-full max-w-lg flex-col sm:flex-row"
>
<motion.div <Input
className="mt-12 flex gap-8 justify-center" type="email"
initial={{ opacity: 0 }} placeholder="Enter your email"
animate={{ opacity: 1 }} className="h-11 text-base flex-1"
transition={{ delay: 0.6, duration: 0.8 }} value={email}
> onChange={(e) => setEmail(e.target.value)}
<form onSubmit={handleSubmit} className="flex gap-3 w-full max-w-lg"> disabled={isSubmitting}
<Input required
type="email" />
placeholder="Enter your email" <Button
className="h-11 text-base flex-1" type="submit"
value={email} size="lg"
onChange={(e) => setEmail(e.target.value)} className="px-6 h-11 text-base"
disabled={isSubmitting} disabled={isSubmitting}
required >
/> <span className="relative z-10">
<Button {isSubmitting ? "Joining..." : "Join waitlist"}
type="submit" </span>
size="lg" <ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" />
className="px-6 h-11 text-base" </Button>
disabled={isSubmitting} </form>
> </motion.div>
<span className="relative z-10">
{isSubmitting ? "Joining..." : "Join waitlist"} {signupCount > 0 && (
</span> <motion.div
<ArrowRight className="relative z-10 ml-0.5 h-4 w-4 inline-block" /> initial={{ opacity: 0, y: 10 }}
</Button> animate={{ opacity: 1, y: 0 }}
</form> transition={{ delay: 0.8, duration: 0.6 }}
</motion.div> className="mt-8 inline-flex items-center gap-2 text-sm text-muted-foreground justify-center"
>
{signupCount > 0 && ( <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<motion.div <span>{signupCount.toLocaleString()} people already joined</span>
initial={{ opacity: 0, y: 10 }} </motion.div>
animate={{ opacity: 1, y: 0 }} )}
transition={{ delay: 0.8, duration: 0.6 }} </motion.div>
className="mt-8 inline-flex items-center gap-2 bg-muted/30 px-4 py-2 rounded-full text-sm text-muted-foreground justify-center" </div>
> );
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" /> }
<span>{signupCount.toLocaleString()} people already joined</span>
</motion.div>
)}
</motion.div>
<motion.div
className="mb-8 text-center text-sm text-muted-foreground/60"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8, duration: 0.8 }}
>
Currently in beta Open source on{" "}
<Link
href="https://github.com/OpenCut-app/OpenCut"
className="text-foreground underline"
>
GitHub {star}+
</Link>
</motion.div>
</div>
);
}

View File

@ -2,13 +2,14 @@
import { useState } from "react"; import { useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { BackgroundType } from "@/types/editor";
interface ImageTimelineTreatmentProps { interface ImageTimelineTreatmentProps {
src: string; src: string;
alt: string; alt: string;
targetAspectRatio?: number; // Default to 16:9 for video targetAspectRatio?: number; // Default to 16:9 for video
className?: string; className?: string;
backgroundType?: "blur" | "mirror" | "color"; backgroundType?: BackgroundType;
backgroundColor?: string; backgroundColor?: string;
} }

View File

@ -4,108 +4,128 @@ import { useRef, useEffect } from "react";
import { usePlaybackStore } from "@/stores/playback-store"; import { usePlaybackStore } from "@/stores/playback-store";
interface VideoPlayerProps { interface VideoPlayerProps {
src: string; src: string;
poster?: string; poster?: string;
className?: string; className?: string;
clipStartTime: number; clipStartTime: number;
trimStart: number; trimStart: number;
trimEnd: number; trimEnd: number;
clipDuration: number; clipDuration: number;
} }
export function VideoPlayer({ export function VideoPlayer({
src, src,
poster, poster,
className = "", className = "",
clipStartTime, clipStartTime,
trimStart, trimStart,
trimEnd, trimEnd,
clipDuration clipDuration,
}: VideoPlayerProps) { }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const { isPlaying, currentTime, volume, speed } = usePlaybackStore(); const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
// Calculate if we're within this clip's timeline range // Calculate if we're within this clip's timeline range
const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd); const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
const isInClipRange = currentTime >= clipStartTime && currentTime < clipEndTime; const isInClipRange =
currentTime >= clipStartTime && currentTime < clipEndTime;
// Sync playback events // Sync playback events
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
if (!video || !isInClipRange) return; if (!video || !isInClipRange) return;
const handleSeekEvent = (e: CustomEvent) => { const handleSeekEvent = (e: CustomEvent) => {
// Always update video time, even if outside clip range // Always update video time, even if outside clip range
const timelineTime = e.detail.time; const timelineTime = e.detail.time;
const videoTime = Math.max(trimStart, Math.min( const videoTime = Math.max(
clipDuration - trimEnd, trimStart,
timelineTime - clipStartTime + trimStart Math.min(
)); clipDuration - trimEnd,
video.currentTime = videoTime; timelineTime - clipStartTime + trimStart
}; )
);
video.currentTime = videoTime;
};
const handleUpdateEvent = (e: CustomEvent) => { const handleUpdateEvent = (e: CustomEvent) => {
// Always update video time, even if outside clip range // Always update video time, even if outside clip range
const timelineTime = e.detail.time; const timelineTime = e.detail.time;
const targetTime = Math.max(trimStart, Math.min( const targetTime = Math.max(
clipDuration - trimEnd, trimStart,
timelineTime - clipStartTime + trimStart Math.min(
)); clipDuration - trimEnd,
timelineTime - clipStartTime + trimStart
)
);
if (Math.abs(video.currentTime - targetTime) > 0.5) { if (Math.abs(video.currentTime - targetTime) > 0.5) {
video.currentTime = targetTime; video.currentTime = targetTime;
} }
}; };
const handleSpeed = (e: CustomEvent) => { const handleSpeed = (e: CustomEvent) => {
video.playbackRate = e.detail.speed; video.playbackRate = e.detail.speed;
}; };
window.addEventListener("playback-seek", handleSeekEvent as EventListener); window.addEventListener("playback-seek", handleSeekEvent as EventListener);
window.addEventListener("playback-update", handleUpdateEvent as EventListener); window.addEventListener(
window.addEventListener("playback-speed", handleSpeed as EventListener); "playback-update",
handleUpdateEvent 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 video = videoRef.current;
if (!video) return;
if (isPlaying && isInClipRange) {
video.play().catch(() => { });
} else {
video.pause();
}
}, [isPlaying, isInClipRange]);
// Sync volume and speed
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.volume = volume;
video.playbackRate = speed;
}, [volume, speed]);
return (
<video
ref={videoRef}
src={src}
poster={poster}
className={`w-full h-full object-cover ${className}`}
playsInline
preload="auto"
controls={false}
disablePictureInPicture
disableRemotePlayback
style={{ pointerEvents: 'none' }}
onContextMenu={(e) => e.preventDefault()}
/>
); );
} 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 video = videoRef.current;
if (!video) return;
if (isPlaying && isInClipRange) {
video.play().catch(() => {});
} else {
video.pause();
}
}, [isPlaying, isInClipRange]);
// Sync volume and speed
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.volume = volume;
video.muted = muted;
video.playbackRate = speed;
}, [volume, speed, muted]);
return (
<video
ref={videoRef}
src={src}
poster={poster}
className={`w-full h-full object-cover ${className}`}
playsInline
preload="auto"
controls={false}
disablePictureInPicture
disableRemotePlayback
style={{ pointerEvents: "none" }}
onContextMenu={(e) => e.preventDefault()}
/>
);
}

View File

@ -0,0 +1,226 @@
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

@ -1,18 +1,174 @@
import { useEffect } from "react"; import { useEffect, useCallback } from "react";
import { usePlaybackStore } from "@/stores/playback-store"; import { usePlaybackStore } from "@/stores/playback-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { toast } from "sonner";
export function usePlaybackControls() { export const usePlaybackControls = () => {
const { toggle } = usePlaybackStore(); const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore();
const {
selectedClips,
tracks,
splitClip,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
} = useTimelineStore();
const handleSplitSelectedClip = useCallback(() => {
if (selectedClips.length !== 1) {
toast.error("Select exactly one clip to split");
return;
}
const { trackId, clipId } = selectedClips[0];
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
if (!clip) return;
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 selected clip");
return;
}
splitClip(trackId, clipId, currentTime);
toast.success("Clip split at playhead");
}, [selectedClips, tracks, currentTime, splitClip]);
const handleSplitAndKeepLeftCallback = useCallback(() => {
if (selectedClips.length !== 1) {
toast.error("Select exactly one clip");
return;
}
const { trackId, clipId } = selectedClips[0];
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
if (!clip) return;
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 selected clip");
return;
}
splitAndKeepLeft(trackId, clipId, currentTime);
toast.success("Split and kept left portion");
}, [selectedClips, tracks, currentTime, splitAndKeepLeft]);
const handleSplitAndKeepRightCallback = useCallback(() => {
if (selectedClips.length !== 1) {
toast.error("Select exactly one clip");
return;
}
const { trackId, clipId } = selectedClips[0];
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
if (!clip) return;
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 selected clip");
return;
}
splitAndKeepRight(trackId, clipId, currentTime);
toast.success("Split and kept right portion");
}, [selectedClips, tracks, currentTime, splitAndKeepRight]);
const handleSeparateAudioCallback = useCallback(() => {
if (selectedClips.length !== 1) {
toast.error("Select exactly one video clip to separate audio");
return;
}
const { trackId, clipId } = selectedClips[0];
const track = tracks.find((t) => t.id === trackId);
if (!track || track.type !== "video") {
toast.error("Select a video clip to separate audio");
return;
}
separateAudio(trackId, clipId);
toast.success("Audio separated to audio track");
}, [selectedClips, tracks, separateAudio]);
const handleKeyPress = useCallback(
(e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
) {
return;
}
switch (e.key) {
case " ":
e.preventDefault();
if (isPlaying) {
pause();
} else {
play();
}
break;
case "s":
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
handleSplitSelectedClip();
}
break;
case "q":
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
handleSplitAndKeepLeftCallback();
}
break;
case "w":
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
handleSplitAndKeepRightCallback();
}
break;
case "d":
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
handleSeparateAudioCallback();
}
break;
}
},
[
isPlaying,
play,
pause,
handleSplitSelectedClip,
handleSplitAndKeepLeftCallback,
handleSplitAndKeepRightCallback,
handleSeparateAudioCallback,
]
);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { document.addEventListener("keydown", handleKeyPress);
if (e.code === "Space" && e.target === document.body) { return () => document.removeEventListener("keydown", handleKeyPress);
e.preventDefault(); }, [handleKeyPress]);
toggle(); };
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [toggle]);
}

View File

@ -11,11 +11,15 @@ import {
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {} export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {}
export async function processMediaFiles( export async function processMediaFiles(
files: FileList | File[] files: FileList | File[],
onProgress?: (progress: number) => void
): Promise<ProcessedMediaItem[]> { ): Promise<ProcessedMediaItem[]> {
const fileArray = Array.from(files); const fileArray = Array.from(files);
const processedItems: ProcessedMediaItem[] = []; const processedItems: ProcessedMediaItem[] = [];
const total = fileArray.length;
let completed = 0;
for (const file of fileArray) { for (const file of fileArray) {
const fileType = getFileType(file); const fileType = getFileType(file);
@ -57,6 +61,15 @@ export async function processMediaFiles(
duration, duration,
aspectRatio, 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) { } catch (error) {
console.error("Error processing file:", file.name, error); console.error("Error processing file:", file.name, error);
toast.error(`Failed to process ${file.name}`); toast.error(`Failed to process ${file.name}`);

View File

@ -1,6 +1,5 @@
import { db } from "@opencut/db"; import { db, sql } from "@opencut/db";
import { waitlist } from "@opencut/db/schema"; import { waitlist } from "@opencut/db/schema";
import { sql } from "drizzle-orm";
export async function getWaitlistCount() { export async function getWaitlistCount() {
try { try {

View File

@ -8,9 +8,9 @@ interface PlaybackStore extends PlaybackState, PlaybackControls {
let playbackTimer: number | null = null; let playbackTimer: number | null = null;
const startTimer = (store: any) => { const startTimer = (store: () => PlaybackStore) => {
if (playbackTimer) cancelAnimationFrame(playbackTimer); if (playbackTimer) cancelAnimationFrame(playbackTimer);
// Use requestAnimationFrame for smoother updates // Use requestAnimationFrame for smoother updates
const updateTime = () => { const updateTime = () => {
const state = store(); const state = store();
@ -18,14 +18,22 @@ const startTimer = (store: any) => {
const now = performance.now(); const now = performance.now();
const delta = (now - lastUpdate) / 1000; // Convert to seconds const delta = (now - lastUpdate) / 1000; // Convert to seconds
lastUpdate = now; lastUpdate = now;
const newTime = state.currentTime + (delta * state.speed); const newTime = state.currentTime + delta * state.speed;
if (newTime >= state.duration) { if (newTime >= state.duration) {
// When video completes, pause and reset playhead to start
state.pause(); state.pause();
state.setCurrentTime(0);
// Notify video elements to sync with reset
window.dispatchEvent(
new CustomEvent("playback-seek", { detail: { time: 0 } })
);
} else { } else {
state.setCurrentTime(newTime); state.setCurrentTime(newTime);
// Notify video elements to sync // Notify video elements to sync
window.dispatchEvent(new CustomEvent('playback-update', { detail: { time: newTime } })); window.dispatchEvent(
new CustomEvent("playback-update", { detail: { time: newTime } })
);
} }
} }
playbackTimer = requestAnimationFrame(updateTime); playbackTimer = requestAnimationFrame(updateTime);
@ -47,6 +55,8 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
currentTime: 0, currentTime: 0,
duration: 0, duration: 0,
volume: 1, volume: 1,
muted: false,
previousVolume: 1,
speed: 1.0, speed: 1.0,
play: () => { play: () => {
@ -72,22 +82,53 @@ export const usePlaybackStore = create<PlaybackStore>((set, get) => ({
const { duration } = get(); const { duration } = get();
const clampedTime = Math.max(0, Math.min(duration, time)); const clampedTime = Math.max(0, Math.min(duration, time));
set({ currentTime: clampedTime }); set({ currentTime: clampedTime });
// Notify video elements to seek const event = new CustomEvent("playback-seek", {
const event = new CustomEvent('playback-seek', { detail: { time: clampedTime } }); detail: { time: clampedTime },
});
window.dispatchEvent(event); window.dispatchEvent(event);
}, },
setVolume: (volume: number) => set({ volume: Math.max(0, Math.min(1, volume)) }), setVolume: (volume: number) =>
set((state) => ({
volume: Math.max(0, Math.min(1, volume)),
muted: volume === 0,
previousVolume: volume > 0 ? volume : state.previousVolume,
})),
setSpeed: (speed: number) => { setSpeed: (speed: number) => {
const newSpeed = Math.max(0.1, Math.min(2.0, speed)); const newSpeed = Math.max(0.1, Math.min(2.0, speed));
set({ speed: newSpeed }); set({ speed: newSpeed });
const event = new CustomEvent('playback-speed', { detail: { speed: newSpeed } }); const event = new CustomEvent("playback-speed", {
detail: { speed: newSpeed },
});
window.dispatchEvent(event); window.dispatchEvent(event);
}, },
setDuration: (duration: number) => set({ duration }), setDuration: (duration: number) => set({ duration }),
setCurrentTime: (time: number) => set({ currentTime: time }), setCurrentTime: (time: number) => set({ currentTime: time }),
}));
mute: () => {
const { volume, previousVolume } = get();
set({
muted: true,
previousVolume: volume > 0 ? volume : previousVolume,
volume: 0,
});
},
unmute: () => {
const { previousVolume } = get();
set({ muted: false, volume: previousVolume ?? 1 });
},
toggleMute: () => {
const { muted } = get();
if (muted) {
get().unmute();
} else {
get().mute();
}
},
}));

View File

@ -7,6 +7,7 @@ interface ProjectStore {
// Actions // Actions
createNewProject: (name: string) => void; createNewProject: (name: string) => void;
closeProject: () => void; closeProject: () => void;
updateProjectName: (name: string) => void;
} }
export const useProjectStore = create<ProjectStore>((set) => ({ export const useProjectStore = create<ProjectStore>((set) => ({
@ -25,4 +26,16 @@ export const useProjectStore = create<ProjectStore>((set) => ({
closeProject: () => { closeProject: () => {
set({ activeProject: null }); set({ activeProject: null });
}, },
updateProjectName: (name: string) => {
set((state) => ({
activeProject: state.activeProject
? {
...state.activeProject,
name,
updatedAt: new Date(),
}
: null,
}));
},
})); }));

View File

@ -1,264 +1,567 @@
import { create } from "zustand"; import { create } from "zustand";
import type { TrackType } from "@/types/timeline";
export interface TimelineClip {
id: string; // Helper function to manage clip naming with suffixes
mediaId: string; const getClipNameWithSuffix = (
name: string; originalName: string,
duration: number; suffix: string
startTime: number; ): string => {
trimStart: number; // Remove existing suffixes to prevent accumulation
trimEnd: number; const baseName = originalName
} .replace(/ \(left\)$/, "")
.replace(/ \(right\)$/, "")
export interface TimelineTrack { .replace(/ \(audio\)$/, "")
id: string; .replace(/ \(split \d+\)$/, "");
name: string;
type: "video" | "audio" | "effects"; return `${baseName} (${suffix})`;
clips: TimelineClip[]; };
muted?: boolean;
} export interface TimelineClip {
id: string;
interface TimelineStore { mediaId: string;
tracks: TimelineTrack[]; name: string;
history: TimelineTrack[][]; duration: number;
redoStack: TimelineTrack[][]; startTime: number;
trimStart: number;
// Multi-selection trimEnd: number;
selectedClips: { trackId: string; clipId: string }[]; }
selectClip: (trackId: string, clipId: string, multi?: boolean) => void;
deselectClip: (trackId: string, clipId: string) => void; export interface TimelineTrack {
clearSelectedClips: () => void; id: string;
setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void; name: string;
type: TrackType;
// Actions clips: TimelineClip[];
addTrack: (type: "video" | "audio" | "effects") => string; muted?: boolean;
removeTrack: (trackId: string) => void; }
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
removeClipFromTrack: (trackId: string, clipId: string) => void; interface TimelineStore {
moveClipToTrack: ( tracks: TimelineTrack[];
fromTrackId: string, history: TimelineTrack[][];
toTrackId: string, redoStack: TimelineTrack[][];
clipId: string
) => void; // Multi-selection
updateClipTrim: ( selectedClips: { trackId: string; clipId: string }[];
trackId: string, selectClip: (trackId: string, clipId: string, multi?: boolean) => void;
clipId: string, deselectClip: (trackId: string, clipId: string) => void;
trimStart: number, clearSelectedClips: () => void;
trimEnd: number setSelectedClips: (clips: { trackId: string; clipId: string }[]) => void;
) => void;
updateClipStartTime: ( // Drag state
trackId: string, dragState: {
clipId: string, isDragging: boolean;
startTime: number clipId: string | null;
) => void; trackId: string | null;
toggleTrackMute: (trackId: string) => void; startMouseX: number;
startClipTime: number;
// Computed values clickOffsetTime: number;
getTotalDuration: () => number; currentTime: number;
};
// New actions setDragState: (dragState: Partial<TimelineStore["dragState"]>) => void;
undo: () => void; startDrag: (
redo: () => void; clipId: string,
pushHistory: () => void; trackId: string,
} startMouseX: number,
startClipTime: number,
export const useTimelineStore = create<TimelineStore>((set, get) => ({ clickOffsetTime: number
tracks: [], ) => void;
history: [], updateDragTime: (currentTime: number) => void;
redoStack: [], endDrag: () => void;
selectedClips: [],
// Actions
pushHistory: () => { addTrack: (type: TrackType) => string;
const { tracks, history, redoStack } = get(); removeTrack: (trackId: string) => void;
// Deep copy tracks addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
set({ removeClipFromTrack: (trackId: string, clipId: string) => void;
history: [...history, JSON.parse(JSON.stringify(tracks))], moveClipToTrack: (
redoStack: [] // Clear redo stack when new action is performed fromTrackId: string,
}); toTrackId: string,
}, clipId: string
) => void;
undo: () => { updateClipTrim: (
const { history, redoStack, tracks } = get(); trackId: string,
if (history.length === 0) return; clipId: string,
const prev = history[history.length - 1]; trimStart: number,
set({ trimEnd: number
tracks: prev, ) => void;
history: history.slice(0, -1), updateClipStartTime: (
redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))] // Add current state to redo stack trackId: string,
}); clipId: string,
}, startTime: number
) => void;
selectClip: (trackId, clipId, multi = false) => { toggleTrackMute: (trackId: string) => void;
set((state) => {
const exists = state.selectedClips.some( // Split operations for clips
(c) => c.trackId === trackId && c.clipId === clipId splitClip: (
); trackId: string,
if (multi) { clipId: string,
// Toggle selection splitTime: number
return exists ) => string | null;
? { selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)) } splitAndKeepLeft: (
: { selectedClips: [...state.selectedClips, { trackId, clipId }] }; trackId: string,
} else { clipId: string,
return { selectedClips: [{ trackId, clipId }] }; splitTime: number
} ) => void;
}); splitAndKeepRight: (
}, trackId: string,
deselectClip: (trackId, clipId) => { clipId: string,
set((state) => ({ splitTime: number
selectedClips: state.selectedClips.filter((c) => !(c.trackId === trackId && c.clipId === clipId)), ) => void;
})); separateAudio: (trackId: string, clipId: string) => string | null;
},
clearSelectedClips: () => { // Computed values
set({ selectedClips: [] }); getTotalDuration: () => number;
},
// History actions
setSelectedClips: (clips) => set({ selectedClips: clips }), undo: () => void;
redo: () => void;
addTrack: (type) => { pushHistory: () => void;
get().pushHistory(); }
const newTrack: TimelineTrack = {
id: crypto.randomUUID(), export const useTimelineStore = create<TimelineStore>((set, get) => ({
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`, tracks: [],
type, history: [],
clips: [], redoStack: [],
muted: false, selectedClips: [],
};
set((state) => ({ pushHistory: () => {
tracks: [...state.tracks, newTrack], const { tracks, history, redoStack } = get();
})); set({
return newTrack.id; history: [...history, JSON.parse(JSON.stringify(tracks))],
}, redoStack: [],
});
removeTrack: (trackId) => { },
get().pushHistory();
set((state) => ({ undo: () => {
tracks: state.tracks.filter((track) => track.id !== trackId), const { history, redoStack, tracks } = get();
})); if (history.length === 0) return;
}, const prev = history[history.length - 1];
set({
addClipToTrack: (trackId, clipData) => { tracks: prev,
get().pushHistory(); history: history.slice(0, -1),
const newClip: TimelineClip = { redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))],
...clipData, });
id: crypto.randomUUID(), },
startTime: clipData.startTime || 0,
trimStart: 0, selectClip: (trackId, clipId, multi = false) => {
trimEnd: 0, set((state) => {
}; const exists = state.selectedClips.some(
(c) => c.trackId === trackId && c.clipId === clipId
set((state) => ({ );
tracks: state.tracks.map((track) => if (multi) {
track.id === trackId return exists
? { ...track, clips: [...track.clips, newClip] } ? {
: track selectedClips: state.selectedClips.filter(
), (c) => !(c.trackId === trackId && c.clipId === clipId)
})); ),
}, }
: { selectedClips: [...state.selectedClips, { trackId, clipId }] };
removeClipFromTrack: (trackId, clipId) => { } else {
get().pushHistory(); return { selectedClips: [{ trackId, clipId }] };
set((state) => ({ }
tracks: state.tracks });
.map((track) => },
track.id === trackId
? { ...track, clips: track.clips.filter((clip) => clip.id !== clipId) } deselectClip: (trackId, clipId) => {
: track set((state) => ({
) selectedClips: state.selectedClips.filter(
// Remove track if it becomes empty (c) => !(c.trackId === trackId && c.clipId === clipId)
.filter((track) => track.clips.length > 0), ),
})); }));
}, },
moveClipToTrack: (fromTrackId, toTrackId, clipId) => { clearSelectedClips: () => {
get().pushHistory(); set({ selectedClips: [] });
set((state) => { },
const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId); setSelectedClips: (clips) => set({ selectedClips: clips }),
if (!clipToMove) return state; addTrack: (type) => {
get().pushHistory();
return { const newTrack: TimelineTrack = {
tracks: state.tracks id: crypto.randomUUID(),
.map((track) => { name: `${type.charAt(0).toUpperCase() + type.slice(1)} Track`,
if (track.id === fromTrackId) { type,
return { clips: [],
...track, muted: false,
clips: track.clips.filter((clip) => clip.id !== clipId), };
}; set((state) => ({
} else if (track.id === toTrackId) { tracks: [...state.tracks, newTrack],
return { }));
...track, return newTrack.id;
clips: [...track.clips, clipToMove], },
};
} removeTrack: (trackId) => {
return track; get().pushHistory();
}) set((state) => ({
// Remove track if it becomes empty tracks: state.tracks.filter((track) => track.id !== trackId),
.filter((track) => track.clips.length > 0), }));
}; },
});
}, addClipToTrack: (trackId, clipData) => {
get().pushHistory();
updateClipTrim: (trackId, clipId, trimStart, trimEnd) => { const newClip: TimelineClip = {
get().pushHistory(); ...clipData,
set((state) => ({ id: crypto.randomUUID(),
tracks: state.tracks.map((track) => startTime: clipData.startTime || 0,
track.id === trackId trimStart: 0,
? { trimEnd: 0,
...track, };
clips: track.clips.map((clip) =>
clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip set((state) => ({
), tracks: state.tracks.map((track) =>
} track.id === trackId
: track ? { ...track, clips: [...track.clips, newClip] }
), : track
})); ),
}, }));
},
updateClipStartTime: (trackId, clipId, startTime) => {
get().pushHistory(); removeClipFromTrack: (trackId, clipId) => {
set((state) => ({ get().pushHistory();
tracks: state.tracks.map((track) => set((state) => ({
track.id === trackId tracks: state.tracks
? { .map((track) =>
...track, track.id === trackId
clips: track.clips.map((clip) => ? {
clip.id === clipId ? { ...clip, startTime } : clip ...track,
), clips: track.clips.filter((clip) => clip.id !== clipId),
} }
: track : track
), )
})); .filter((track) => track.clips.length > 0),
}, }));
},
toggleTrackMute: (trackId) => {
get().pushHistory(); moveClipToTrack: (fromTrackId, toTrackId, clipId) => {
set((state) => ({ get().pushHistory();
tracks: state.tracks.map((track) => set((state) => {
track.id === trackId ? { ...track, muted: !track.muted } : track const fromTrack = state.tracks.find((track) => track.id === fromTrackId);
), const clipToMove = fromTrack?.clips.find((clip) => clip.id === clipId);
}));
}, if (!clipToMove) return state;
getTotalDuration: () => { return {
const { tracks } = get(); tracks: state.tracks
if (tracks.length === 0) return 0; .map((track) => {
if (track.id === fromTrackId) {
const trackEndTimes = tracks.map((track) => return {
track.clips.reduce((maxEnd, clip) => { ...track,
const clipEnd = clips: track.clips.filter((clip) => clip.id !== clipId),
clip.startTime + clip.duration - clip.trimStart - clip.trimEnd; };
return Math.max(maxEnd, clipEnd); } else if (track.id === toTrackId) {
}, 0) return {
); ...track,
clips: [...track.clips, clipToMove],
return Math.max(...trackEndTimes, 0); };
}, }
return track;
redo: () => { })
const { redoStack } = get(); .filter((track) => track.clips.length > 0),
if (redoStack.length === 0) return; };
const next = redoStack[redoStack.length - 1]; });
set({ tracks: next, redoStack: redoStack.slice(0, -1) }); },
},
})); updateClipTrim: (trackId, clipId, trimStart, trimEnd) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.map((clip) =>
clip.id === clipId ? { ...clip, trimStart, trimEnd } : clip
),
}
: track
),
}));
},
updateClipStartTime: (trackId, clipId, startTime) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.map((clip) =>
clip.id === clipId ? { ...clip, startTime } : clip
),
}
: track
),
}));
},
toggleTrackMute: (trackId) => {
get().pushHistory();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId ? { ...track, muted: !track.muted } : track
),
}));
},
splitClip: (trackId, clipId, splitTime) => {
const { tracks } = get();
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
if (!clip) return null;
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null;
get().pushHistory();
const relativeTime = splitTime - clip.startTime;
const firstDuration = relativeTime;
const secondDuration =
clip.duration - clip.trimStart - clip.trimEnd - relativeTime;
const secondClipId = crypto.randomUUID();
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.flatMap((c) =>
c.id === clipId
? [
{
...c,
trimEnd: c.trimEnd + secondDuration,
name: getClipNameWithSuffix(c.name, "left"),
},
{
...c,
id: secondClipId,
startTime: splitTime,
trimStart: c.trimStart + firstDuration,
name: getClipNameWithSuffix(c.name, "right"),
},
]
: [c]
),
}
: track
),
}));
return secondClipId;
},
// Split clip and keep only the left portion
splitAndKeepLeft: (trackId, clipId, splitTime) => {
const { tracks } = get();
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
if (!clip) return;
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
get().pushHistory();
const relativeTime = splitTime - clip.startTime;
const durationToRemove =
clip.duration - clip.trimStart - clip.trimEnd - relativeTime;
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.map((c) =>
c.id === clipId
? {
...c,
trimEnd: c.trimEnd + durationToRemove,
name: getClipNameWithSuffix(c.name, "left"),
}
: c
),
}
: track
),
}));
},
// Split clip and keep only the right portion
splitAndKeepRight: (trackId, clipId, splitTime) => {
const { tracks } = get();
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
if (!clip) return;
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
get().pushHistory();
const relativeTime = splitTime - clip.startTime;
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === trackId
? {
...track,
clips: track.clips.map((c) =>
c.id === clipId
? {
...c,
startTime: splitTime,
trimStart: c.trimStart + relativeTime,
name: getClipNameWithSuffix(c.name, "right"),
}
: c
),
}
: track
),
}));
},
// Extract audio from video clip to an audio track
separateAudio: (trackId, clipId) => {
const { tracks } = get();
const track = tracks.find((t) => t.id === trackId);
const clip = track?.clips.find((c) => c.id === clipId);
if (!clip || track?.type !== "video") return null;
get().pushHistory();
// Find existing audio track or prepare to create one
const existingAudioTrack = tracks.find((t) => t.type === "audio");
const audioClipId = crypto.randomUUID();
if (existingAudioTrack) {
// Add audio clip to existing audio track
set((state) => ({
tracks: state.tracks.map((track) =>
track.id === existingAudioTrack.id
? {
...track,
clips: [
...track.clips,
{
...clip,
id: audioClipId,
name: getClipNameWithSuffix(clip.name, "audio"),
},
],
}
: track
),
}));
} else {
// Create new audio track with the audio clip in a single atomic update
const newAudioTrack: TimelineTrack = {
id: crypto.randomUUID(),
name: "Audio Track",
type: "audio",
clips: [
{
...clip,
id: audioClipId,
name: getClipNameWithSuffix(clip.name, "audio"),
},
],
muted: false,
};
set((state) => ({
tracks: [...state.tracks, newAudioTrack],
}));
}
return audioClipId;
},
getTotalDuration: () => {
const { tracks } = get();
if (tracks.length === 0) return 0;
const trackEndTimes = tracks.map((track) =>
track.clips.reduce((maxEnd, clip) => {
const clipEnd =
clip.startTime + clip.duration - clip.trimStart - clip.trimEnd;
return Math.max(maxEnd, clipEnd);
}, 0)
);
return Math.max(...trackEndTimes, 0);
},
redo: () => {
const { redoStack } = get();
if (redoStack.length === 0) return;
const next = redoStack[redoStack.length - 1];
set({ tracks: next, redoStack: redoStack.slice(0, -1) });
},
dragState: {
isDragging: false,
clipId: null,
trackId: null,
startMouseX: 0,
startClipTime: 0,
clickOffsetTime: 0,
currentTime: 0,
},
setDragState: (dragState) =>
set((state) => ({
dragState: { ...state.dragState, ...dragState },
})),
startDrag: (clipId, trackId, startMouseX, startClipTime, clickOffsetTime) => {
set({
dragState: {
isDragging: true,
clipId,
trackId,
startMouseX,
startClipTime,
clickOffsetTime,
currentTime: startClipTime,
},
});
},
updateDragTime: (currentTime) => {
set((state) => ({
dragState: {
...state.dragState,
currentTime,
},
}));
},
endDrag: () => {
set({
dragState: {
isDragging: false,
clipId: null,
trackId: null,
startMouseX: 0,
startClipTime: 0,
clickOffsetTime: 0,
currentTime: 0,
},
});
},
}));

View File

@ -0,0 +1 @@
export type BackgroundType = "blur" | "mirror" | "color";

View File

@ -4,6 +4,8 @@ export interface PlaybackState {
duration: number; duration: number;
volume: number; volume: number;
speed: number; speed: number;
muted: boolean;
previousVolume?: number;
} }
export interface PlaybackControls { export interface PlaybackControls {
@ -13,4 +15,7 @@ export interface PlaybackControls {
setVolume: (volume: number) => void; setVolume: (volume: number) => void;
setSpeed: (speed: number) => void; setSpeed: (speed: number) => void;
toggle: () => void; toggle: () => void;
} mute: () => void;
unmute: () => void;
toggleMute: () => void;
}

View File

@ -0,0 +1,29 @@
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;
}

View File

@ -69,7 +69,7 @@ export default {
borderRadius: { borderRadius: {
lg: "var(--radius)", lg: "var(--radius)",
md: "calc(var(--radius) - 2px)", md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)", sm: "calc(var(--radius) - 6px)",
}, },
keyframes: { keyframes: {
"accordion-down": { "accordion-down": {

View File

@ -4,6 +4,7 @@
"": { "": {
"dependencies": { "dependencies": {
"next": "^15.3.4", "next": "^15.3.4",
"wavesurfer.js": "^7.9.8",
}, },
"devDependencies": { "devDependencies": {
"turbo": "^2.5.4", "turbo": "^2.5.4",
@ -902,6 +903,8 @@
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"wavesurfer.js": ["wavesurfer.js@7.9.8", "", {}, "sha512-Mxz6qRwkSmuWVxLzp0XQ6EzSv1FTvQgMEUJTirLN1Ox76sn0YeyQlI99WuE+B0IuxShPHXIhvEuoBSJdaQs7tA=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "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=="], "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=="],

View File

@ -48,7 +48,7 @@ services:
start_period: 10s start_period: 10s
web: web:
build: build:
context: ./apps/web context: .
dockerfile: ./apps/web/Dockerfile dockerfile: ./apps/web/Dockerfile
restart: unless-stopped restart: unless-stopped
ports: ports:

View File

@ -1,13 +1,10 @@
[build] # Next.js plugin
base = "../.."
command = "bun install && bunx turbo build --filter=opencut"
publish = "apps/web/.next"
[[plugins]] [[plugins]]
package = "@netlify/plugin-nextjs" package = "@netlify/plugin-nextjs"
# Redirects for domain migration
[[redirects]] [[redirects]]
from = "https://appcut.app/*" from = "https://appcut.app/*"
to = "https://opencut.app/:splat" to = "https://opencut.app/:splat"
status = 301 status = 301
force = true force = true

View File

@ -16,6 +16,7 @@
"format": "turbo run format" "format": "turbo run format"
}, },
"dependencies": { "dependencies": {
"next": "^15.3.4" "next": "^15.3.4",
"wavesurfer.js": "^7.9.8"
} }
} }

View File

@ -16,7 +16,7 @@ export const auth = betterAuth({
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
}, },
socialProviders: { socialProviders: {
google: { google: {
clientId: process.env.GOOGLE_CLIENT_ID as string, clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
@ -26,4 +26,4 @@ export const auth = betterAuth({
trustedOrigins: ["http://localhost:3000"], trustedOrigins: ["http://localhost:3000"],
}); });
export type Auth = typeof auth; export type Auth = typeof auth;

View File

@ -2,15 +2,43 @@ import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres"; import postgres from "postgres";
import * as schema from "./schema"; import * as schema from "./schema";
if (!process.env.DATABASE_URL) { // Create a lazy database instance that only initializes when accessed
throw new Error("DATABASE_URL is not set"); let _db: ReturnType<typeof drizzle> | null = null;
function getDb() {
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is not set");
}
if (!_db) {
const client = postgres(process.env.DATABASE_URL);
_db = drizzle(client, { schema });
}
return _db;
} }
// Create the postgres client // Export a proxy that forwards all calls to the actual db instance
const client = postgres(process.env.DATABASE_URL); export const db = new Proxy({} as ReturnType<typeof drizzle>, {
get(target, prop) {
// Create the drizzle instance return getDb()[prop as keyof typeof _db];
export const db = drizzle(client, { schema }); },
});
// Re-export schema for convenience // Re-export schema for convenience
export * from "./schema"; export * from "./schema";
// Re-export drizzle-orm functions to ensure version consistency
export {
eq,
and,
or,
not,
isNull,
isNotNull,
inArray,
notInArray,
exists,
notExists,
sql,
} from "drizzle-orm";