Compare commits

..
Author SHA1 Message Date
Dominik K.andGitHub 0ba2893610 fix: hardcode blog url 2025-07-22 00:43:27 +02:00
MazeandGitHub 6399f1c902 Merge pull request #405 from mezotv/main
fix: typo in url
2025-07-22 00:39:20 +02:00
Dominik Koch e5c8df5555 fix: typo in url 2025-07-22 00:34:49 +02:00
MazeandGitHub 11b270ffd3 Merge pull request #404 from mezotv/fix/blog
fix: move to next_public keys
2025-07-22 00:30:54 +02:00
Dominik K.andGitHub 1b0dd48f3d Merge branch 'main' into fix/blog 2025-07-22 00:29:46 +02:00
Dominik Koch b10c8536fa fix: move to next_public keys 2025-07-22 00:27:23 +02:00
MazeandGitHub bd05f64511 Merge pull request #403 from mezotv/fix/readme
fix: marble env keys in readme
2025-07-22 00:10:34 +02:00
Dominik Koch fe2762904c fix: marble env keys 2025-07-22 00:07:04 +02:00
izaandGitHub 8c1ea7e680 Merge pull request #402 from mezotv/feat/opencut-blog
feat: add blog powered by marble
2025-07-22 00:59:57 +03:00
Dominik Koch ddf0353d92 fix: use default marble data 2025-07-21 23:50:21 +02:00
Dominik Koch 5ef6e2bc97 fix: implement coderabbit suggestions
They actually good wtf
2025-07-21 23:45:02 +02:00
Dominik Koch 7ea4b31626 fix: remove unused siteName 2025-07-21 23:31:00 +02:00
Dominik Koch e21c467489 feat: add opencut blog 2025-07-21 23:18:07 +02:00
Maze Winther 3446fdde87 Merge branch 'staging' 2025-07-21 18:44:27 +02:00
Maze Winther b360e8d985 Merge pull request #336 2025-07-21 18:42:47 +02:00
Maze Winther b87a62372d Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-07-21 18:17:02 +02:00
Maze Winther fb51cc2597 Merge branch 'staging' 2025-07-21 18:16:54 +02:00
Maze Winther ad771a0b56 Merge pull request [#336](https://github.com/mazeincoding/AppCut/issues/336) from mannix-lei/main 2025-07-21 18:00:48 +02:00
Maze Winther 5782df7350 remove toasts 2025-07-21 14:47:42 +02:00
Maze Winther a2a9bef1d8 fix: undo for resizing timeline elements 2025-07-21 14:46:28 +02:00
izaandGitHub 1b3d42b2a9 Merge pull request #389 from mezotv/fix/early-alpha
fix: switch to alpha
2025-07-21 15:31:18 +03:00
Dominik Koch 935ccea85e fix: switch to alpha 2025-07-21 14:27:27 +02:00
izaandGitHub 11e2e1a49d Merge pull request #379 from vagxrth/feature/better-github-icon
[REFACTOR]: replace GitHub icon with FontAwesome version in footer
2025-07-21 14:53:56 +03:00
Maze Winther aeb9cfc6b8 fix: AZERTY support for keyboard shortcuts 2025-07-21 12:23:09 +02:00
Maze Winther 4c2d8c52ab feat: basic audio view in media panel 2025-07-21 11:52:48 +02:00
Maze Winther e74e60d5bf style: height in media panel 2025-07-21 11:52:35 +02:00
Vagarth 15c57239f4 refactor: replace GitHub icon with FontAwesome version in footer 2025-07-21 07:01:31 +05:30
Maze Winther 263f14ef2a Merge branch 'staging' 2025-07-20 22:30:30 +02:00
Maze Winther 4896dce268 hero: cleanup and fix button to redirect 2025-07-20 21:48:47 +02:00
Maze Winther 07e068b658 feat: get hero ready for beta 2025-07-20 21:46:50 +02:00
Maze Winther efabec8bcb feat: make button on header consistent for beta 2025-07-20 21:44:20 +02:00
Maze Winther c7c394b2ef feat: onboarding 2025-07-20 21:42:02 +02:00
Maze Winther cac4fcaab8 styling: dialog responsiveness 2025-07-20 21:38:41 +02:00
Maze Winther ab12258432 feat: remove protection from middleware 2025-07-20 21:02:06 +02:00
Maze Winther ba3da43728 Merge branch 'staging' 2025-07-20 16:36:21 +02:00
Maze Winther baaf773b80 Merge branch 'staging' of https://github.com/mazeincoding/AppCut into staging 2025-07-20 16:13:49 +02:00
Maze Winther d7c9a2391c fix: import 2025-07-20 16:12:38 +02:00
izaandGitHub afa8f426fb Merge pull request #264 from Chubercik/optimal_pngs
Optimise PNGs across the repo
2025-07-20 11:31:43 +03:00
izaandGitHub 3c4d539a08 Merge pull request #278 from phamtanminhtien/chore/ignore-ds-store
chore: add .DS_Store to .gitignore
2025-07-20 11:27:34 +03:00
Hyteq be1ae20d90 format 2025-07-20 11:00:22 +03:00
Hyteq ede08dac41 Merge branch 'main' into staging 2025-07-20 10:50:28 +03:00
Hyteq fe020be6ce Revert "format, cleanup, deps"
This reverts commit eab8ec3ba8.
2025-07-20 10:50:17 +03:00
Hyteq eab8ec3ba8 format, cleanup, deps 2025-07-20 10:48:40 +03:00
Maze Winther 33d8caf65c Merge pull request #357 2025-07-19 21:03:13 +02:00
Maze Winther 24e13f692a feat: hello world 2025-07-19 20:51:14 +02:00
Maze Winther ac962d023c Merge pull request #279 2025-07-19 19:18:01 +02:00
Maze Winther 5266aed5eb feat: ripple editing mode on timeline 2025-07-19 19:08:08 +02:00
Maze Winther 42ae8a7530 fix: ensure dragging/resizing an element on the timeline doesn't deselect it 2025-07-19 16:19:06 +02:00
Maze Winther 0d95f032cc fix: wrong logic on snapping button 2025-07-19 16:14:48 +02:00
Maze Winther a9b7043829 refactor: improve timeline elements, fix few bugs 2025-07-18 18:05:15 +02:00
Maze Winther 6ce8434615 fix: element trimming, allow infinite trimStart for text/image elements 2025-07-18 12:52:11 +02:00
Maze Winther d98638bfe6 disable databuddy in development 2025-07-18 12:51:44 +02:00
Anwarul Islam 0e1311bfe0 feat: ripple editing support sync with latest timeline changes 2025-07-18 16:43:50 +06:00
Anwarul Islam 0b12fdfa0a feat: implement ripple-aware track removal and element deletion in timeline store 2025-07-18 16:43:50 +06:00
Anwarul Islam d5bc63357b feat: implement overlap checking and resolution for timeline elements during ripple editing 2025-07-18 16:43:50 +06:00
Anwarul Islam d440280f97 feat: add overlap checking and resolution functions for timeline elements 2025-07-18 16:43:50 +06:00
Anwarul Islam 63e07dc9ff feat: replace setRippleEditingEnabled with toggleRippleEditing method 2025-07-18 16:43:50 +06:00
Anwarul Islam e3e26a207f feat: enhance element deletion with ripple editing support 2025-07-18 16:43:50 +06:00
Anwarul Islam ffd82087c7 feat: integrate ripple editing into element start time updates 2025-07-18 16:43:50 +06:00
Anwarul Islam 53fe61f684 feat: implement ripple editing functionality in timeline component 2025-07-18 16:43:50 +06:00
Anwarul Islam 8b1c4de01b feat: add ripple editing functionality to timeline store 2025-07-18 16:43:50 +06:00
Maze Winther 85b6123510 fix: z-index of playhead 2025-07-18 11:54:32 +02:00
Maze Winther f8ccb673bf style: match editing experience to match capcut 2025-07-18 11:51:47 +02:00
Maze Winther 74e7d427aa Merge branch 'feat/shortcut-actions' into staging 2025-07-18 11:37:48 +02:00
MazeandGitHub d040ca53cb Merge pull request #342 from anwarulislam/feat/kbd
chore: keyboard shortcuts and improve key display
2025-07-18 11:29:36 +02:00
Maze Winther c4e6a9a45a Merge branch 'feat/kbd' into staging 2025-07-18 11:27:02 +02:00
Maze Winther b19db0bbea cleanup 2025-07-18 11:26:49 +02:00
Maze Winther dc5416da07 refactor: remove unused functions 2025-07-18 11:20:17 +02:00
Anwarul Islam 23001f8442 feat: define keybinding types and refactor keybinding configuration 2025-07-18 15:19:46 +06:00
Maze Winther f9bfc30959 Merge branch 'fix/new-project-has-old-media' into staging 2025-07-18 11:16:30 +02:00
Anwarul Islam c3caefc001 feat: enhance keybinding import validation and improve error handling 2025-07-18 08:30:42 +06:00
Anwarul Islam 8e4661951d feat: enhance keybinding management by integrating keybindingsEnabled state and updating related hooks 2025-07-18 08:05:02 +06:00
Anwarul Islam cdae10ca0a feat: refactor keybinding management and add keybinding editor component 2025-07-18 07:38:32 +06:00
Anwarul Islam 33531fb3bf feat: implement keybindings listener and disabler composables in use-keybindings hook 2025-07-18 07:37:12 +06:00
Anwarul Islam 4137747aba feat: add keybindings store with default configurations and utility functions 2025-07-18 07:35:31 +06:00
Kha Nguyen c2ef774525 remove unused state 2025-07-17 20:29:13 -05:00
Kha Nguyen e4748ecb09 Revert "fix: prevent multiple project creation when project doesn't exist"
This reverts commit d04aa8d1c1.
2025-07-17 20:25:16 -05:00
Anwarul Islam d328e009d4 Refactor keyboard shortcuts management: introduce useKeyboardShortcutsHelp hook and update KeyBadge and ShortcutItem components 2025-07-18 07:09:05 +06:00
Anwarul Islam f3bffae7ea Refactor editor provider to integrate keybinding management and action handlers 2025-07-18 07:08:57 +06:00
Anwarul Islam 7eda7975fc Implement editor actions for playback and timeline editing 2025-07-18 07:08:46 +06:00
Kha Nguyen d04aa8d1c1 fix: prevent multiple project creation when project doesn't exist 2025-07-17 20:08:08 -05:00
Anwarul Islam 1afc51eb9d Enhance action types and arguments for playback controls 2025-07-18 07:07:52 +06:00
Anwarul Islam 01b11226a3 Add additional keybindings for navigation and editing actions 2025-07-18 07:07:47 +06:00
Anwarul Islam 4b80c14982 Normalize keyboard shortcut keys to lowercase for consistent handling 2025-07-18 06:27:31 +06:00
Anwarul Islam c391eb8616 Refactor keyboard shortcuts to use uppercase keys for display and update KeyBadge functionality 2025-07-18 06:22:49 +06:00
Kha Nguyen e7bad87217 fix: remove wrong check causing new project to show old medias 2025-07-17 19:17:21 -05:00
Anwarul Islam f5d221feec Refactor KeyBadge component to return key names directly and update ShortcutItem to use <kbd> for key display 2025-07-18 05:53:42 +06:00
Anwarul Islam c34b402297 Add utils for platform detection 2025-07-18 05:53:19 +06:00
Anwarul Islam 480e83fa79 Add shortcut key styles to globals.css 2025-07-18 05:52:31 +06:00
Anwarul Islam 7f609cb86f Add action and keybinding management with React hooks 2025-07-18 05:27:40 +06:00
Maze Winther f492ac9aad fix: timeline element resizing 2025-07-17 23:51:12 +02:00
Maze Winther 499f06cb52 style: timeline tracks content 2025-07-17 23:02:29 +02:00
yassinehaimouch e487ca254e fix: vercel link to push you to home page not dashboard 2025-07-17 19:20:29 +01:00
Maze Winther f1b216848d Merge branch 'staging' 2025-07-17 19:51:31 +02:00
Maze Winther b564f7c239 middleware access 2025-07-17 19:50:32 +02:00
Maze Winther 648f0b8529 stable timeline 2025-07-17 19:46:23 +02:00
Maze Winther 0b919d4df8 refactor all timeline components 2025-07-17 19:33:10 +02:00
Maze Winther 56e6edcc5a move timeline component 2025-07-17 17:37:18 +02:00
Maze Winther 0217303532 formatting 2025-07-17 17:34:48 +02:00
Maze Winther 4170ee4f5b cleanup: tailwind classes 2025-07-17 17:34:18 +02:00
Maze Winther 4e5e9756c8 Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-07-17 17:22:53 +02:00
MazeandGitHub b97a30824e Merge pull request #283 from KhacTuanIT/feature/optimize-timeline-ruler
Optimize timeline performance
2025-07-17 17:22:42 +02:00
Maze Winther e9ec13999a Merge branch 'main' into staging 2025-07-17 17:19:30 +02:00
Maze Winther ce47e88e34 Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-07-17 17:19:23 +02:00
MazeandGitHub 502725b98e Merge pull request #289 from anwarulislam/feat/readme-clarfication
Update README and contributing guidelines for clarity
2025-07-17 17:18:47 +02:00
MazeandGitHub 103e4d5d3f Merge pull request #311 from enkeii64/feat/search-sort-projects
feat: add search and sort functionality to projects page
2025-07-17 17:17:20 +02:00
Maze Winther bb9befc626 cleanup 2025-07-17 17:14:29 +02:00
Maze Winther fe5b208a18 Merge branch 'feat/search-sort-projects' into staging 2025-07-17 17:11:56 +02:00
Maze Winther 22bf42d397 Merge branch 'staging' of https://github.com/mazeincoding/AppCut into staging 2025-07-17 17:08:36 +02:00
Maze Winther fef9d9a3fb ??? typescript 2025-07-17 17:08:33 +02:00
Anwarul Islam f3307a64d1 docs: reorganize prerequisites and setup instructions for clarity 2025-07-17 20:12:34 +06:00
Anwarul Islam 4d028487e6 docs: clarify Docker setup note in contributing guidelines 2025-07-17 20:02:46 +06:00
Anwarul Islam f1fed51502 docs: update setup instructions to include environment file copying steps 2025-07-17 19:52:42 +06:00
Anwarul Islam 6691e74db9 docs: update contributing guidelines for clarity on setup instructions 2025-07-17 19:52:42 +06:00
Anwarul Islam f21b704323 docs: update README for clarity on setup instructions 2025-07-17 19:46:57 +06:00
Khắc TuấnandGitHub faf5a121ac Merge branch 'main' into feature/optimize-timeline-ruler 2025-07-17 20:32:08 +07:00
Maze Winther f94b1a86d6 fuck yan 2025-07-17 14:17:47 +02:00
Maze Winther b18a48b586 refactor: move sorting logic to project store 2025-07-17 13:45:12 +02:00
MazeandGitHub d768da9216 Merge pull request #319 from anwarulislam/fix/tabbar
fix: make tab bar container full width
2025-07-17 13:40:32 +02:00
MazeandGitHub 021c3fb7d0 Merge pull request #331 from simonorzel26/patch-1
Update pull_request_template.md
2025-07-17 13:39:10 +02:00
Maze Winther e577c9659f Merge branch 'staging' of https://github.com/mazeincoding/AppCut into staging 2025-07-17 13:38:16 +02:00
MazeandGitHub b1efc90157 Merge pull request #321 from vadi25/main
Project creation and project access denied outside of dev mode
2025-07-17 13:35:44 +02:00
Simon OandGitHub 4dd1ea635b Update pull_request_template.md 2025-07-17 13:27:59 +02:00
MazeandGitHub 5a7a089f0e Merge pull request #323 from khanguyen74/feature/regenerate-media-items-thumbnail
fix: regenerate video thumbnails when a project is loaded
2025-07-17 13:06:06 +02:00
MazeandGitHub 9bf5ce399c Merge pull request #324 from simonorzel26/feat/refactor-timeline
feat(web): timeline refactor and horizontal + vertical scrolling
2025-07-17 13:02:30 +02:00
enkeii64 8efc696dcd feat: update delete project dialog 2025-07-17 20:54:31 +10:00
vadi25andGitHub 9af8905c0c Merge branch 'OpenCut-app:main' into main 2025-07-17 12:20:47 +02:00
Maze Winther 7d4cbca119 docs: README 2025-07-17 11:51:20 +02:00
Maze Winther 51857f1709 docs: what to focus on section 2025-07-17 11:50:28 +02:00
Simon Orzel 167f366c6d feat: bunx biome format --write . 2025-07-17 03:00:38 +02:00
Simon Orzel 1505c1b12f feat: timeline changes 2025-07-17 02:53:25 +02:00
Simon Orzel 98b2103286 feat: working 2025-07-17 02:25:24 +02:00
Simon Orzel 9fa9f406fe feat: splits working code 2025-07-17 01:59:39 +02:00
Simon Orzel 0d2894feda feat: working z indexing and borders 2025-07-17 01:13:38 +02:00
Kha Nguyen 36df8f2afe chore: format 2025-07-16 16:55:35 -05:00
Kha Nguyen 9f3ba9905d fix: regenerate video thumbnails when loading project 2025-07-16 16:49:08 -05:00
Simon Orzel 6ff4bcd093 feat: starts to refactor timeline 2025-07-16 23:31:51 +02:00
Maze Winther a3c84e4a19 fix handlebars 2025-07-16 23:28:26 +02:00
Maze Winther 36516b874f remove onboarding 2025-07-16 22:50:40 +02:00
Maze Winther 387404661a Merge branch 'vadi25/main' into staging 2025-07-16 22:49:55 +02:00
Maze Winther 7d1f99c20c fix 2025-07-16 22:49:10 +02:00
Maze Winther 651cfbfccb Merge branch 'main' of https://github.com/vadi25/opencut into vadi25/main 2025-07-16 22:47:23 +02:00
Maze Winther accad442f4 cleanup 2025-07-16 22:47:17 +02:00
MazeandGitHub 39ac2cbeb6 Merge pull request #322 from Zaid-maker/type-fix
MERGE
2025-07-16 22:44:12 +02:00
Maze Winther 38035fe5d0 Merge branch 'sahilsobhani/main' into staging 2025-07-16 22:42:36 +02:00
Maze Winther 4851c38e74 style(footer): align everything to left 2025-07-16 22:39:33 +02:00
Zaid-maker 2b618b189c more 2025-07-17 01:10:49 +05:00
Zaid-maker 10f5691d9e fix typo 2025-07-17 00:28:35 +05:00
Maze Winther 87dd275e57 fix: no more rounded corners in preview 2025-07-16 21:05:28 +02:00
vadi25 0988a936ad added redirect to project page if users not authorized, to be change by rolegate in the future 2025-07-16 20:37:13 +02:00
vadi25 6e3a9ec7ec fixed so only Projects can be created in production, so DB is safe from being saturated in production 2025-07-16 20:24:16 +02:00
Sahil Sobhani 04a1036770 added .env.example 2025-07-16 23:35:25 +05:30
Sahil Sobhani caf6fef3ad Redesigned the footer for mobile devices: UI Changes 2025-07-16 23:34:20 +05:30
MazeandGitHub da973b2e1e Merge pull request #318 from Zaid-maker/fix-builds
Add BETTER_AUTH_SECRET and BETTER_AUTH_URL env vars to build job
2025-07-16 19:50:53 +02:00
Anwarul Islam 4e7b088647 fix: make tab bar container full width 2025-07-16 23:35:11 +06:00
Maze Winther 5f58662ec7 Merge branch 'main' into staging 2025-07-16 19:13:04 +02:00
Zaid-maker e419eba29b fix build by passing correct env 2025-07-16 21:34:03 +05:00
Maze Winther 218625b61a feat: advance snapping 2025-07-16 17:44:44 +02:00
Maze Winther c1c4b114eb fix: snapping for both directions (left/right) 2025-07-16 17:38:29 +02:00
Maze Winther fcfe612cd6 fix: handlers component on mobile 2025-07-16 17:36:47 +02:00
Maze Winther e7344d9120 Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-07-16 16:15:11 +02:00
Maze Winther c3de532709 fix 2025-07-16 16:14:45 +02:00
enkeii64 9d5707d5fd fix: resolve merge conflict in draggable-item.tsx 2025-07-17 00:12:41 +10:00
enkeii64 7cbbbe1630 fix: resolve merge conflict in draggable-item.tsx 2025-07-17 00:07:39 +10:00
izaandGitHub 3b93fd6b68 Merge pull request #315 from OpenCut-app/staging
Staging
2025-07-16 17:06:36 +03:00
enkeii64 e0a5d4860a fix(ui): add tooltipText prop to PlusButton 2025-07-17 00:06:22 +10:00
Maze Winther 20f73bf705 Merge branch 'staging' of https://github.com/mazeincoding/AppCut into staging 2025-07-16 16:05:35 +02:00
Maze Winther 0c086e3c24 more shortcut fixes 2025-07-16 16:05:30 +02:00
izaandGitHub f6e2d5a60f Merge pull request #314 from mezotv/feat/sponsored-by-vercel
feat(hero): add sponsored by vercel to hero
2025-07-16 16:59:36 +03:00
gantiro 955f6331f2 Merge branch 'main' into feature/optimize-timeline-ruler 2025-07-16 20:53:00 +07:00
Maze Winther 5483db83de fix keyboard shortcuts 2025-07-16 15:52:25 +02:00
Maze Winther 3371a68896 block access in prod 2025-07-16 15:48:30 +02:00
Maze Winther fbd14290c6 another fix 2025-07-16 15:45:45 +02:00
Maze Winther 1389ce469a Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-07-16 15:44:09 +02:00
Maze Winther e317fb547d revert and fix (please work) 2025-07-16 15:44:05 +02:00
Maze Winther 8432f517cc please 2025-07-16 15:42:30 +02:00
Maze Winther 94f1c720bb fix 2025-07-16 15:41:03 +02:00
Maze Winther 453c422f94 fix 2025-07-16 15:36:16 +02:00
Maze Winther 700366f61e fix issue 2025-07-16 15:34:55 +02:00
Maze Winther 9530058b97 add waitlist count back 2025-07-16 15:30:19 +02:00
Dominik Koch 18d2186101 feat(hero): add sponsored by vercel to hero 2025-07-16 15:19:06 +02:00
Khắc TuấnandGitHub 2974fd89a9 Merge branch 'main' into feature/optimize-timeline-ruler 2025-07-16 20:04:27 +07:00
Maze Winther 1e01364740 Merge branch 'main' into staging 2025-07-16 14:53:16 +02:00
Maze Winther 0554a031c2 man idk 2025-07-16 14:52:53 +02:00
enkeii64 f83f647299 refactor: improve type safety and security in projects page 2025-07-16 22:43:01 +10:00
enkeii64 fe1c0edb8f feat: add no results screen for project search 2025-07-16 22:37:19 +10:00
Maze Winther 8bc26539eb cleanup (this doesn't work) 2025-07-16 14:37:08 +02:00
enkeii64 5a76196221 feat: add search and sort functionality to projects page 2025-07-16 22:23:21 +10:00
Maze Winther 527d588897 chore: bump lucide react verion 2025-07-16 13:40:34 +02:00
Maze Winther 49d467cd83 Merge branch 'staging' into pr/vishesh711/284 2025-07-16 13:38:26 +02:00
Maze Winther e57272043f Merge branch 'staging' 2025-07-16 13:30:19 +02:00
Maze Winther 9ae7cf0691 Merge branch 'main' into staging 2025-07-16 13:29:54 +02:00
MazeandGitHub 025b0100d3 Merge pull request #288 from simonorzel26/feat/select-all-projects-button-clickable
feat: makes the select all project button entirely clickable and cleans up small css changes
2025-07-16 13:29:36 +02:00
Maze Winther 6281652239 fix docs 2025-07-16 13:15:41 +02:00
Maze Winther 5b10e84542 Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-07-16 13:11:43 +02:00
Maze Winther 96f7516c94 Merge branch 'staging' of https://github.com/mazeincoding/AppCut into staging 2025-07-16 12:22:15 +02:00
Maze Winther a7e23ec714 media drag overlay cleanup 2025-07-16 12:22:09 +02:00
Maze Winther 63d5b2f110 Merge branch 'feat/media-button' into staging 2025-07-16 12:04:09 +02:00
Maze Winther 446745a33c cleanup 2025-07-16 12:03:44 +02:00
MazeandGitHub e96d97885b Merge pull request #307 from W-DOS0/fix-header
Update README.md
2025-07-16 12:01:28 +02:00
MazeandGitHub ccca2e8d0f Merge pull request #308 from enkeii64/feat/display-all-contributors
feat: display all contributors
2025-07-16 12:00:11 +02:00
MazeandGitHub 2982293b54 Merge pull request #287 from simonorzel26/fix/268_multiple-new-projects
fix(web): #268 multiple new projects bug
2025-07-16 11:58:56 +02:00
enkei64andGitHub 0cb26ef088 Merge branch 'OpenCut-app:main' into feat/display-all-contributors 2025-07-16 19:07:33 +10:00
[W]DOS_ 2aa1eac7da Update README.md
Removed the duplicate “## Getting Started” header to improve clarity and avoid redundancy in the setup instructions.
2025-07-16 10:54:59 +02:00
MazeandGitHub 27731a270d Merge pull request #291 from ahmetskilinc/better-rate-limiting
feat: better-rate-limiting
2025-07-16 10:54:38 +02:00
enkeii64 7c6b2e4f49 feat: display all contributors 2025-07-16 18:49:11 +10:00
Ahmet Kilinc 79f6c680f3 remove browser check 2025-07-16 01:23:06 +01:00
Ahmet Kilinc ece9c78b16 coderabbit comments 2025-07-16 01:05:42 +01:00
Ahmet Kilinc eb49500592 adds Vercel BotId 2025-07-16 01:00:40 +01:00
Ahmet Kilinc 5178732c4c csrf token and browser detection 2025-07-16 00:44:42 +01:00
Simon Orzel f76169ada1 feat: refactors media drag overlay and media tab 2025-07-16 00:48:00 +02:00
Simon Orzel acd1e2e216 feat: makes the select all project button entirely clickable and cleans up small css changes 2025-07-15 23:48:47 +02:00
Maze Winther d5c52557df Merge branch 'staging' 2025-07-15 23:28:06 +02:00
Maze Winther 83d65bdd15 feat: remove waitlist count due to bots 2025-07-15 23:26:04 +02:00
Simon Orzel 6df492b661 fix(web): #268 multiple new projects bug 2025-07-15 23:25:09 +02:00
vishesh711 13db27c173 fix: resolve TypeScript build errors and deployment blockers
- Fix missing lucide-react icon imports in type declarations
  - Add 40+ missing icon exports (Loader2, ExternalLink, Type, etc.)
  - Fix ArrowLeft → ChevronLeft imports in auth pages
  - Resolve LucideIcon type declaration issue

- Fix invalid Button variant 'ghost' → 'text' in KeyboardShortcutsHelp

- Ensure project builds successfully with Bun package manager
- Address CodeRabbit review feedback for production readiness

These fixes resolve all TypeScript compilation errors and make the
project fully deployment-ready. Build now passes with zero errors.
2025-07-15 12:58:04 -04:00
vishesh711 d3204f6cd7 feat: Add comprehensive keyboard shortcuts system for video editor
- Implement industry-standard video editing shortcuts (Space, J/K/L, arrows)
- Add playback controls: Space (play/pause), J/K/L (rewind/pause/forward)
- Add navigation: arrow keys (frame stepping), Shift+arrows (5s jumps), Home/End
- Add editing shortcuts: S (split at playhead), N (toggle snapping)
- Add selection shortcuts: Ctrl/Cmd+A (select all), Ctrl/Cmd+D (duplicate)
- Add keyboard shortcuts help dialog with categorized shortcuts display
- Integrate help button in editor header for discoverability
- Add TypeScript type declarations for better development experience
- Fix Docker build environment variable validation issues

All shortcuts follow professional video editing standards (Avid/Premiere/Final Cut).
Context-aware shortcuts that don't interfere with text input fields.
Toast notifications provide user feedback for better UX.
2025-07-15 12:36:53 -04:00
gantiro faf317cd20 fix comment remove check time 2025-07-15 23:33:31 +07:00
gantiro 7cc3a907de fix comment for use-timeline-playhead hook 2025-07-15 23:29:30 +07:00
gantiro 8bfc91e62e fix comment 2025-07-15 23:26:56 +07:00
gantiro 5fc06257f2 change zIndex for timeline-playhead 2025-07-15 23:23:15 +07:00
gantiro 52f003f25a add comment for hooks 2025-07-15 22:56:56 +07:00
gantiro b266678fe4 Optimize timeline performance 2025-07-15 22:51:38 +07:00
Maze Winther 3a8d57921e feat: add lock icon toggle for snapping functionality 2025-07-15 17:41:10 +02:00
Maze Winther fd2aa00074 style: tooltip 2025-07-15 17:40:19 +02:00
Maze Winther 7a725ecd09 cleanup 2025-07-15 17:29:28 +02:00
Maze Winther a32ffb601a Merge branch 'feat/thumbnail-appearance' into staging 2025-07-15 17:07:21 +02:00
Maze Winther dc0e4783fb cleanup 2025-07-15 16:55:04 +02:00
Maze Winther fdeddbe3ed Merge branch 'feature/plus-button-timeline' into staging 2025-07-15 16:28:08 +02:00
Pham Tan Minh Tien 7732f0f158 chore: add .DS_Store to .gitignore 2025-07-15 20:37:06 +07:00
Maze Winther 1f95bb98d6 Merge branch 'staging' 2025-07-15 15:08:00 +02:00
Maze Winther b1ccf3695e fix quicl 2025-07-15 15:07:47 +02:00
Maze Winther a8a5a68cae Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-07-15 15:03:56 +02:00
Maze Winther 7d2f323f4d fix: last updated date in roadmap 2025-07-15 15:03:33 +02:00
Maze Winther d16f2cc3c1 i love turbopack 2025-07-15 15:00:04 +02:00
Maze Winther ebad05f17b refactor: remove development debug 2025-07-15 14:58:08 +02:00
Maze Winther ce851e52ce Merge branch 'staging' of https://github.com/mazeincoding/AppCut into staging 2025-07-15 14:57:09 +02:00
Maze Winther 10b6cc98b8 chore: bump next version in bun lock 2025-07-15 14:57:05 +02:00
Maze Winther 2432bcf54b chore: bump next version 2025-07-15 14:56:48 +02:00
MazeandGitHub f5ed3e196a Merge pull request #266 from anwarulislam/feat/snapping
feature: add timeline snapping functionality
2025-07-15 14:17:50 +02:00
Maze Winther 1fdf2a0515 cleanup 2025-07-15 14:16:54 +02:00
Maze Winther 5e86ed9efc fix: tooltip styling 2025-07-15 14:13:02 +02:00
Maze Winther cb877769fb refactor: remove google auth 2025-07-15 12:35:08 +02:00
MazeandGitHub 0e83b6c15e Merge pull request #272 from vietnamesekid/main
feat: integrate @t3-oss/env for environment variable management across the application
2025-07-15 12:20:55 +02:00
MazeandGitHub 2aa7493189 Merge pull request #274 from Zaid-maker/migration
Set default value of users.email_verified column to false via migration
2025-07-15 12:17:44 +02:00
enkeii64 d7c764f968 feat: Add Discord link to footer 2025-07-15 19:24:34 +10:00
Zaid-maker bfa5d8f062 migration 2025-07-15 13:51:17 +05:00
tranminhquang 9d5fb821c7 feat: integrate @t3-oss/env for environment variable management across the application 2025-07-15 13:29:16 +07:00
Anwarul Islam 0f05d25e8a fix: ensure correct type assertion for snappedTime in useTimelineSnapping 2025-07-15 07:17:45 +06:00
Anwarul Islam e3cb491a29 feat: add snap point notification to TimelineTrackContent for improved snapping feedback 2025-07-15 07:06:29 +06:00
Anwarul Islam 2cc25b70d5 feat: enhance snapping functionality by adding snap point handling in Timeline component 2025-07-15 07:03:36 +06:00
Anwarul Islam 9cc77b4499 feat: enforce non-negative snap threshold and minimum grid interval in timeline store 2025-07-15 06:59:46 +06:00
Anwarul Islam 59f4098f75 refactor: remove keyboard shortcuts for better integration later 2025-07-15 04:32:36 +06:00
Anwarul Islam 1106aeeb60 feat: integrate snapping functionality into timeline track component 2025-07-15 04:27:19 +06:00
Anwarul Islam af632d8cc5 feat: add snapping functionality and controls to timeline component 2025-07-15 04:27:12 +06:00
Anwarul Islam e7485340cf feat: implement useTimelineSnapping hook for snapping functionality in timeline 2025-07-15 04:25:51 +06:00
Anwarul Islam 2b57f3026c feat: add snapping settings and actions to timeline store 2025-07-15 04:25:16 +06:00
Anwarul Islam 4de81796a3 feat: add SnapIndicator component for timeline snapping visualization 2025-07-15 04:23:45 +06:00
Maze Winther 55978c30fa docs: vercel sponsor to readme 2025-07-14 23:53:33 +02:00
Jakub Marcowski 7fb1181034 Optimise PNGs across the repo 2025-07-14 23:16:38 +02:00
Anwarul Islam d53321ec36 fix: enhance media tile display with aspect ratio adjustments and overlay borders 2025-07-15 00:42:19 +06:00
Maze Winther 7798334300 feat: roadmap page and move all open-graph images to /open-graph folder 2025-07-14 20:34:16 +02:00
Anwarul Islam 2c941b1585 fix: optimize media thumbnail display by removing tiling utility and using CSS background instead 2025-07-15 00:12:01 +06:00
Anwarul Islam c628b5a8ee fix improve timeline element visual display by tiling repeating media thumbnails 2025-07-14 23:53:11 +06:00
Anwarul Islam 1e64bc4abd fix improve timeline element visual display 2025-07-14 23:49:48 +06:00
Maze Winther 0309f9cf08 refactor: comment 2025-07-14 19:48:12 +02:00
ni-zavandGitHub ffb254c339 Merge branch 'OpenCut-app:main' into refactor/move-add-button 2025-07-14 23:43:34 +07:00
Maze Winther 416511084a fix: drag media to timeline 2025-07-14 18:06:11 +02:00
Nizav acd42a29f6 refactor: move the add button below the placeholder text 2025-07-14 22:46:28 +07:00
Maze Winther 64b84ca041 fix: toast 2025-07-14 17:42:00 +02:00
Maze Winther 87215b31e9 fix: last updated date in privacy/terms 2025-07-14 17:39:13 +02:00
Maze Winther d8bb13eb78 feat: privacy/terms pages 2025-07-14 17:37:25 +02:00
Maze Winther b54c5443a0 fix: metadata 2025-07-14 16:21:41 +02:00
Maze Winther 89691312b8 chore: bump drizzle-orm version 2025-07-14 16:20:52 +02:00
Maze Winther 73ba7d6577 fix: better auth env variable 2025-07-14 16:16:10 +02:00
Maze Winther 238f8e85bb chore: add missing env variables to turbo.json 2025-07-14 13:07:20 +02:00
Maze Winther b0e9901730 databuddy: enable errors 2025-07-13 13:25:43 +02:00
Maze Winther 3b4b7e2009 databuddy 2025-07-13 13:20:06 +02:00
Maze Winther 3037a7ecbf fix: handle border 2025-07-12 21:52:35 +02:00
Maze Winther ad7ace3fd2 fix: spacing 2025-07-12 21:52:18 +02:00
ahmedfahim21 03b3c952c7 feat: click to add to timeline 2025-07-13 00:59:54 +05:30
Anwarul Islam d0769fdcaa fix: reorder export statements for consistency 2025-07-12 23:53:15 +06:00
Anwarul Islam 9756559811 chore: remove GripVertical as it is an unused import 2025-07-12 23:52:17 +06:00
Anwarul Islam 0ee043b319 fix: enhance layout of editor and resizable components for better usability 2025-07-12 23:49:49 +06:00
Anwarul Islam 9d828063c1 fix: update ResizableHandle styles for improved visibility and interaction 2025-07-12 23:40:54 +06:00
Anwarul Islam 70517fec18 fix: improve layout and styling of resizable panels 2025-07-12 23:40:38 +06:00
Maze Winther 22db0b8b89 Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-07-12 17:15:06 +02:00
Maze Winther 71f666c6d7 feat: optimzation for deleting projects 2025-07-12 17:14:53 +02:00
Maze Winther e57ca15dc6 fix: missing name field 2025-07-12 16:54:55 +02:00
MazeandGitHub e96832bf9b Merge pull request #172 from Zaid-maker/bump-bun-version
Bump bun version
2025-07-12 16:52:42 +02:00
Maze Winther 0a0f68d711 Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-07-12 16:51:18 +02:00
Maze Winther 57dace960c Merge branch 'manuarora700/main' 2025-07-12 16:51:09 +02:00
Maze Winther 145d01c8e4 cleanup 2025-07-12 16:50:34 +02:00
MazeandGitHub 25d12a6fa4 Merge pull request #183 from youknowom/improve-contributing
docs: add note about npm workspace error and bun recommendation
2025-07-12 16:39:29 +02:00
MazeandGitHub a9b02df2e3 Merge pull request #185 from omraval18/fix/login-redirect-issue
fix: redirects to /projects after login instead of /editor
2025-07-12 16:38:32 +02:00
Maze Winther b76e84354d feat: dark mode always 2025-07-12 15:45:12 +02:00
Maze Winther 233f78bf52 Merge remote-tracking branch 'anwarulislam/feat/fix-scroll' 2025-07-12 15:44:33 +02:00
MazeandGitHub f0b0451616 Merge pull request #200 from ahmedfahim21/feature/gesture-navigation-fix
fix: prevent unintended navigation on swipe
2025-07-12 15:39:14 +02:00
MazeandGitHub 4d0382cc4b Merge pull request #202 from MarcBlattmann/patch-1
Remove Unused Bracket in readme
2025-07-12 15:38:13 +02:00
MazeandGitHub 800e720e6e Merge pull request #208 from Sanjit-K/main
fixed: [BUG] crypto.randomUUID is not a function Runtime Error #205. …
2025-07-12 15:27:00 +02:00
MazeandGitHub 457d828c45 Merge pull request #210 from Mirza-Samad-Ahmed-Baig/main
refactor: improve theme handling, env var safety, and db schema
2025-07-12 15:24:01 +02:00
MazeandGitHub d925b62a47 Merge pull request #215 from Chirag-varu/main
Better README and docker-compose port error solved
2025-07-12 15:22:51 +02:00
Maze Winther 84d153e2c5 databuddy 2025-07-12 14:42:31 +02:00
Chirag-varu 27da1838cc Better README and docker-compose port error solved 2025-07-12 17:05:53 +05:30
Maze Winther 8545d95070 refactor: add FPS and remove turbopack (yes, fuck you turbopack) 2025-07-12 12:48:31 +02:00
Maze Winther 0726c27221 small change 2025-07-12 11:52:54 +02:00
Maze Winther 2de4c7c153 feat: remove playback speed from timeline 2025-07-12 11:45:43 +02:00
Maze Winther ba809effb6 databuddy 2025-07-12 11:29:35 +02:00
Maze Winther 09577b712a Merge branch 'main' of https://github.com/mazeincoding/AppCut 2025-07-12 11:24:05 +02:00
Maze Winther da06c5946d databuddy 2025-07-12 11:23:54 +02:00
Mirza-Samad-Ahmed-Baig d48a3fe287 refactor: improve theme handling, env var safety, and db schema 2025-07-12 13:31:50 +05:00
ahmedfahim21 b4c93182d0 fix: apply suggestion 2025-07-12 12:45:50 +05:30
Sanjit K 799fd2981a fixed: [BUG] crypto.randomUUID is not a function Runtime Error #205. Added fallback function with manual UUID generation if crypto.randomUUID() was not available. 2025-07-12 00:14:03 -05:00
MarcandGitHub ac1eef7bd0 Update README.md 2025-07-11 22:18:32 +02:00
MarcandGitHub 6e6f5211fc Remove Unused Bracket in readme 2025-07-11 22:05:35 +02:00
ahmedfahim21 338e13a601 fix: apply suggestion 2025-07-12 01:04:29 +05:30
ahmedfahim21 410f8da1c9 fix: prevent unintended navigation on swipe 2025-07-12 00:39:17 +05:30
Anwarul Islam 6ecc359d80 chore: clean up 2025-07-11 20:02:09 +06:00
MazeandGitHub fd017d6aca docs: add growth to README 2025-07-11 15:59:56 +02:00
Anwarul Islam 4d8760d0e1 refactor: enhance scroll synchronization for timeline and track labels 2025-07-11 19:58:35 +06:00
omraval18 3e45be5c47 fix: redirects to /projects after login instead of /editor 2025-07-11 00:33:31 +05:30
Omkaar d9d54df431 docs: update note to mention npm upgrade option 2025-07-10 23:20:32 +05:30
Omkaar 5e74906e19 docs: add note about npm workspace error and bun recommendation 2025-07-10 23:12:57 +05:30
Manu Arora c5d96a0ded add: actual handlebars to resize container 2025-07-09 17:30:37 +05:30
Zaid HafeezandGitHub b5d04d591f Update bun-ci.yml 2025-07-08 13:29:03 +05:00
Zaid HafeezandGitHub e26c3bccdb Update package.json 2025-07-08 13:25:13 +05:00
Zaid HafeezandGitHub 74408541fd Update package.json 2025-07-08 13:24:33 +05:00
Zaid HafeezandGitHub 65afd3f18b bump bun version in CI 2025-07-08 13:23:41 +05:00
134 changed files with 7470 additions and 2657 deletions
+55 -17
View File
@@ -4,20 +4,66 @@ Thank you for your interest in contributing to OpenCut! This document provides g
## Getting Started
### Prerequisites
- [Node.js](https://nodejs.org/en/) (v18 or later)
- [Bun](https://bun.sh/docs/installation)
(for `npm` alternative)
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
> **Note:** Docker is optional, but it's essential for running the local database and Redis services. If you're planning to contribute to frontend features, you can skip the Docker setup. If you have followed the steps below in [Setup](#setup), you're all set to go!
### Setup
1. Fork the repository
2. Clone your fork locally
3. Navigate to the web app directory: `cd apps/web`
4. Install dependencies: `bun install`
5. Start the development server: `bun run dev`
4. Copy `.env.example` to `.env.local`:
```bash
# Unix/Linux/Mac
cp .env.example .env.local
# Windows Command Prompt
copy .env.example .env.local
# Windows PowerShell
Copy-Item .env.example .env.local
```
5. Install dependencies: `bun install`
6. Start the development server: `bun run dev`
> **Note:** If you see an error like `Unsupported URL Type "workspace:*"` when running `npm install`, you have two options:
>
> 1. Upgrade to a recent npm version (v9 or later), which has full workspace protocol support.
> 2. Use an alternative package manager such as **bun** or **pnpm**.
## What to Focus On
**🎯 Good Areas to Contribute:**
- Timeline functionality and UI improvements
- Project management features
- Performance optimizations
- Bug fixes in existing functionality
- UI/UX improvements
- Documentation and testing
**⚠️ Areas to Avoid:**
- Preview panel enhancements (text fonts, stickers, effects)
- Export functionality improvements
- Preview rendering optimizations
**Why?** We're currently planning a major refactor of the preview system. The current preview renders DOM elements (HTML), but we're moving to a binary rendering approach similar to CapCut. This new system will ensure consistency between preview and export, and provide much better performance and quality.
The current HTML-based preview is essentially a prototype - the binary approach will be the "real deal." To avoid wasted effort, please focus on other areas of the application until this refactor is complete.
If you're unsure whether your idea falls into the preview category, feel free to ask us [directly in discord](https://discord.gg/zmR9N35cjK) or create a GitHub issue!
## Development Setup
### Prerequisites
- Node.js 18+
- Bun (latest version)
- Docker (for local database)
### Local Development
1. Start the database and Redis services:
@@ -56,7 +102,7 @@ Thank you for your interest in contributing to OpenCut! This document provides g
# Generate a secure secret for Better Auth
BETTER_AUTH_SECRET="your-generated-secret-here"
BETTER_AUTH_URL="http://localhost:3000"
NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000"
# Redis (matches docker-compose.yaml)
UPSTASH_REDIS_REST_URL="http://localhost:8079"
@@ -81,14 +127,6 @@ Thank you for your interest in contributing to OpenCut! This document provides g
# Or use an online generator: https://generate-secret.vercel.app/32
```
**Optional Variables (for Google OAuth):**
```bash
# Only needed if you want to test Google login
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
```
5. Run database migrations: `bun run db:migrate`
6. Start the development server: `bun run dev`
+1
View File
@@ -36,6 +36,7 @@ Add screenshots to help explain your changes.
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have added screenshots if ui has been changed
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
+6 -2
View File
@@ -23,6 +23,10 @@ jobs:
env:
DATABASE_URL: "postgresql://opencut:opencutthegoat@localhost:5432/opencut"
BETTER_AUTH_SECRET: "supersecret"
NEXT_PUBLIC_BETTER_AUTH_URL: "http://localhost:3000"
UPSTASH_REDIS_REST_URL: "https://your-upstash-redis-url"
UPSTASH_REDIS_REST_TOKEN: "your-upstash-redis-token"
steps:
- name: Checkout repository
@@ -31,13 +35,13 @@ jobs:
- name: Install Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
with:
bun-version: 1.2.17
bun-version: 1.2.18
- name: Cache Bun modules
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-1.2.17-${{ hashFiles('apps/web/bun.lock') }}
key: ${{ runner.os }}-bun-1.2.18-${{ hashFiles('apps/web/bun.lock') }}
- name: Install dependencies
working-directory: apps/web
+3 -3
View File
@@ -9,8 +9,6 @@
# debug
/apps/web/npm-debug.log*
/apps/web/yarn-debug.log*
/apps/web/yarn-error.log*
# env files (can opt-in for committing if needed)
/apps/web/.env*
@@ -18,7 +16,6 @@
# typescript
/apps/web/next-env.d.ts
/apps/web/yarn.lock
# asdf version management
.tool-versions
@@ -33,3 +30,6 @@ node_modules
.cursor/
bun.lockb
# mac
.DS_Store
+86 -51
View File
@@ -23,6 +23,7 @@
- Real-time preview
- No watermarks or subscriptions
- Analytics provided by [Databuddy](https://www.databuddy.cc?utm_source=opencut), 100% Anonymized & Non-invasive.
- Blog powered by [Marble](https://marblecms.com?utm_source=opencut), Headless CMS.
## Project Structure
@@ -39,86 +40,117 @@
Before you begin, ensure you have the following installed on your system:
- [Node.js](https://nodejs.org/en/) (v18 or later)
- [Bun](https://bun.sh/docs/installation)
(for `npm` alternative)
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
- [Node.js](https://nodejs.org/en/) (for `npm` alternative)
> **Note:** Docker is optional, but it's essential for running the local database and Redis services. If you're planning to run the frontend or want to contribute to frontend features, you can skip the Docker setup. If you have followed the steps below in [Setup](#setup), you're all set to go!
### Setup
1. **Clone the repository**
1. Fork the repository
2. Clone your fork locally
3. Navigate to the web app directory: `cd apps/web`
4. Copy `.env.example` to `.env.local`:
```bash
git clone https://github.com/OpenCut-app/OpenCut.git
cd OpenCut
```
```bash
# Unix/Linux/Mac
cp .env.example .env.local
2. **Start backend services**
From the project root, start the PostgreSQL and Redis services:
# Windows Command Prompt
copy .env.example .env.local
```bash
docker-compose up -d
```
# Windows PowerShell
Copy-Item .env.example .env.local
```
3. **Set up environment variables**
Navigate into the web app's directory and create a `.env` file from the example:
5. Install dependencies: `bun install`
6. Start the development server: `bun dev`
```bash
cd apps/web
## Development Setup
### Local Development
# Unix/Linux/Mac
cp .env.example .env.local
1. Start the database and Redis services:
# Windows Command Prompt
copy .env.example .env.local
```bash
# From project root
docker-compose up -d
```
# Windows PowerShell
Copy-Item .env.example .env.local
```
2. Navigate to the web app directory:
_The default values in the `.env` file should work for local development._
```bash
cd apps/web
```
4. **Install dependencies**
Install the project dependencies using `bun` (recommended) or `npm`.
3. Copy `.env.example` to `.env.local`:
```bash
# With bun
bun install
```bash
# Unix/Linux/Mac
cp .env.example .env.local
# Or with npm
npm install
```
# Windows Command Prompt
copy .env.example .env.local
5. **Run database migrations**
Apply the database schema to your local database:
# Windows PowerShell
Copy-Item .env.example .env.local
```
```bash
# With bun
bun run db:push:local
4. Configure required environment variables in `.env.local`:
# Or with npm
npm run db:push:local
```
**Required Variables:**
6. **Start the development server**
```bash
# Database (matches docker-compose.yaml)
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
```bash
# With bun
bun run dev
# Generate a secure secret for Better Auth
BETTER_AUTH_SECRET="your-generated-secret-here"
BETTER_AUTH_URL="http://localhost:3000"
# Or with npm
npm run dev
```
# Redis (matches docker-compose.yaml)
UPSTASH_REDIS_REST_URL="http://localhost:8079"
UPSTASH_REDIS_REST_TOKEN="example_token"
# Marble Blog
MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key
NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
# Development
NODE_ENV="development"
```
**Generate BETTER_AUTH_SECRET:**
```bash
# Unix/Linux/Mac
openssl rand -base64 32
# Windows PowerShell (simple method)
[System.Web.Security.Membership]::GeneratePassword(32, 0)
# Cross-platform (using Node.js)
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# Or use an online generator: https://generate-secret.vercel.app/32
```
5. Run database migrations: `bun run db:migrate` from (inside apps/web)
6. Start the development server: `bun run dev` from (inside apps/web)
The application will be available at [http://localhost:3000](http://localhost:3000).
## Contributing
**Note**: We're currently moving at an extremely fast pace with rapid development and breaking changes. While we appreciate the interest, it's recommended to wait until the project stabilizes before contributing to avoid conflicts and wasted effort.
We welcome contributions! While we're actively developing and refactoring certain areas, there are plenty of opportunities to contribute effectively.
## Visit [CONTRIBUTING.md](.github/CONTRIBUTING.md)
**🎯 Focus areas:** Timeline functionality, project management, performance, bug fixes, and UI improvements outside the preview panel.
We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions and development guidelines.
**⚠️ Avoid for now:** Preview panel enhancements (fonts, stickers, effects) and export functionality - we're refactoring these with a new binary rendering approach.
See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instructions, development guidelines, and complete focus area guidance.
**Quick start for contributors:**
@@ -130,6 +162,10 @@ We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTI
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
<a href="https://vercel.com/oss">
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
</a>
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FOpenCut-app%2FOpenCut&project-name=opencut&repository-name=opencut)
## License
@@ -138,5 +174,4 @@ Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss
---
[![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date)]
![Star History Chart](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date)
+5 -5
View File
@@ -4,16 +4,16 @@
DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
# Better Auth
BETTER_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=your-secret-key-here
#Google Clients
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Development Environment
NODE_ENV=development
# Redis
UPSTASH_REDIS_REST_URL=http://localhost:8079
UPSTASH_REDIS_REST_TOKEN=example_token
# Marble Blog
MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key
NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
+6
View File
@@ -21,6 +21,12 @@ COPY packages/auth/ packages/auth/
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
# Set build-time environment variables for validation
ENV DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
ENV BETTER_AUTH_SECRET="build-time-secret"
ENV UPSTASH_REDIS_REST_URL="http://localhost:8079"
ENV UPSTASH_REDIS_REST_TOKEN="example_token"
ENV NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000"
WORKDIR /app/apps/web
RUN bun run build
+1 -5
View File
@@ -8,15 +8,11 @@ if (process.env.NODE_ENV === "production") {
dotenv.config({ path: ".env.local" });
}
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is not set");
}
export default {
schema: "../../packages/db/src/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL,
url: process.env.DATABASE_URL!,
},
out: "./migrations",
strict: process.env.NODE_ENV === "production",
@@ -0,0 +1 @@
ALTER TABLE "users" ALTER COLUMN "email_verified" SET DEFAULT false;
+359
View File
@@ -0,0 +1,359 @@
{
"id": "1b9148ed-497b-4e53-8cf6-f556c8fe7f7f",
"prevId": "b7d920ca-6dd0-430f-8ee6-1d38fdf3e80f",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.accounts": {
"name": "accounts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"accounts_user_id_users_id_fk": {
"name": "accounts_user_id_users_id_fk",
"tableFrom": "accounts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"sessions_token_unique": {
"name": "sessions_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.verifications": {
"name": "verifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.waitlist": {
"name": "waitlist",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"waitlist_email_unique": {
"name": "waitlist_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -15,6 +15,13 @@
"when": 1750689835736,
"tag": "0001_tricky_jackpot",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1752569400809,
"tag": "0002_cuddly_pretty_boy",
"breakpoints": true
}
]
}
+14 -1
View File
@@ -1,4 +1,5 @@
import type { NextConfig } from "next";
import { withBotId } from "botid/next/config";
const nextConfig: NextConfig = {
compiler: {
@@ -17,8 +18,20 @@ const nextConfig: NextConfig = {
protocol: "https",
hostname: "images.unsplash.com",
},
{
protocol: "https",
hostname: "images.marblecms.com",
},
{
protocol: "https",
hostname: "lh3.googleusercontent.com",
},
{
protocol: "https",
hostname: "avatars.githubusercontent.com",
},
],
},
};
export default nextConfig;
export default withBotId(nextConfig);
+18 -6
View File
@@ -2,9 +2,9 @@
"name": "opencut",
"version": "0.1.0",
"private": true,
"packageManager": "bun@1.2.17",
"packageManager": "bun@1.2.18",
"scripts": {
"dev": "next dev --turbopack",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
@@ -21,10 +21,14 @@
"@hookform/resolvers": "^3.9.1",
"@opencut/auth": "workspace:*",
"@opencut/db": "workspace:*",
"@radix-ui/react-separator": "^1.1.7",
"@t3-oss/env-core": "^0.13.8",
"@t3-oss/env-nextjs": "^0.13.8",
"@upstash/ratelimit": "^2.0.5",
"@upstash/redis": "^1.35.0",
"@vercel/analytics": "^1.4.1",
"better-auth": "^1.2.7",
"botid": "^1.4.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
@@ -36,7 +40,7 @@
"input-otp": "^1.4.1",
"lucide-react": "^0.468.0",
"motion": "^12.18.1",
"next": "^15.3.4",
"next": "^15.4.1",
"next-themes": "^0.4.4",
"pg": "^8.16.2",
"radix-ui": "^1.4.2",
@@ -45,27 +49,35 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.54.0",
"react-icons": "^5.4.0",
"react-markdown": "^10.1.0",
"react-phone-number-input": "^3.4.11",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.14.1",
"rehype-autolink-headings": "^7.1.0",
"rehype-parse": "^9.0.1",
"rehype-sanitize": "^6.0.0",
"rehype-slug": "^6.0.0",
"rehype-stringify": "^10.0.1",
"sonner": "^1.7.1",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5",
"vaul": "^1.1.1",
"zod": "^3.25.67",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/pg": "^8.15.4",
"@tailwindcss/typography": "^0.5.16",
"@types/bun": "latest",
"@types/pg": "^8.15.4",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.31.1",
"drizzle-kit": "^0.31.4",
"postcss": "^8",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.1",
"tsx": "^4.7.1",
"typescript": "^5"
"typescript": "^5.8.3"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 467 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 741 B

After

Width:  |  Height:  |  Size: 564 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 768 B

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 802 B

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 826 B

After

Width:  |  Height:  |  Size: 274 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 906 B

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 985 B

After

Width:  |  Height:  |  Size: 399 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 B

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 467 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 724 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 809 B

After

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 843 B

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 826 B

After

Width:  |  Height:  |  Size: 274 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 B

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 B

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 747 B

After

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 906 B

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 467 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 814 B

After

Width:  |  Height:  |  Size: 259 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1021 B

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

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

Some files were not shown because too many files have changed in this diff Show More