From 7bf0984698e43b9aa2dd879f65951318ed410390 Mon Sep 17 00:00:00 2001
From: Maze <167211895+mazeincoding@users.noreply.github.com>
Date: Sat, 31 Jan 2026 00:20:04 +0100
Subject: codebase overhaul (#697)
---
apps/web/.env.example | 41 +-
apps/web/.gitignore | 8 +-
apps/web/Dockerfile | 65 -
apps/web/drizzle.config.ts | 12 +-
apps/web/migrations/0000_brainy_saracen.sql | 62 +
apps/web/migrations/0000_hot_the_fallen.sql | 54 -
apps/web/migrations/0001_tricky_jackpot.sql | 8 -
apps/web/migrations/0002_cuddly_pretty_boy.sql | 1 -
apps/web/migrations/0003_long_polaris.sql | 10 -
apps/web/migrations/meta/0000_snapshot.json | 39 +-
apps/web/migrations/meta/0001_snapshot.json | 344 ----
apps/web/migrations/meta/0002_snapshot.json | 345 ----
apps/web/migrations/meta/0003_snapshot.json | 365 ----
apps/web/migrations/meta/_journal.json | 27 +-
apps/web/next-env.d.ts | 6 +
apps/web/package.json | 33 +-
apps/web/public/frame.svg | 10 -
apps/web/public/logo.png | Bin 1789 -> 0 bytes
apps/web/public/logo.svg | 10 -
.../logos/opencut/1k/logo-black-with-text.png | Bin 0 -> 10331 bytes
apps/web/public/logos/opencut/1k/logo-black.png | Bin 0 -> 2101 bytes
.../logos/opencut/1k/logo-white-with-text.png | Bin 0 -> 10839 bytes
apps/web/public/logos/opencut/1k/logo-white.png | Bin 0 -> 2149 bytes
.../logos/opencut/2k/logo-black-with-text.png | Bin 0 -> 22647 bytes
apps/web/public/logos/opencut/2k/logo-black.png | Bin 0 -> 4815 bytes
.../logos/opencut/2k/logo-white-with-text.png | Bin 0 -> 22670 bytes
apps/web/public/logos/opencut/2k/logo-white.png | Bin 0 -> 4153 bytes
.../logos/opencut/4k/logo-black-with-text.png | Bin 0 -> 52288 bytes
apps/web/public/logos/opencut/4k/logo-black.png | Bin 0 -> 11217 bytes
.../logos/opencut/4k/logo-white-with-text.png | Bin 0 -> 54360 bytes
apps/web/public/logos/opencut/4k/logo-white.png | Bin 0 -> 10259 bytes
apps/web/public/logos/opencut/svg/logo.svg | 10 +
apps/web/public/logos/others/fal.svg | 3 +
apps/web/public/logos/others/vercel.svg | 3 +
apps/web/src/app/(auth)/login/page.tsx | 146 --
apps/web/src/app/(auth)/signup/page.tsx | 162 --
apps/web/src/app/animation/page.tsx | 24 -
apps/web/src/app/api/auth/[...all]/route.ts | 2 +-
apps/web/src/app/api/get-upload-url/route.ts | 128 --
apps/web/src/app/api/health/route.ts | 6 +-
apps/web/src/app/api/sounds/search/route.ts | 467 ++---
apps/web/src/app/api/transcribe/route.ts | 189 --
apps/web/src/app/api/waitlist/export/route.ts | 83 -
apps/web/src/app/base-page.tsx | 53 +
apps/web/src/app/blog/[slug]/page.tsx | 264 +--
apps/web/src/app/blog/page.tsx | 151 +-
apps/web/src/app/contributors/page.tsx | 536 +++---
apps/web/src/app/editor/[project_id]/layout.tsx | 9 -
apps/web/src/app/editor/[project_id]/page.tsx | 543 +-----
apps/web/src/app/globals.css | 430 ++---
apps/web/src/app/layout.tsx | 86 +-
apps/web/src/app/metadata.ts | 164 +-
apps/web/src/app/page.tsx | 22 +-
apps/web/src/app/privacy/page.tsx | 512 +++---
apps/web/src/app/projects/page.tsx | 1552 +++++++++-------
apps/web/src/app/projects/store.ts | 129 ++
apps/web/src/app/roadmap/page.tsx | 371 ++--
apps/web/src/app/robots.ts | 18 +-
apps/web/src/app/rss.xml/route.ts | 78 +-
apps/web/src/app/sitemap.ts | 110 +-
apps/web/src/app/sponsors/page.tsx | 82 +
apps/web/src/app/terms/page.tsx | 580 +++---
apps/web/src/app/why-not-capcut/page.tsx | 191 --
apps/web/src/components/delete-project-dialog.tsx | 68 -
apps/web/src/components/editable-timecode.tsx | 154 ++
apps/web/src/components/editor/audio-waveform.tsx | 162 --
.../editor/dialogs/delete-project-dialog.tsx | 84 +
.../components/editor/dialogs/migration-dialog.tsx | 44 +
.../editor/dialogs/project-info-dialog.tsx | 83 +
.../editor/dialogs/rename-project-dialog.tsx | 72 +
.../components/editor/dialogs/shortcuts-dialog.tsx | 229 +++
apps/web/src/components/editor/editor-header.tsx | 279 +--
apps/web/src/components/editor/export-button.tsx | 588 +++---
.../src/components/editor/layout-guide-overlay.tsx | 30 +-
.../components/editor/media-panel/drag-overlay.tsx | 57 -
.../src/components/editor/media-panel/index.tsx | 52 -
.../web/src/components/editor/media-panel/store.ts | 86 -
.../src/components/editor/media-panel/tabbar.tsx | 99 -
.../editor/media-panel/views/captions.tsx | 303 ----
.../components/editor/media-panel/views/media.tsx | 545 ------
.../editor/media-panel/views/settings.tsx | 332 ----
.../components/editor/media-panel/views/sounds.tsx | 562 ------
.../editor/media-panel/views/stickers.tsx | 612 -------
.../components/editor/media-panel/views/text.tsx | 36 -
apps/web/src/components/editor/onboarding.tsx | 227 ++-
apps/web/src/components/editor/panel-base-view.tsx | 85 -
.../components/editor/panel-preset-selector.tsx | 91 -
.../editor/panels/assets/drag-overlay.tsx | 61 +
.../editor/panels/assets/draggable-item.tsx | 244 +++
.../src/components/editor/panels/assets/index.tsx | 52 +
.../src/components/editor/panels/assets/tabbar.tsx | 116 ++
.../editor/panels/assets/views/captions.tsx | 152 ++
.../editor/panels/assets/views/media.tsx | 675 +++++++
.../editor/panels/assets/views/settings.tsx | 394 ++++
.../editor/panels/assets/views/sounds.tsx | 574 ++++++
.../editor/panels/assets/views/stickers.tsx | 615 +++++++
.../components/editor/panels/assets/views/text.tsx | 46 +
.../components/editor/panels/panel-base-view.tsx | 85 +
.../src/components/editor/panels/preview/index.tsx | 124 ++
.../editor/panels/properties/audio-properties.tsx | 5 +
.../components/editor/panels/properties/index.tsx | 68 +
.../editor/panels/properties/property-item.tsx | 91 +
.../editor/panels/properties/text-properties.tsx | 410 +++++
.../editor/panels/properties/video-properties.tsx | 9 +
apps/web/src/components/editor/preview-panel.tsx | 1134 ------------
.../editor/properties-panel/audio-properties.tsx | 5 -
.../components/editor/properties-panel/index.tsx | 70 -
.../editor/properties-panel/media-properties.tsx | 5 -
.../editor/properties-panel/property-item.tsx | 86 -
.../editor/properties-panel/text-properties.tsx | 370 ----
apps/web/src/components/editor/scenes-view.tsx | 367 ++--
apps/web/src/components/editor/selection-box.tsx | 87 +-
apps/web/src/components/editor/snap-indicator.tsx | 77 -
apps/web/src/components/editor/speed-control.tsx | 46 -
.../components/editor/timeline/audio-waveform.tsx | 176 ++
.../src/components/editor/timeline/bookmarks.tsx | 111 ++
.../src/components/editor/timeline/drag-line.tsx | 28 +
apps/web/src/components/editor/timeline/index.tsx | 1421 ++++++---------
.../components/editor/timeline/snap-indicator.tsx | 52 +
.../editor/timeline/timeline-cache-indicator.tsx | 126 --
.../editor/timeline/timeline-element.tsx | 923 ++++++----
.../components/editor/timeline/timeline-marker.tsx | 67 -
.../editor/timeline/timeline-playhead.tsx | 244 +--
.../components/editor/timeline/timeline-ruler.tsx | 83 +
.../components/editor/timeline/timeline-tick.tsx | 39 +
.../editor/timeline/timeline-toolbar.tsx | 696 ++++---
.../components/editor/timeline/timeline-track.tsx | 1300 ++-----------
apps/web/src/components/footer.tsx | 234 ++-
.../src/components/gitHub-contribute-section.tsx | 47 +
apps/web/src/components/header-base.tsx | 41 -
apps/web/src/components/header.tsx | 180 +-
apps/web/src/components/icons.tsx | 233 ---
.../web/src/components/keyboard-shortcuts-help.tsx | 245 ---
apps/web/src/components/landing/handlebars.tsx | 258 ++-
apps/web/src/components/landing/hero.tsx | 103 +-
apps/web/src/components/language-select.tsx | 204 ---
.../src/components/providers/editor-provider.tsx | 169 +-
.../providers/migrators/scenes-migrator.tsx | 177 --
apps/web/src/components/rename-project-dialog.tsx | 73 -
apps/web/src/components/storage-provider.tsx | 111 +-
apps/web/src/components/theme-toggle.tsx | 41 +-
apps/web/src/components/ui/accordion.tsx | 64 +-
apps/web/src/components/ui/alert-dialog.tsx | 172 +-
apps/web/src/components/ui/alert.tsx | 83 +-
apps/web/src/components/ui/audio-player.tsx | 127 --
apps/web/src/components/ui/avatar.tsx | 56 +-
apps/web/src/components/ui/badge.tsx | 48 +-
apps/web/src/components/ui/breadcrumb.tsx | 146 +-
apps/web/src/components/ui/button.tsx | 94 +-
apps/web/src/components/ui/calendar.tsx | 114 +-
apps/web/src/components/ui/card.tsx | 90 +-
apps/web/src/components/ui/carousel.tsx | 388 ++--
apps/web/src/components/ui/chart.tsx | 628 +++----
apps/web/src/components/ui/checkbox.tsx | 34 +-
apps/web/src/components/ui/color-picker.tsx | 637 +++----
apps/web/src/components/ui/command.tsx | 191 +-
apps/web/src/components/ui/context-menu.tsx | 323 ++--
apps/web/src/components/ui/dialog.tsx | 166 +-
apps/web/src/components/ui/draggable-item.tsx | 247 ---
apps/web/src/components/ui/drawer.tsx | 138 +-
apps/web/src/components/ui/dropdown-menu.tsx | 326 ++--
apps/web/src/components/ui/editable-timecode.tsx | 138 --
apps/web/src/components/ui/font-picker.tsx | 64 +-
apps/web/src/components/ui/form.tsx | 234 +--
apps/web/src/components/ui/hover-card.tsx | 26 +-
.../src/components/ui/image-timeline-treatment.tsx | 102 --
apps/web/src/components/ui/input-otp.tsx | 92 +-
apps/web/src/components/ui/input-with-back.tsx | 142 +-
apps/web/src/components/ui/input.tsx | 225 ++-
apps/web/src/components/ui/label.tsx | 20 +-
apps/web/src/components/ui/menubar.tsx | 324 ++--
apps/web/src/components/ui/navigation-menu.tsx | 166 +-
apps/web/src/components/ui/pagination.tsx | 148 +-
apps/web/src/components/ui/phone-input.tsx | 246 +--
apps/web/src/components/ui/popover.tsx | 30 +-
apps/web/src/components/ui/progress.tsx | 32 +-
apps/web/src/components/ui/prose.tsx | 26 +-
apps/web/src/components/ui/radio-group.tsx | 52 +-
.../src/components/ui/react-markdown-wrapper.tsx | 26 +
apps/web/src/components/ui/resizable.tsx | 42 +-
apps/web/src/components/ui/scroll-area.tsx | 20 +-
apps/web/src/components/ui/select.tsx | 249 +--
apps/web/src/components/ui/separator.tsx | 38 +-
apps/web/src/components/ui/sheet.tsx | 182 +-
apps/web/src/components/ui/sidebar.tsx | 1440 +++++++--------
apps/web/src/components/ui/skeleton.tsx | 18 +-
apps/web/src/components/ui/slider.tsx | 32 +-
apps/web/src/components/ui/sonner.tsx | 46 +-
apps/web/src/components/ui/spinner.tsx | 17 +
apps/web/src/components/ui/split-button.tsx | 120 +-
apps/web/src/components/ui/sponsor-button.tsx | 39 -
apps/web/src/components/ui/switch.tsx | 34 +-
apps/web/src/components/ui/table.tsx | 150 +-
apps/web/src/components/ui/tabs.tsx | 62 +-
apps/web/src/components/ui/textarea.tsx | 30 +-
apps/web/src/components/ui/toast.tsx | 160 +-
apps/web/src/components/ui/toaster.tsx | 31 -
apps/web/src/components/ui/toggle-group.tsx | 74 +-
apps/web/src/components/ui/toggle.tsx | 56 +-
apps/web/src/components/ui/tooltip.tsx | 111 +-
apps/web/src/components/ui/video-player.tsx | 133 --
apps/web/src/constants/actions.ts | 295 ---
apps/web/src/constants/editor-constants.ts | 15 +
apps/web/src/constants/export-constants.ts | 12 +
apps/web/src/constants/font-constants.ts | 122 +-
apps/web/src/constants/language-constants.ts | 11 +
apps/web/src/constants/project-constants.ts | 27 +
apps/web/src/constants/site-constants.ts | 66 +
apps/web/src/constants/site.ts | 33 -
apps/web/src/constants/stickers-constants.ts | 16 +
apps/web/src/constants/text-constants.ts | 50 +-
apps/web/src/constants/timeline-constants.ts | 107 --
apps/web/src/constants/timeline-constants.tsx | 72 +
apps/web/src/constants/transcription-constants.ts | 58 +
apps/web/src/core/index.ts | 50 +
apps/web/src/core/managers/audio-manager.ts | 335 ++++
apps/web/src/core/managers/commands.ts | 44 +
apps/web/src/core/managers/media-manager.ts | 162 ++
apps/web/src/core/managers/playback-manager.ts | 162 ++
apps/web/src/core/managers/project-manager.ts | 648 +++++++
apps/web/src/core/managers/renderer-manager.ts | 129 ++
apps/web/src/core/managers/save-manager.ts | 107 ++
apps/web/src/core/managers/scenes-manager.ts | 316 ++++
apps/web/src/core/managers/selection-manager.ts | 35 +
apps/web/src/core/managers/timeline-manager.ts | 275 +++
apps/web/src/data/colors/pattern-craft.ts | 76 +-
apps/web/src/data/colors/solid.ts | 484 ++---
apps/web/src/data/colors/syntax-ui.tsx | 42 +-
apps/web/src/env.ts | 45 -
apps/web/src/hooks/actions/use-action-handler.ts | 52 +
apps/web/src/hooks/actions/use-editor-actions.ts | 312 ++++
apps/web/src/hooks/auth/useLogin.ts | 60 -
apps/web/src/hooks/auth/useSignUp.ts | 65 -
apps/web/src/hooks/storage/use-local-storage.ts | 58 +
.../timeline/element/use-element-interaction.ts | 619 +++++++
.../hooks/timeline/element/use-element-resize.ts | 331 ++++
.../timeline/element/use-element-selection.ts | 114 ++
.../web/src/hooks/timeline/use-edge-auto-scroll.ts | 100 +
apps/web/src/hooks/timeline/use-scroll-sync.ts | 173 ++
apps/web/src/hooks/timeline/use-selection-box.ts | 243 +++
.../hooks/timeline/use-snap-indicator-position.ts | 62 +
.../src/hooks/timeline/use-timeline-drag-drop.ts | 460 +++++
.../src/hooks/timeline/use-timeline-playhead.ts | 218 +++
apps/web/src/hooks/timeline/use-timeline-seek.ts | 197 ++
.../src/hooks/timeline/use-timeline-snapping.ts | 159 ++
apps/web/src/hooks/timeline/use-timeline-zoom.ts | 224 +++
apps/web/src/hooks/use-aspect-ratio.ts | 97 -
apps/web/src/hooks/use-drag-drop.ts | 85 -
apps/web/src/hooks/use-edge-auto-scroll.ts | 101 --
apps/web/src/hooks/use-editor-actions.ts | 241 ---
apps/web/src/hooks/use-editor.ts | 39 +
apps/web/src/hooks/use-file-upload.ts | 103 ++
apps/web/src/hooks/use-frame-cache.ts | 348 ----
apps/web/src/hooks/use-highlight-scroll.ts | 36 -
apps/web/src/hooks/use-infinite-scroll.ts | 46 +-
apps/web/src/hooks/use-keybinding-conflicts.ts | 59 -
apps/web/src/hooks/use-keybindings.ts | 102 +-
apps/web/src/hooks/use-keyboard-shortcuts-help.ts | 189 +-
apps/web/src/hooks/use-mobile.ts | 19 +
apps/web/src/hooks/use-mobile.tsx | 21 -
apps/web/src/hooks/use-playback-controls.ts | 109 --
apps/web/src/hooks/use-raf-loop.ts | 20 +
apps/web/src/hooks/use-reveal-item.ts | 36 +
apps/web/src/hooks/use-selection-box.ts | 215 ---
apps/web/src/hooks/use-sound-search.ts | 302 ++-
apps/web/src/hooks/use-timeline-element-resize.ts | 257 ---
apps/web/src/hooks/use-timeline-playhead.ts | 218 ---
apps/web/src/hooks/use-timeline-snapping.ts | 178 --
apps/web/src/hooks/use-timeline-zoom.ts | 60 -
apps/web/src/hooks/use-toast.ts | 191 --
apps/web/src/lib/actions/definitions.ts | 162 ++
apps/web/src/lib/actions/index.ts | 3 +
apps/web/src/lib/actions/registry.ts | 61 +
apps/web/src/lib/actions/types.ts | 42 +
apps/web/src/lib/auth/client.ts | 6 +
apps/web/src/lib/auth/server.ts | 43 +
apps/web/src/lib/blog-query.ts | 64 -
apps/web/src/lib/blog/query.ts | 72 +
apps/web/src/lib/canvas-gradients.ts | 219 ---
apps/web/src/lib/commands/base-command.ts | 11 +
apps/web/src/lib/commands/index.ts | 6 +
apps/web/src/lib/commands/media/add-media-asset.ts | 61 +
apps/web/src/lib/commands/media/index.ts | 2 +
.../src/lib/commands/media/remove-media-asset.ts | 85 +
apps/web/src/lib/commands/project/index.ts | 1 +
.../commands/project/update-project-settings.ts | 46 +
apps/web/src/lib/commands/scene/create-scene.ts | 40 +
apps/web/src/lib/commands/scene/delete-scene.ts | 59 +
apps/web/src/lib/commands/scene/index.ts | 5 +
apps/web/src/lib/commands/scene/remove-bookmark.ts | 59 +
apps/web/src/lib/commands/scene/rename-scene.ts | 46 +
apps/web/src/lib/commands/scene/toggle-bookmark.ts | 52 +
.../src/lib/commands/timeline/clipboard/index.ts | 1 +
.../src/lib/commands/timeline/clipboard/paste.ts | 263 +++
.../commands/timeline/element/delete-elements.ts | 48 +
.../timeline/element/duplicate-elements.ts | 114 ++
.../web/src/lib/commands/timeline/element/index.ts | 11 +
.../commands/timeline/element/insert-element.ts | 311 ++++
.../lib/commands/timeline/element/move-elements.ts | 127 ++
.../commands/timeline/element/split-elements.ts | 133 ++
.../timeline/element/toggle-elements-muted.ts | 57 +
.../timeline/element/toggle-elements-visibility.ts | 47 +
.../timeline/element/update-element-duration.ts | 37 +
.../timeline/element/update-element-start-time.ts | 48 +
.../timeline/element/update-element-trim.ts | 48 +
.../timeline/element/update-text-element.ts | 52 +
apps/web/src/lib/commands/timeline/index.ts | 3 +
.../src/lib/commands/timeline/track/add-track.ts | 53 +
apps/web/src/lib/commands/timeline/track/index.ts | 4 +
.../lib/commands/timeline/track/remove-track.ts | 35 +
.../commands/timeline/track/toggle-track-mute.ts | 39 +
.../timeline/track/toggle-track-visibility.ts | 40 +
apps/web/src/lib/db/index.ts | 19 +
apps/web/src/lib/db/schema.ts | 59 +
apps/web/src/lib/drag-data.ts | 48 +
apps/web/src/lib/editor-utils.ts | 46 -
apps/web/src/lib/export.ts | 313 +---
apps/web/src/lib/font-config.ts | 39 -
apps/web/src/lib/gradients/canvas.ts | 923 ++++++++++
apps/web/src/lib/gradients/index.ts | 2 +
apps/web/src/lib/gradients/parser.ts | 652 +++++++
apps/web/src/lib/iconify-api.ts | 367 ++--
apps/web/src/lib/media-processing.ts | 89 -
apps/web/src/lib/media/audio.ts | 459 +++++
apps/web/src/lib/media/media-utils.ts | 32 +
apps/web/src/lib/media/mediabunny.ts | 226 +++
apps/web/src/lib/media/processing.ts | 282 +++
apps/web/src/lib/mediabunny-utils.ts | 311 ----
apps/web/src/lib/rate-limit.ts | 21 +-
apps/web/src/lib/scenes.ts | 103 ++
apps/web/src/lib/schemas/waitlist.ts | 15 -
apps/web/src/lib/storage/indexeddb-adapter.ts | 89 -
apps/web/src/lib/storage/opfs-adapter.ts | 73 -
apps/web/src/lib/storage/storage-service.ts | 480 -----
apps/web/src/lib/storage/types.ts | 69 -
apps/web/src/lib/time.ts | 357 ++--
apps/web/src/lib/timeline-renderer.ts | 279 ---
apps/web/src/lib/timeline.ts | 54 -
apps/web/src/lib/timeline/bookmarks.ts | 61 +
apps/web/src/lib/timeline/drop-utils.ts | 234 +++
apps/web/src/lib/timeline/element-utils.ts | 304 ++++
apps/web/src/lib/timeline/index.ts | 23 +
apps/web/src/lib/timeline/ruler-utils.ts | 228 +++
apps/web/src/lib/timeline/track-utils.ts | 244 +++
apps/web/src/lib/timeline/zoom-utils.ts | 53 +
apps/web/src/lib/transcription-utils.ts | 13 -
apps/web/src/lib/transcription/caption.ts | 49 +
apps/web/src/lib/utils.ts | 80 -
apps/web/src/lib/video-cache.ts | 186 --
apps/web/src/lib/zk-encryption.ts | 71 -
apps/web/src/middleware.ts | 30 +-
apps/web/src/services/media/video-cache.ts | 314 ++++
apps/web/src/services/renderer/canvas-renderer.ts | 87 +
apps/web/src/services/renderer/nodes/base-node.ts | 35 +
.../renderer/nodes/blur-background-node.ts | 75 +
apps/web/src/services/renderer/nodes/color-node.ts | 31 +
apps/web/src/services/renderer/nodes/image-node.ts | 93 +
apps/web/src/services/renderer/nodes/root-node.ts | 11 +
.../src/services/renderer/nodes/sticker-node.ts | 87 +
apps/web/src/services/renderer/nodes/text-node.ts | 74 +
apps/web/src/services/renderer/nodes/video-node.ts | 84 +
apps/web/src/services/renderer/scene-builder.ts | 130 ++
apps/web/src/services/renderer/scene-exporter.ts | 152 ++
apps/web/src/services/storage/indexeddb-adapter.ts | 101 ++
apps/web/src/services/storage/migrations/base.ts | 5 +
apps/web/src/services/storage/migrations/index.ts | 14 +
apps/web/src/services/storage/migrations/runner.ts | 134 ++
.../src/services/storage/migrations/v0-to-v1.ts | 93 +
.../src/services/storage/migrations/v1-to-v2.ts | 339 ++++
.../src/services/storage/migrations/v2-to-v3.ts | 92 +
.../services/storage/migrations/version-manager.ts | 73 +
apps/web/src/services/storage/opfs-adapter.ts | 73 +
apps/web/src/services/storage/storage-service.ts | 475 +++++
apps/web/src/services/storage/types.ts | 66 +
apps/web/src/services/transcription/index.ts | 186 ++
apps/web/src/services/transcription/worker.ts | 176 ++
apps/web/src/stores/assets-panel-store.tsx | 107 ++
apps/web/src/stores/editor-store.ts | 129 +-
apps/web/src/stores/keybindings-store.ts | 475 +++--
.../web/src/stores/keybindings/migrations/index.ts | 28 +
.../src/stores/keybindings/migrations/v2-to-v3.ts | 26 +
apps/web/src/stores/media-store.ts | 296 ---
apps/web/src/stores/panel-store.ts | 300 +--
apps/web/src/stores/playback-store.ts | 174 --
apps/web/src/stores/project-store.ts | 571 ------
apps/web/src/stores/scene-store.ts | 318 ----
apps/web/src/stores/sounds-store.ts | 525 +++---
apps/web/src/stores/stickers-store.ts | 383 ++--
apps/web/src/stores/text-properties-store.ts | 30 +-
apps/web/src/stores/timeline-store.ts | 1913 +-------------------
apps/web/src/types/assets.ts | 9 +
apps/web/src/types/blog.ts | 92 +
apps/web/src/types/drag.ts | 21 +
apps/web/src/types/editor.ts | 29 +-
apps/web/src/types/export.ts | 33 +-
apps/web/src/types/keybinding.ts | 124 +-
apps/web/src/types/language.ts | 4 +
apps/web/src/types/media.ts | 17 -
apps/web/src/types/playback.ts | 21 -
apps/web/src/types/post.ts | 92 -
apps/web/src/types/project.ts | 74 +-
apps/web/src/types/sounds.ts | 62 +-
apps/web/src/types/stickers.ts | 3 +
apps/web/src/types/time.ts | 1 +
apps/web/src/types/timeline.ts | 330 ++--
apps/web/src/types/transcription.ts | 47 +
apps/web/src/utils/browser.ts | 17 +
apps/web/src/utils/date.ts | 7 +
apps/web/src/utils/geometry.ts | 13 +
apps/web/src/utils/id.ts | 28 +
apps/web/src/utils/math.ts | 11 +
apps/web/src/utils/platform.ts | 11 +
apps/web/src/utils/string.ts | 7 +
apps/web/src/utils/ui.ts | 6 +
apps/web/tsconfig.json | 7 +-
apps/web/tsconfig.tsbuildinfo | 2 +-
416 files changed, 33697 insertions(+), 31581 deletions(-)
delete mode 100644 apps/web/Dockerfile
create mode 100644 apps/web/migrations/0000_brainy_saracen.sql
delete mode 100644 apps/web/migrations/0000_hot_the_fallen.sql
delete mode 100644 apps/web/migrations/0001_tricky_jackpot.sql
delete mode 100644 apps/web/migrations/0002_cuddly_pretty_boy.sql
delete mode 100644 apps/web/migrations/0003_long_polaris.sql
delete mode 100644 apps/web/migrations/meta/0001_snapshot.json
delete mode 100644 apps/web/migrations/meta/0002_snapshot.json
delete mode 100644 apps/web/migrations/meta/0003_snapshot.json
create mode 100644 apps/web/next-env.d.ts
delete mode 100644 apps/web/public/frame.svg
delete mode 100644 apps/web/public/logo.png
delete mode 100644 apps/web/public/logo.svg
create mode 100644 apps/web/public/logos/opencut/1k/logo-black-with-text.png
create mode 100644 apps/web/public/logos/opencut/1k/logo-black.png
create mode 100644 apps/web/public/logos/opencut/1k/logo-white-with-text.png
create mode 100644 apps/web/public/logos/opencut/1k/logo-white.png
create mode 100644 apps/web/public/logos/opencut/2k/logo-black-with-text.png
create mode 100644 apps/web/public/logos/opencut/2k/logo-black.png
create mode 100644 apps/web/public/logos/opencut/2k/logo-white-with-text.png
create mode 100644 apps/web/public/logos/opencut/2k/logo-white.png
create mode 100644 apps/web/public/logos/opencut/4k/logo-black-with-text.png
create mode 100644 apps/web/public/logos/opencut/4k/logo-black.png
create mode 100644 apps/web/public/logos/opencut/4k/logo-white-with-text.png
create mode 100644 apps/web/public/logos/opencut/4k/logo-white.png
create mode 100644 apps/web/public/logos/opencut/svg/logo.svg
create mode 100644 apps/web/public/logos/others/fal.svg
create mode 100644 apps/web/public/logos/others/vercel.svg
delete mode 100644 apps/web/src/app/(auth)/login/page.tsx
delete mode 100644 apps/web/src/app/(auth)/signup/page.tsx
delete mode 100644 apps/web/src/app/animation/page.tsx
delete mode 100644 apps/web/src/app/api/get-upload-url/route.ts
delete mode 100644 apps/web/src/app/api/transcribe/route.ts
delete mode 100644 apps/web/src/app/api/waitlist/export/route.ts
create mode 100644 apps/web/src/app/base-page.tsx
delete mode 100644 apps/web/src/app/editor/[project_id]/layout.tsx
create mode 100644 apps/web/src/app/projects/store.ts
create mode 100644 apps/web/src/app/sponsors/page.tsx
delete mode 100644 apps/web/src/app/why-not-capcut/page.tsx
delete mode 100644 apps/web/src/components/delete-project-dialog.tsx
create mode 100644 apps/web/src/components/editable-timecode.tsx
delete mode 100644 apps/web/src/components/editor/audio-waveform.tsx
create mode 100644 apps/web/src/components/editor/dialogs/delete-project-dialog.tsx
create mode 100644 apps/web/src/components/editor/dialogs/migration-dialog.tsx
create mode 100644 apps/web/src/components/editor/dialogs/project-info-dialog.tsx
create mode 100644 apps/web/src/components/editor/dialogs/rename-project-dialog.tsx
create mode 100644 apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx
delete mode 100644 apps/web/src/components/editor/media-panel/drag-overlay.tsx
delete mode 100644 apps/web/src/components/editor/media-panel/index.tsx
delete mode 100644 apps/web/src/components/editor/media-panel/store.ts
delete mode 100644 apps/web/src/components/editor/media-panel/tabbar.tsx
delete mode 100644 apps/web/src/components/editor/media-panel/views/captions.tsx
delete mode 100644 apps/web/src/components/editor/media-panel/views/media.tsx
delete mode 100644 apps/web/src/components/editor/media-panel/views/settings.tsx
delete mode 100644 apps/web/src/components/editor/media-panel/views/sounds.tsx
delete mode 100644 apps/web/src/components/editor/media-panel/views/stickers.tsx
delete mode 100644 apps/web/src/components/editor/media-panel/views/text.tsx
delete mode 100644 apps/web/src/components/editor/panel-base-view.tsx
delete mode 100644 apps/web/src/components/editor/panel-preset-selector.tsx
create mode 100644 apps/web/src/components/editor/panels/assets/drag-overlay.tsx
create mode 100644 apps/web/src/components/editor/panels/assets/draggable-item.tsx
create mode 100644 apps/web/src/components/editor/panels/assets/index.tsx
create mode 100644 apps/web/src/components/editor/panels/assets/tabbar.tsx
create mode 100644 apps/web/src/components/editor/panels/assets/views/captions.tsx
create mode 100644 apps/web/src/components/editor/panels/assets/views/media.tsx
create mode 100644 apps/web/src/components/editor/panels/assets/views/settings.tsx
create mode 100644 apps/web/src/components/editor/panels/assets/views/sounds.tsx
create mode 100644 apps/web/src/components/editor/panels/assets/views/stickers.tsx
create mode 100644 apps/web/src/components/editor/panels/assets/views/text.tsx
create mode 100644 apps/web/src/components/editor/panels/panel-base-view.tsx
create mode 100644 apps/web/src/components/editor/panels/preview/index.tsx
create mode 100644 apps/web/src/components/editor/panels/properties/audio-properties.tsx
create mode 100644 apps/web/src/components/editor/panels/properties/index.tsx
create mode 100644 apps/web/src/components/editor/panels/properties/property-item.tsx
create mode 100644 apps/web/src/components/editor/panels/properties/text-properties.tsx
create mode 100644 apps/web/src/components/editor/panels/properties/video-properties.tsx
delete mode 100644 apps/web/src/components/editor/preview-panel.tsx
delete mode 100644 apps/web/src/components/editor/properties-panel/audio-properties.tsx
delete mode 100644 apps/web/src/components/editor/properties-panel/index.tsx
delete mode 100644 apps/web/src/components/editor/properties-panel/media-properties.tsx
delete mode 100644 apps/web/src/components/editor/properties-panel/property-item.tsx
delete mode 100644 apps/web/src/components/editor/properties-panel/text-properties.tsx
delete mode 100644 apps/web/src/components/editor/snap-indicator.tsx
delete mode 100644 apps/web/src/components/editor/speed-control.tsx
create mode 100644 apps/web/src/components/editor/timeline/audio-waveform.tsx
create mode 100644 apps/web/src/components/editor/timeline/bookmarks.tsx
create mode 100644 apps/web/src/components/editor/timeline/drag-line.tsx
create mode 100644 apps/web/src/components/editor/timeline/snap-indicator.tsx
delete mode 100644 apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx
delete mode 100644 apps/web/src/components/editor/timeline/timeline-marker.tsx
create mode 100644 apps/web/src/components/editor/timeline/timeline-ruler.tsx
create mode 100644 apps/web/src/components/editor/timeline/timeline-tick.tsx
create mode 100644 apps/web/src/components/gitHub-contribute-section.tsx
delete mode 100644 apps/web/src/components/header-base.tsx
delete mode 100644 apps/web/src/components/icons.tsx
delete mode 100644 apps/web/src/components/keyboard-shortcuts-help.tsx
delete mode 100644 apps/web/src/components/language-select.tsx
delete mode 100644 apps/web/src/components/providers/migrators/scenes-migrator.tsx
delete mode 100644 apps/web/src/components/rename-project-dialog.tsx
delete mode 100644 apps/web/src/components/ui/audio-player.tsx
delete mode 100644 apps/web/src/components/ui/draggable-item.tsx
delete mode 100644 apps/web/src/components/ui/editable-timecode.tsx
delete mode 100644 apps/web/src/components/ui/image-timeline-treatment.tsx
create mode 100644 apps/web/src/components/ui/react-markdown-wrapper.tsx
create mode 100644 apps/web/src/components/ui/spinner.tsx
delete mode 100644 apps/web/src/components/ui/sponsor-button.tsx
delete mode 100644 apps/web/src/components/ui/toaster.tsx
delete mode 100644 apps/web/src/components/ui/video-player.tsx
delete mode 100644 apps/web/src/constants/actions.ts
create mode 100644 apps/web/src/constants/editor-constants.ts
create mode 100644 apps/web/src/constants/export-constants.ts
create mode 100644 apps/web/src/constants/language-constants.ts
create mode 100644 apps/web/src/constants/project-constants.ts
create mode 100644 apps/web/src/constants/site-constants.ts
delete mode 100644 apps/web/src/constants/site.ts
create mode 100644 apps/web/src/constants/stickers-constants.ts
delete mode 100644 apps/web/src/constants/timeline-constants.ts
create mode 100644 apps/web/src/constants/timeline-constants.tsx
create mode 100644 apps/web/src/constants/transcription-constants.ts
create mode 100644 apps/web/src/core/index.ts
create mode 100644 apps/web/src/core/managers/audio-manager.ts
create mode 100644 apps/web/src/core/managers/commands.ts
create mode 100644 apps/web/src/core/managers/media-manager.ts
create mode 100644 apps/web/src/core/managers/playback-manager.ts
create mode 100644 apps/web/src/core/managers/project-manager.ts
create mode 100644 apps/web/src/core/managers/renderer-manager.ts
create mode 100644 apps/web/src/core/managers/save-manager.ts
create mode 100644 apps/web/src/core/managers/scenes-manager.ts
create mode 100644 apps/web/src/core/managers/selection-manager.ts
create mode 100644 apps/web/src/core/managers/timeline-manager.ts
delete mode 100644 apps/web/src/env.ts
create mode 100644 apps/web/src/hooks/actions/use-action-handler.ts
create mode 100644 apps/web/src/hooks/actions/use-editor-actions.ts
delete mode 100644 apps/web/src/hooks/auth/useLogin.ts
delete mode 100644 apps/web/src/hooks/auth/useSignUp.ts
create mode 100644 apps/web/src/hooks/storage/use-local-storage.ts
create mode 100644 apps/web/src/hooks/timeline/element/use-element-interaction.ts
create mode 100644 apps/web/src/hooks/timeline/element/use-element-resize.ts
create mode 100644 apps/web/src/hooks/timeline/element/use-element-selection.ts
create mode 100644 apps/web/src/hooks/timeline/use-edge-auto-scroll.ts
create mode 100644 apps/web/src/hooks/timeline/use-scroll-sync.ts
create mode 100644 apps/web/src/hooks/timeline/use-selection-box.ts
create mode 100644 apps/web/src/hooks/timeline/use-snap-indicator-position.ts
create mode 100644 apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
create mode 100644 apps/web/src/hooks/timeline/use-timeline-playhead.ts
create mode 100644 apps/web/src/hooks/timeline/use-timeline-seek.ts
create mode 100644 apps/web/src/hooks/timeline/use-timeline-snapping.ts
create mode 100644 apps/web/src/hooks/timeline/use-timeline-zoom.ts
delete mode 100644 apps/web/src/hooks/use-aspect-ratio.ts
delete mode 100644 apps/web/src/hooks/use-drag-drop.ts
delete mode 100644 apps/web/src/hooks/use-edge-auto-scroll.ts
delete mode 100644 apps/web/src/hooks/use-editor-actions.ts
create mode 100644 apps/web/src/hooks/use-editor.ts
create mode 100644 apps/web/src/hooks/use-file-upload.ts
delete mode 100644 apps/web/src/hooks/use-frame-cache.ts
delete mode 100644 apps/web/src/hooks/use-highlight-scroll.ts
delete mode 100644 apps/web/src/hooks/use-keybinding-conflicts.ts
create mode 100644 apps/web/src/hooks/use-mobile.ts
delete mode 100644 apps/web/src/hooks/use-mobile.tsx
delete mode 100644 apps/web/src/hooks/use-playback-controls.ts
create mode 100644 apps/web/src/hooks/use-raf-loop.ts
create mode 100644 apps/web/src/hooks/use-reveal-item.ts
delete mode 100644 apps/web/src/hooks/use-selection-box.ts
delete mode 100644 apps/web/src/hooks/use-timeline-element-resize.ts
delete mode 100644 apps/web/src/hooks/use-timeline-playhead.ts
delete mode 100644 apps/web/src/hooks/use-timeline-snapping.ts
delete mode 100644 apps/web/src/hooks/use-timeline-zoom.ts
delete mode 100644 apps/web/src/hooks/use-toast.ts
create mode 100644 apps/web/src/lib/actions/definitions.ts
create mode 100644 apps/web/src/lib/actions/index.ts
create mode 100644 apps/web/src/lib/actions/registry.ts
create mode 100644 apps/web/src/lib/actions/types.ts
create mode 100644 apps/web/src/lib/auth/client.ts
create mode 100644 apps/web/src/lib/auth/server.ts
delete mode 100644 apps/web/src/lib/blog-query.ts
create mode 100644 apps/web/src/lib/blog/query.ts
delete mode 100644 apps/web/src/lib/canvas-gradients.ts
create mode 100644 apps/web/src/lib/commands/base-command.ts
create mode 100644 apps/web/src/lib/commands/index.ts
create mode 100644 apps/web/src/lib/commands/media/add-media-asset.ts
create mode 100644 apps/web/src/lib/commands/media/index.ts
create mode 100644 apps/web/src/lib/commands/media/remove-media-asset.ts
create mode 100644 apps/web/src/lib/commands/project/index.ts
create mode 100644 apps/web/src/lib/commands/project/update-project-settings.ts
create mode 100644 apps/web/src/lib/commands/scene/create-scene.ts
create mode 100644 apps/web/src/lib/commands/scene/delete-scene.ts
create mode 100644 apps/web/src/lib/commands/scene/index.ts
create mode 100644 apps/web/src/lib/commands/scene/remove-bookmark.ts
create mode 100644 apps/web/src/lib/commands/scene/rename-scene.ts
create mode 100644 apps/web/src/lib/commands/scene/toggle-bookmark.ts
create mode 100644 apps/web/src/lib/commands/timeline/clipboard/index.ts
create mode 100644 apps/web/src/lib/commands/timeline/clipboard/paste.ts
create mode 100644 apps/web/src/lib/commands/timeline/element/delete-elements.ts
create mode 100644 apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
create mode 100644 apps/web/src/lib/commands/timeline/element/index.ts
create mode 100644 apps/web/src/lib/commands/timeline/element/insert-element.ts
create mode 100644 apps/web/src/lib/commands/timeline/element/move-elements.ts
create mode 100644 apps/web/src/lib/commands/timeline/element/split-elements.ts
create mode 100644 apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts
create mode 100644 apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts
create mode 100644 apps/web/src/lib/commands/timeline/element/update-element-duration.ts
create mode 100644 apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
create mode 100644 apps/web/src/lib/commands/timeline/element/update-element-trim.ts
create mode 100644 apps/web/src/lib/commands/timeline/element/update-text-element.ts
create mode 100644 apps/web/src/lib/commands/timeline/index.ts
create mode 100644 apps/web/src/lib/commands/timeline/track/add-track.ts
create mode 100644 apps/web/src/lib/commands/timeline/track/index.ts
create mode 100644 apps/web/src/lib/commands/timeline/track/remove-track.ts
create mode 100644 apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts
create mode 100644 apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts
create mode 100644 apps/web/src/lib/db/index.ts
create mode 100644 apps/web/src/lib/db/schema.ts
create mode 100644 apps/web/src/lib/drag-data.ts
delete mode 100644 apps/web/src/lib/editor-utils.ts
delete mode 100644 apps/web/src/lib/font-config.ts
create mode 100644 apps/web/src/lib/gradients/canvas.ts
create mode 100644 apps/web/src/lib/gradients/index.ts
create mode 100644 apps/web/src/lib/gradients/parser.ts
delete mode 100644 apps/web/src/lib/media-processing.ts
create mode 100644 apps/web/src/lib/media/audio.ts
create mode 100644 apps/web/src/lib/media/media-utils.ts
create mode 100644 apps/web/src/lib/media/mediabunny.ts
create mode 100644 apps/web/src/lib/media/processing.ts
delete mode 100644 apps/web/src/lib/mediabunny-utils.ts
create mode 100644 apps/web/src/lib/scenes.ts
delete mode 100644 apps/web/src/lib/schemas/waitlist.ts
delete mode 100644 apps/web/src/lib/storage/indexeddb-adapter.ts
delete mode 100644 apps/web/src/lib/storage/opfs-adapter.ts
delete mode 100644 apps/web/src/lib/storage/storage-service.ts
delete mode 100644 apps/web/src/lib/storage/types.ts
delete mode 100644 apps/web/src/lib/timeline-renderer.ts
delete mode 100644 apps/web/src/lib/timeline.ts
create mode 100644 apps/web/src/lib/timeline/bookmarks.ts
create mode 100644 apps/web/src/lib/timeline/drop-utils.ts
create mode 100644 apps/web/src/lib/timeline/element-utils.ts
create mode 100644 apps/web/src/lib/timeline/index.ts
create mode 100644 apps/web/src/lib/timeline/ruler-utils.ts
create mode 100644 apps/web/src/lib/timeline/track-utils.ts
create mode 100644 apps/web/src/lib/timeline/zoom-utils.ts
delete mode 100644 apps/web/src/lib/transcription-utils.ts
create mode 100644 apps/web/src/lib/transcription/caption.ts
delete mode 100644 apps/web/src/lib/utils.ts
delete mode 100644 apps/web/src/lib/video-cache.ts
delete mode 100644 apps/web/src/lib/zk-encryption.ts
create mode 100644 apps/web/src/services/media/video-cache.ts
create mode 100644 apps/web/src/services/renderer/canvas-renderer.ts
create mode 100644 apps/web/src/services/renderer/nodes/base-node.ts
create mode 100644 apps/web/src/services/renderer/nodes/blur-background-node.ts
create mode 100644 apps/web/src/services/renderer/nodes/color-node.ts
create mode 100644 apps/web/src/services/renderer/nodes/image-node.ts
create mode 100644 apps/web/src/services/renderer/nodes/root-node.ts
create mode 100644 apps/web/src/services/renderer/nodes/sticker-node.ts
create mode 100644 apps/web/src/services/renderer/nodes/text-node.ts
create mode 100644 apps/web/src/services/renderer/nodes/video-node.ts
create mode 100644 apps/web/src/services/renderer/scene-builder.ts
create mode 100644 apps/web/src/services/renderer/scene-exporter.ts
create mode 100644 apps/web/src/services/storage/indexeddb-adapter.ts
create mode 100644 apps/web/src/services/storage/migrations/base.ts
create mode 100644 apps/web/src/services/storage/migrations/index.ts
create mode 100644 apps/web/src/services/storage/migrations/runner.ts
create mode 100644 apps/web/src/services/storage/migrations/v0-to-v1.ts
create mode 100644 apps/web/src/services/storage/migrations/v1-to-v2.ts
create mode 100644 apps/web/src/services/storage/migrations/v2-to-v3.ts
create mode 100644 apps/web/src/services/storage/migrations/version-manager.ts
create mode 100644 apps/web/src/services/storage/opfs-adapter.ts
create mode 100644 apps/web/src/services/storage/storage-service.ts
create mode 100644 apps/web/src/services/storage/types.ts
create mode 100644 apps/web/src/services/transcription/index.ts
create mode 100644 apps/web/src/services/transcription/worker.ts
create mode 100644 apps/web/src/stores/assets-panel-store.tsx
create mode 100644 apps/web/src/stores/keybindings/migrations/index.ts
create mode 100644 apps/web/src/stores/keybindings/migrations/v2-to-v3.ts
delete mode 100644 apps/web/src/stores/media-store.ts
delete mode 100644 apps/web/src/stores/playback-store.ts
delete mode 100644 apps/web/src/stores/project-store.ts
delete mode 100644 apps/web/src/stores/scene-store.ts
create mode 100644 apps/web/src/types/assets.ts
create mode 100644 apps/web/src/types/blog.ts
create mode 100644 apps/web/src/types/drag.ts
create mode 100644 apps/web/src/types/language.ts
delete mode 100644 apps/web/src/types/media.ts
delete mode 100644 apps/web/src/types/playback.ts
delete mode 100644 apps/web/src/types/post.ts
create mode 100644 apps/web/src/types/stickers.ts
create mode 100644 apps/web/src/types/time.ts
create mode 100644 apps/web/src/types/transcription.ts
create mode 100644 apps/web/src/utils/browser.ts
create mode 100644 apps/web/src/utils/date.ts
create mode 100644 apps/web/src/utils/geometry.ts
create mode 100644 apps/web/src/utils/id.ts
create mode 100644 apps/web/src/utils/math.ts
create mode 100644 apps/web/src/utils/platform.ts
create mode 100644 apps/web/src/utils/string.ts
create mode 100644 apps/web/src/utils/ui.ts
(limited to 'apps/web')
diff --git a/apps/web/.env.example b/apps/web/.env.example
index e77d3c5a..b321f09a 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -1,33 +1,28 @@
-# Environment Variables Example
+# Environment variables example
# Copy this file to .env.local and update the values as needed
-DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut"
+# Node
+NODE_ENV=development
-# Better Auth
-NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000
-BETTER_AUTH_SECRET=your-secret-key-here
+# Public
+NEXT_PUBLIC_SITE_URL=http://localhost:3000
+NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
-# Development Environment
-NODE_ENV=development
+# Server
+DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut"
+BETTER_AUTH_SECRET=your_better_auth_secret
-# Redis
UPSTASH_REDIS_REST_URL=http://localhost:8079
-UPSTASH_REDIS_REST_TOKEN=example_token
+UPSTASH_REDIS_REST_TOKEN=example_token_here
-# Marble Blog
-MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key
-NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com
+MARBLE_WORKSPACE_KEY=your_workspace_key_here
-# Freesound (generate at https://freesound.org/apiv2/apply/)
-FREESOUND_CLIENT_ID=...
-FREESOUND_API_KEY=...
+FREESOUND_CLIENT_ID=your_client_id_here
+FREESOUND_API_KEY=your_api_key_here
-# Cloudflare R2 (for auto-captions/transcription)
-# Get these from Cloudflare Dashboard > R2 > Manage R2 API tokens
-CLOUDFLARE_ACCOUNT_ID=your-account-id
-R2_ACCESS_KEY_ID=your-access-key-id
-R2_SECRET_ACCESS_KEY=your-secret-access-key
-R2_BUCKET_NAME=opencut-transcription
+CLOUDFLARE_ACCOUNT_ID=your_account_id_here
+R2_ACCESS_KEY_ID=your_access_key_here
+R2_SECRET_ACCESS_KEY=your_secret_key_here
+R2_BUCKET_NAME=opencut-transcription # whatever you named your r2 bucket
-# Modal transcription endpoint (from modal deploy transcription.py)
-MODAL_TRANSCRIPTION_URL=https://your-username--opencut-transcription-transcribe-audio.modal.run
\ No newline at end of file
+MODAL_TRANSCRIPTION_URL=your_modal_url_here
\ No newline at end of file
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
index 36b42034..dc069c57 100644
--- a/apps/web/.gitignore
+++ b/apps/web/.gitignore
@@ -1,2 +1,8 @@
# Turborepo
-.turbo
\ No newline at end of file
+.turbo
+
+# Env vars
+.env*
+!.env.example
+
+.next/
\ No newline at end of file
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
deleted file mode 100644
index 6c42e708..00000000
--- a/apps/web/Dockerfile
+++ /dev/null
@@ -1,65 +0,0 @@
-FROM oven/bun:alpine AS base
-
-# Install dependencies and build the application
-FROM base AS builder
-
-WORKDIR /app
-
-ARG FREESOUND_CLIENT_ID
-ARG FREESOUND_API_KEY
-
-COPY package.json package.json
-COPY bun.lock bun.lock
-COPY turbo.json turbo.json
-
-COPY apps/web/package.json apps/web/package.json
-COPY packages/db/package.json packages/db/package.json
-COPY packages/auth/package.json packages/auth/package.json
-
-RUN bun install
-
-COPY apps/web/ apps/web/
-COPY packages/db/ packages/db/
-COPY packages/auth/ packages/auth/
-
-ENV NODE_ENV production
-ENV NEXT_TELEMETRY_DISABLED 1
-# 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"
-
-ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID
-ENV FREESOUND_API_KEY=$FREESOUND_API_KEY
-
-WORKDIR /app/apps/web
-RUN bun run build
-
-# Production image
-FROM base AS runner
-WORKDIR /app
-
-ENV NODE_ENV production
-ENV NEXT_TELEMETRY_DISABLED 1
-
-RUN addgroup --system --gid 1001 nodejs
-RUN adduser --system --uid 1001 nextjs
-
-COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
-COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
-COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
-
-RUN chown nextjs:nodejs apps
-
-USER nextjs
-
-EXPOSE 3000
-
-ENV PORT=3000
-ENV HOSTNAME="0.0.0.0"
-
-
-CMD ["bun", "apps/web/server.js"]
-
diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts
index a0602a74..2d53d6b9 100644
--- a/apps/web/drizzle.config.ts
+++ b/apps/web/drizzle.config.ts
@@ -1,19 +1,23 @@
import type { Config } from "drizzle-kit";
import * as dotenv from "dotenv";
+import { webEnv } from "@opencut/env/web";
// Load the right env file based on environment
-if (process.env.NODE_ENV === "production") {
+if (webEnv.NODE_ENV === "production") {
dotenv.config({ path: ".env.production" });
} else {
dotenv.config({ path: ".env.local" });
}
export default {
- schema: "../../packages/db/src/schema.ts",
+ schema: "./src/schema.ts",
dialect: "postgresql",
+ migrations: {
+ table: "drizzle_migrations",
+ },
dbCredentials: {
- url: process.env.DATABASE_URL!,
+ url: webEnv.DATABASE_URL,
},
out: "./migrations",
- strict: process.env.NODE_ENV === "production",
+ strict: webEnv.NODE_ENV === "production",
} satisfies Config;
diff --git a/apps/web/migrations/0000_brainy_saracen.sql b/apps/web/migrations/0000_brainy_saracen.sql
new file mode 100644
index 00000000..2c6ab2b2
--- /dev/null
+++ b/apps/web/migrations/0000_brainy_saracen.sql
@@ -0,0 +1,62 @@
+CREATE TABLE "accounts" (
+ "id" text PRIMARY KEY NOT NULL,
+ "account_id" text NOT NULL,
+ "provider_id" text NOT NULL,
+ "user_id" text NOT NULL,
+ "access_token" text,
+ "refresh_token" text,
+ "id_token" text,
+ "access_token_expires_at" timestamp,
+ "refresh_token_expires_at" timestamp,
+ "scope" text,
+ "password" text,
+ "created_at" timestamp NOT NULL,
+ "updated_at" timestamp NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "accounts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
+CREATE TABLE "sessions" (
+ "id" text PRIMARY KEY NOT NULL,
+ "expires_at" timestamp NOT NULL,
+ "token" text NOT NULL,
+ "created_at" timestamp NOT NULL,
+ "updated_at" timestamp NOT NULL,
+ "ip_address" text,
+ "user_agent" text,
+ "user_id" text NOT NULL,
+ CONSTRAINT "sessions_token_unique" UNIQUE("token")
+);
+--> statement-breakpoint
+ALTER TABLE "sessions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
+CREATE TABLE "users" (
+ "id" text PRIMARY KEY NOT NULL,
+ "name" text NOT NULL,
+ "email" text NOT NULL,
+ "email_verified" boolean NOT NULL,
+ "image" text,
+ "created_at" timestamp NOT NULL,
+ "updated_at" timestamp NOT NULL,
+ CONSTRAINT "users_email_unique" UNIQUE("email")
+);
+--> statement-breakpoint
+ALTER TABLE "users" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
+CREATE TABLE "verifications" (
+ "id" text PRIMARY KEY NOT NULL,
+ "identifier" text NOT NULL,
+ "value" text NOT NULL,
+ "expires_at" timestamp NOT NULL,
+ "created_at" timestamp,
+ "updated_at" timestamp
+);
+--> statement-breakpoint
+ALTER TABLE "verifications" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
+CREATE TABLE "waitlist" (
+ "id" text PRIMARY KEY NOT NULL,
+ "email" text NOT NULL,
+ "created_at" timestamp NOT NULL,
+ CONSTRAINT "waitlist_email_unique" UNIQUE("email")
+);
+--> statement-breakpoint
+ALTER TABLE "waitlist" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
+ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
\ No newline at end of file
diff --git a/apps/web/migrations/0000_hot_the_fallen.sql b/apps/web/migrations/0000_hot_the_fallen.sql
deleted file mode 100644
index 321043f0..00000000
--- a/apps/web/migrations/0000_hot_the_fallen.sql
+++ /dev/null
@@ -1,54 +0,0 @@
-CREATE TABLE "accounts" (
- "id" text PRIMARY KEY NOT NULL,
- "account_id" text NOT NULL,
- "provider_id" text NOT NULL,
- "user_id" text NOT NULL,
- "access_token" text,
- "refresh_token" text,
- "id_token" text,
- "access_token_expires_at" timestamp,
- "refresh_token_expires_at" timestamp,
- "scope" text,
- "password" text,
- "created_at" timestamp NOT NULL,
- "updated_at" timestamp NOT NULL
-);
---> statement-breakpoint
-ALTER TABLE "accounts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
-CREATE TABLE "sessions" (
- "id" text PRIMARY KEY NOT NULL,
- "expires_at" timestamp NOT NULL,
- "token" text NOT NULL,
- "created_at" timestamp NOT NULL,
- "updated_at" timestamp NOT NULL,
- "ip_address" text,
- "user_agent" text,
- "user_id" text NOT NULL,
- CONSTRAINT "sessions_token_unique" UNIQUE("token")
-);
---> statement-breakpoint
-ALTER TABLE "sessions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
-CREATE TABLE "users" (
- "id" text PRIMARY KEY NOT NULL,
- "name" text NOT NULL,
- "email" text NOT NULL,
- "email_verified" boolean NOT NULL,
- "image" text,
- "created_at" timestamp NOT NULL,
- "updated_at" timestamp NOT NULL,
- CONSTRAINT "users_email_unique" UNIQUE("email")
-);
---> statement-breakpoint
-ALTER TABLE "users" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
-CREATE TABLE "verifications" (
- "id" text PRIMARY KEY NOT NULL,
- "identifier" text NOT NULL,
- "value" text NOT NULL,
- "expires_at" timestamp NOT NULL,
- "created_at" timestamp,
- "updated_at" timestamp
-);
---> statement-breakpoint
-ALTER TABLE "verifications" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
-ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
-ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
\ No newline at end of file
diff --git a/apps/web/migrations/0001_tricky_jackpot.sql b/apps/web/migrations/0001_tricky_jackpot.sql
deleted file mode 100644
index 6eaeb56e..00000000
--- a/apps/web/migrations/0001_tricky_jackpot.sql
+++ /dev/null
@@ -1,8 +0,0 @@
-CREATE TABLE "waitlist" (
- "id" text PRIMARY KEY NOT NULL,
- "email" text NOT NULL,
- "created_at" timestamp NOT NULL,
- CONSTRAINT "waitlist_email_unique" UNIQUE("email")
-);
---> statement-breakpoint
-ALTER TABLE "waitlist" ENABLE ROW LEVEL SECURITY;
\ No newline at end of file
diff --git a/apps/web/migrations/0002_cuddly_pretty_boy.sql b/apps/web/migrations/0002_cuddly_pretty_boy.sql
deleted file mode 100644
index fc8aed79..00000000
--- a/apps/web/migrations/0002_cuddly_pretty_boy.sql
+++ /dev/null
@@ -1 +0,0 @@
-ALTER TABLE "users" ALTER COLUMN "email_verified" SET DEFAULT false;
\ No newline at end of file
diff --git a/apps/web/migrations/0003_long_polaris.sql b/apps/web/migrations/0003_long_polaris.sql
deleted file mode 100644
index b2b74064..00000000
--- a/apps/web/migrations/0003_long_polaris.sql
+++ /dev/null
@@ -1,10 +0,0 @@
-CREATE TABLE "export_waitlist" (
- "id" text PRIMARY KEY NOT NULL,
- "email" text NOT NULL,
- "created_at" timestamp NOT NULL,
- "updated_at" timestamp NOT NULL,
- CONSTRAINT "export_waitlist_email_unique" UNIQUE("email")
-);
---> statement-breakpoint
-ALTER TABLE "export_waitlist" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
-DROP TABLE "waitlist" CASCADE;
\ No newline at end of file
diff --git a/apps/web/migrations/meta/0000_snapshot.json b/apps/web/migrations/meta/0000_snapshot.json
index 2da63d5c..8d505274 100644
--- a/apps/web/migrations/meta/0000_snapshot.json
+++ b/apps/web/migrations/meta/0000_snapshot.json
@@ -1,5 +1,5 @@
{
- "id": "4440fb90-cf0e-4cb2-afad-08250ce3dc1e",
+ "id": "33a6742f-89da-4ac5-958f-421aa1cf9bd6",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -291,6 +291,43 @@
"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": {},
diff --git a/apps/web/migrations/meta/0001_snapshot.json b/apps/web/migrations/meta/0001_snapshot.json
deleted file mode 100644
index 008540ad..00000000
--- a/apps/web/migrations/meta/0001_snapshot.json
+++ /dev/null
@@ -1,344 +0,0 @@
-{
- "id": "b7d920ca-6dd0-430f-8ee6-1d38fdf3e80f",
- "prevId": "4440fb90-cf0e-4cb2-afad-08250ce3dc1e",
- "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
- },
- "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": {}
- }
-}
diff --git a/apps/web/migrations/meta/0002_snapshot.json b/apps/web/migrations/meta/0002_snapshot.json
deleted file mode 100644
index 42512564..00000000
--- a/apps/web/migrations/meta/0002_snapshot.json
+++ /dev/null
@@ -1,345 +0,0 @@
-{
- "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": {}
- }
-}
diff --git a/apps/web/migrations/meta/0003_snapshot.json b/apps/web/migrations/meta/0003_snapshot.json
deleted file mode 100644
index 2c5d986e..00000000
--- a/apps/web/migrations/meta/0003_snapshot.json
+++ /dev/null
@@ -1,365 +0,0 @@
-{
- "id": "71b84015-78f8-4d07-9115-8d56ea550459",
- "prevId": "1b9148ed-497b-4e53-8cf6-f556c8fe7f7f",
- "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.export_waitlist": {
- "name": "export_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
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "export_waitlist_email_unique": {
- "name": "export_waitlist_email_unique",
- "nullsNotDistinct": false,
- "columns": [
- "email"
- ]
- }
- },
- "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
- }
- },
- "enums": {},
- "schemas": {},
- "sequences": {},
- "roles": {},
- "policies": {},
- "views": {},
- "_meta": {
- "columns": {},
- "schemas": {},
- "tables": {}
- }
-}
\ No newline at end of file
diff --git a/apps/web/migrations/meta/_journal.json b/apps/web/migrations/meta/_journal.json
index c172952e..c7d30f16 100644
--- a/apps/web/migrations/meta/_journal.json
+++ b/apps/web/migrations/meta/_journal.json
@@ -5,30 +5,9 @@
{
"idx": 0,
"version": "7",
- "when": 1750581188229,
- "tag": "0000_hot_the_fallen",
- "breakpoints": true
- },
- {
- "idx": 1,
- "version": "7",
- "when": 1750689835736,
- "tag": "0001_tricky_jackpot",
- "breakpoints": true
- },
- {
- "idx": 2,
- "version": "7",
- "when": 1752569400809,
- "tag": "0002_cuddly_pretty_boy",
- "breakpoints": true
- },
- {
- "idx": 3,
- "version": "7",
- "when": 1755377469662,
- "tag": "0003_long_polaris",
+ "when": 1750753385927,
+ "tag": "0000_brainy_saracen",
"breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
new file mode 100644
index 00000000..c4b7818f
--- /dev/null
+++ b/apps/web/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+import "./.next/dev/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/web/package.json b/apps/web/package.json
index 25dc9aee..aa752a81 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,5 +1,5 @@
{
- "name": "opencut",
+ "name": "@opencut/web",
"version": "0.1.0",
"private": true,
"packageManager": "bun@1.2.18",
@@ -21,13 +21,18 @@
"@ffmpeg/util": "^0.12.2",
"@hello-pangea/dnd": "^18.0.1",
"@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",
+ "@hugeicons/core-free-icons": "^3.1.1",
+ "@hugeicons/react": "^1.1.4",
+ "@huggingface/transformers": "^3.8.1",
+ "@opencut/env": "workspace:*",
+ "@opencut/ui": "workspace:*",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-separator": "^1.1.8",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@upstash/ratelimit": "^2.0.6",
+ "@upstash/redis": "^1.35.4",
"@vercel/analytics": "^1.4.1",
"aws4fetch": "^1.0.20",
"better-auth": "^1.2.7",
@@ -36,18 +41,19 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"dayjs": "^1.11.13",
- "dotenv": "^16.5.0",
"drizzle-orm": "^0.44.2",
"embla-carousel-react": "^8.5.1",
+ "eventemitter3": "^5.0.1",
"feed": "^5.1.0",
- "framer-motion": "^11.13.1",
"input-otp": "^1.4.1",
- "lucide-react": "^0.468.0",
+ "lucide-react": "^0.562.0",
+ "mediabunny": "^1.29.1",
"motion": "^12.18.1",
"nanoid": "^5.1.5",
- "next": "^15.5.7",
+ "next": "16.1.3",
"next-themes": "^0.4.4",
"pg": "^8.16.2",
+ "postgres": "^3.4.5",
"radix-ui": "^1.4.2",
"react": "^18.2.0",
"react-day-picker": "^8.10.1",
@@ -67,7 +73,9 @@
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5",
+ "use-deep-compare-effect": "^1.8.1",
"vaul": "^1.1.1",
+ "wavesurfer.js": "^7.9.8",
"zod": "^3.25.67",
"zustand": "^5.0.2"
},
@@ -81,6 +89,7 @@
"@types/react-dom": "^18.2.18",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.31.4",
+ "dotenv": "^16.5.0",
"postcss": "^8",
"tailwindcss": "^4.1.11",
"tsx": "^4.7.1",
diff --git a/apps/web/public/frame.svg b/apps/web/public/frame.svg
deleted file mode 100644
index 4dac68e9..00000000
--- a/apps/web/public/frame.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/web/public/logo.png b/apps/web/public/logo.png
deleted file mode 100644
index fc9e7902..00000000
Binary files a/apps/web/public/logo.png and /dev/null differ
diff --git a/apps/web/public/logo.svg b/apps/web/public/logo.svg
deleted file mode 100644
index f4079a5e..00000000
--- a/apps/web/public/logo.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/web/public/logos/opencut/1k/logo-black-with-text.png b/apps/web/public/logos/opencut/1k/logo-black-with-text.png
new file mode 100644
index 00000000..08ceb702
Binary files /dev/null and b/apps/web/public/logos/opencut/1k/logo-black-with-text.png differ
diff --git a/apps/web/public/logos/opencut/1k/logo-black.png b/apps/web/public/logos/opencut/1k/logo-black.png
new file mode 100644
index 00000000..6b21beb9
Binary files /dev/null and b/apps/web/public/logos/opencut/1k/logo-black.png differ
diff --git a/apps/web/public/logos/opencut/1k/logo-white-with-text.png b/apps/web/public/logos/opencut/1k/logo-white-with-text.png
new file mode 100644
index 00000000..a3151060
Binary files /dev/null and b/apps/web/public/logos/opencut/1k/logo-white-with-text.png differ
diff --git a/apps/web/public/logos/opencut/1k/logo-white.png b/apps/web/public/logos/opencut/1k/logo-white.png
new file mode 100644
index 00000000..f4f6ae4c
Binary files /dev/null and b/apps/web/public/logos/opencut/1k/logo-white.png differ
diff --git a/apps/web/public/logos/opencut/2k/logo-black-with-text.png b/apps/web/public/logos/opencut/2k/logo-black-with-text.png
new file mode 100644
index 00000000..7493bed6
Binary files /dev/null and b/apps/web/public/logos/opencut/2k/logo-black-with-text.png differ
diff --git a/apps/web/public/logos/opencut/2k/logo-black.png b/apps/web/public/logos/opencut/2k/logo-black.png
new file mode 100644
index 00000000..1336d172
Binary files /dev/null and b/apps/web/public/logos/opencut/2k/logo-black.png differ
diff --git a/apps/web/public/logos/opencut/2k/logo-white-with-text.png b/apps/web/public/logos/opencut/2k/logo-white-with-text.png
new file mode 100644
index 00000000..29bf0e6f
Binary files /dev/null and b/apps/web/public/logos/opencut/2k/logo-white-with-text.png differ
diff --git a/apps/web/public/logos/opencut/2k/logo-white.png b/apps/web/public/logos/opencut/2k/logo-white.png
new file mode 100644
index 00000000..9ee83d3c
Binary files /dev/null and b/apps/web/public/logos/opencut/2k/logo-white.png differ
diff --git a/apps/web/public/logos/opencut/4k/logo-black-with-text.png b/apps/web/public/logos/opencut/4k/logo-black-with-text.png
new file mode 100644
index 00000000..edaf5358
Binary files /dev/null and b/apps/web/public/logos/opencut/4k/logo-black-with-text.png differ
diff --git a/apps/web/public/logos/opencut/4k/logo-black.png b/apps/web/public/logos/opencut/4k/logo-black.png
new file mode 100644
index 00000000..b5f0ff13
Binary files /dev/null and b/apps/web/public/logos/opencut/4k/logo-black.png differ
diff --git a/apps/web/public/logos/opencut/4k/logo-white-with-text.png b/apps/web/public/logos/opencut/4k/logo-white-with-text.png
new file mode 100644
index 00000000..90596e87
Binary files /dev/null and b/apps/web/public/logos/opencut/4k/logo-white-with-text.png differ
diff --git a/apps/web/public/logos/opencut/4k/logo-white.png b/apps/web/public/logos/opencut/4k/logo-white.png
new file mode 100644
index 00000000..794e360b
Binary files /dev/null and b/apps/web/public/logos/opencut/4k/logo-white.png differ
diff --git a/apps/web/public/logos/opencut/svg/logo.svg b/apps/web/public/logos/opencut/svg/logo.svg
new file mode 100644
index 00000000..f4079a5e
--- /dev/null
+++ b/apps/web/public/logos/opencut/svg/logo.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/public/logos/others/fal.svg b/apps/web/public/logos/others/fal.svg
new file mode 100644
index 00000000..abc56513
--- /dev/null
+++ b/apps/web/public/logos/others/fal.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/web/public/logos/others/vercel.svg b/apps/web/public/logos/others/vercel.svg
new file mode 100644
index 00000000..824018a3
--- /dev/null
+++ b/apps/web/public/logos/others/vercel.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx
deleted file mode 100644
index f28cd191..00000000
--- a/apps/web/src/app/(auth)/login/page.tsx
+++ /dev/null
@@ -1,146 +0,0 @@
-"use client";
-
-import { useRouter } from "next/navigation";
-import { Button } from "@/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { memo, Suspense } from "react";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Separator } from "@/components/ui/separator";
-import Link from "next/link";
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
-import { ArrowLeft, Loader2 } from "lucide-react";
-import { GoogleIcon } from "@/components/icons";
-import { useLogin } from "@/hooks/auth/useLogin";
-
-const LoginPage = () => {
- const router = useRouter();
- const {
- email,
- setEmail,
- password,
- setPassword,
- error,
- isAnyLoading,
- isEmailLoading,
- isGoogleLoading,
- handleLogin,
- handleGoogleLogin,
- } = useLogin();
-
- return (
-
-
router.back()}
- className="absolute top-6 left-6"
- >
- Back
-
-
-
- Welcome back
-
- Sign in to your account to continue
-
-
-
-
-
-
- }
- >
-
- {error && (
-
- Error
- {error}
-
- )}
-
-
- {isGoogleLoading ? (
-
- ) : (
-
- )}{" "}
- Continue with Google
-
-
-
-
-
-
-
- Or continue with
-
-
-
-
-
- Email
- setEmail(e.target.value)}
- disabled={isAnyLoading}
- className="h-11"
- />
-
-
- Password
- setPassword(e.target.value)}
- disabled={isAnyLoading}
- className="h-11"
- />
-
-
- {isEmailLoading ? (
-
- ) : (
- "Sign in"
- )}
-
-
-
-
- Don't have an account?{" "}
-
- Sign up
-
-
-
-
-
-
- );
-};
-
-export default memo(LoginPage);
diff --git a/apps/web/src/app/(auth)/signup/page.tsx b/apps/web/src/app/(auth)/signup/page.tsx
deleted file mode 100644
index c3a0f899..00000000
--- a/apps/web/src/app/(auth)/signup/page.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-"use client";
-
-import { useRouter } from "next/navigation";
-import { Button } from "@/components/ui/button";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { memo, Suspense } from "react";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Separator } from "@/components/ui/separator";
-import Link from "next/link";
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
-import { ArrowLeft, Loader2 } from "lucide-react";
-import { GoogleIcon } from "@/components/icons";
-import { useSignUp } from "@/hooks/auth/useSignUp";
-
-const SignUpPage = () => {
- const router = useRouter();
- const {
- name,
- setName,
- email,
- setEmail,
- password,
- setPassword,
- error,
- isAnyLoading,
- isEmailLoading,
- isGoogleLoading,
- handleSignUp,
- handleGoogleSignUp,
- } = useSignUp();
-
- return (
-
-
router.back()}
- className="absolute top-6 left-6"
- >
- Back
-
-
-
-
- Create your account
-
-
- Get started with your free account today
-
-
-
-
-
-
- }
- >
-
- {error && (
-
- Error
- {error}
-
- )}
-
- {isGoogleLoading ? (
-
- ) : (
-
- )}{" "}
- Continue with Google
-
-
-
-
-
-
-
- Or continue with
-
-
-
-
-
-
- Already have an account?{" "}
-
- Sign in
-
-
-
-
-
-
- );
-};
-
-export default memo(SignUpPage);
diff --git a/apps/web/src/app/animation/page.tsx b/apps/web/src/app/animation/page.tsx
deleted file mode 100644
index 37be1ef0..00000000
--- a/apps/web/src/app/animation/page.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-"use client";
-
-import { InputWithBack } from "@/components/ui/input-with-back";
-import { useState } from "react";
-
-export default function AnimationPage() {
- const [isExpanded, setIsExpanded] = useState(false);
-
- return (
-
-
-
-
-
-
setIsExpanded(!isExpanded)}
- className="cursor-pointer hover:opacity-75 transition-opacity"
- >
- {isExpanded ? "Collapse" : "Expand"}
-
-
-
- );
-}
diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts
index 5deef204..e17d0cce 100644
--- a/apps/web/src/app/api/auth/[...all]/route.ts
+++ b/apps/web/src/app/api/auth/[...all]/route.ts
@@ -1,4 +1,4 @@
-import { auth } from "@opencut/auth";
+import { auth } from "@/lib/auth/server";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);
diff --git a/apps/web/src/app/api/get-upload-url/route.ts b/apps/web/src/app/api/get-upload-url/route.ts
deleted file mode 100644
index dc5b7328..00000000
--- a/apps/web/src/app/api/get-upload-url/route.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import { z } from "zod";
-import { AwsClient } from "aws4fetch";
-import { nanoid } from "nanoid";
-import { env } from "@/env";
-import { baseRateLimit } from "@/lib/rate-limit";
-import { isTranscriptionConfigured } from "@/lib/transcription-utils";
-
-const uploadRequestSchema = z.object({
- fileExtension: z.enum(["wav", "mp3", "m4a", "flac"], {
- errorMap: () => ({
- message: "File extension must be wav, mp3, m4a, or flac",
- }),
- }),
-});
-
-const apiResponseSchema = z.object({
- uploadUrl: z.string().url(),
- fileName: z.string().min(1),
-});
-
-export async function POST(request: NextRequest) {
- try {
- // Rate limiting
- const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
- const { success } = await baseRateLimit.limit(ip);
-
- if (!success) {
- return NextResponse.json({ error: "Too many requests" }, { status: 429 });
- }
-
- // Check transcription configuration
- const transcriptionCheck = isTranscriptionConfigured();
- if (!transcriptionCheck.configured) {
- console.error(
- "Missing environment variables:",
- JSON.stringify(transcriptionCheck.missingVars)
- );
-
- return NextResponse.json(
- {
- error: "Transcription not configured",
- message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`,
- },
- { status: 503 }
- );
- }
-
- // Parse and validate request body
- const rawBody = await request.json().catch(() => null);
- if (!rawBody) {
- return NextResponse.json(
- { error: "Invalid JSON in request body" },
- { status: 400 }
- );
- }
-
- const validationResult = uploadRequestSchema.safeParse(rawBody);
- if (!validationResult.success) {
- return NextResponse.json(
- {
- error: "Invalid request parameters",
- details: validationResult.error.flatten().fieldErrors,
- },
- { status: 400 }
- );
- }
-
- const { fileExtension } = validationResult.data;
-
- // Initialize R2 client
- const client = new AwsClient({
- accessKeyId: env.R2_ACCESS_KEY_ID,
- secretAccessKey: env.R2_SECRET_ACCESS_KEY,
- });
-
- // Generate unique filename with timestamp
- const timestamp = Date.now();
- const fileName = `audio/${timestamp}-${nanoid()}.${fileExtension}`;
-
- // Create presigned URL
- const url = new URL(
- `https://${env.R2_BUCKET_NAME}.${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com/${fileName}`
- );
-
- url.searchParams.set("X-Amz-Expires", "3600"); // 1 hour expiry
-
- const signed = await client.sign(new Request(url, { method: "PUT" }), {
- aws: { signQuery: true },
- });
-
- if (!signed.url) {
- throw new Error("Failed to generate presigned URL");
- }
-
- // Prepare and validate response
- const responseData = {
- uploadUrl: signed.url,
- fileName,
- };
-
- const responseValidation = apiResponseSchema.safeParse(responseData);
- if (!responseValidation.success) {
- console.error(
- "Invalid API response structure:",
- responseValidation.error
- );
- return NextResponse.json(
- { error: "Internal response formatting error" },
- { status: 500 }
- );
- }
-
- return NextResponse.json(responseValidation.data);
- } catch (error) {
- console.error("Error generating upload URL:", error);
- return NextResponse.json(
- {
- error: "Failed to generate upload URL",
- message:
- error instanceof Error
- ? error.message
- : "An unexpected error occurred",
- },
- { status: 500 }
- );
- }
-}
diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts
index ecb9ce04..edb40a0d 100644
--- a/apps/web/src/app/api/health/route.ts
+++ b/apps/web/src/app/api/health/route.ts
@@ -1,5 +1,3 @@
-import { NextRequest } from "next/server";
-
-export async function GET(request: NextRequest) {
- return new Response("OK", { status: 200 });
+export async function GET() {
+ return new Response("OK", { status: 200 });
}
diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts
index c89bc76c..3070d152 100644
--- a/apps/web/src/app/api/sounds/search/route.ts
+++ b/apps/web/src/app/api/sounds/search/route.ts
@@ -1,265 +1,280 @@
-import { NextRequest, NextResponse } from "next/server";
+import { webEnv } from "@opencut/env/web";
+import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
-import { env } from "@/env";
-import { baseRateLimit } from "@/lib/rate-limit";
+import { checkRateLimit } from "@/lib/rate-limit";
const searchParamsSchema = z.object({
- q: z.string().max(500, "Query too long").optional(),
- type: z.enum(["songs", "effects"]).optional(),
- page: z.coerce.number().int().min(1).max(1000).default(1),
- page_size: z.coerce.number().int().min(1).max(150).default(20),
- sort: z
- .enum(["downloads", "rating", "created", "score"])
- .default("downloads"),
- min_rating: z.coerce.number().min(0).max(5).default(3),
- commercial_only: z.coerce.boolean().default(true),
+ q: z.string().max(500, "Query too long").optional(),
+ type: z.enum(["songs", "effects"]).optional(),
+ page: z.coerce.number().int().min(1).max(1000).default(1),
+ page_size: z.coerce.number().int().min(1).max(150).default(20),
+ sort: z
+ .enum(["downloads", "rating", "created", "score"])
+ .default("downloads"),
+ min_rating: z.coerce.number().min(0).max(5).default(3),
+ commercial_only: z.coerce.boolean().default(true),
});
const freesoundResultSchema = z.object({
- id: z.number(),
- name: z.string(),
- description: z.string(),
- url: z.string().url(),
- previews: z
- .object({
- "preview-hq-mp3": z.string().url(),
- "preview-lq-mp3": z.string().url(),
- "preview-hq-ogg": z.string().url(),
- "preview-lq-ogg": z.string().url(),
- })
- .optional(),
- download: z.string().url().optional(),
- duration: z.number(),
- filesize: z.number(),
- type: z.string(),
- channels: z.number(),
- bitrate: z.number(),
- bitdepth: z.number(),
- samplerate: z.number(),
- username: z.string(),
- tags: z.array(z.string()),
- license: z.string(),
- created: z.string(),
- num_downloads: z.number().optional(),
- avg_rating: z.number().optional(),
- num_ratings: z.number().optional(),
+ id: z.number(),
+ name: z.string(),
+ description: z.string(),
+ url: z.string().url(),
+ previews: z
+ .object({
+ "preview-hq-mp3": z.string().url(),
+ "preview-lq-mp3": z.string().url(),
+ "preview-hq-ogg": z.string().url(),
+ "preview-lq-ogg": z.string().url(),
+ })
+ .optional(),
+ download: z.string().url().optional(),
+ duration: z.number(),
+ filesize: z.number(),
+ type: z.string(),
+ channels: z.number(),
+ bitrate: z.number(),
+ bitdepth: z.number(),
+ samplerate: z.number(),
+ username: z.string(),
+ tags: z.array(z.string()),
+ license: z.string(),
+ created: z.string(),
+ num_downloads: z.number().optional(),
+ avg_rating: z.number().optional(),
+ num_ratings: z.number().optional(),
});
const freesoundResponseSchema = z.object({
- count: z.number(),
- next: z.string().url().nullable(),
- previous: z.string().url().nullable(),
- results: z.array(freesoundResultSchema),
+ count: z.number(),
+ next: z.string().url().nullable(),
+ previous: z.string().url().nullable(),
+ results: z.array(freesoundResultSchema),
});
const transformedResultSchema = z.object({
- id: z.number(),
- name: z.string(),
- description: z.string(),
- url: z.string(),
- previewUrl: z.string().optional(),
- downloadUrl: z.string().optional(),
- duration: z.number(),
- filesize: z.number(),
- type: z.string(),
- channels: z.number(),
- bitrate: z.number(),
- bitdepth: z.number(),
- samplerate: z.number(),
- username: z.string(),
- tags: z.array(z.string()),
- license: z.string(),
- created: z.string(),
- downloads: z.number().optional(),
- rating: z.number().optional(),
- ratingCount: z.number().optional(),
+ id: z.number(),
+ name: z.string(),
+ description: z.string(),
+ url: z.string(),
+ previewUrl: z.string().optional(),
+ downloadUrl: z.string().optional(),
+ duration: z.number(),
+ filesize: z.number(),
+ type: z.string(),
+ channels: z.number(),
+ bitrate: z.number(),
+ bitdepth: z.number(),
+ samplerate: z.number(),
+ username: z.string(),
+ tags: z.array(z.string()),
+ license: z.string(),
+ created: z.string(),
+ downloads: z.number().optional(),
+ rating: z.number().optional(),
+ ratingCount: z.number().optional(),
});
const apiResponseSchema = z.object({
- count: z.number(),
- next: z.string().nullable(),
- previous: z.string().nullable(),
- results: z.array(transformedResultSchema),
- query: z.string().optional(),
- type: z.string(),
- page: z.number(),
- pageSize: z.number(),
- sort: z.string(),
- minRating: z.number().optional(),
+ count: z.number(),
+ next: z.string().nullable(),
+ previous: z.string().nullable(),
+ results: z.array(transformedResultSchema),
+ query: z.string().optional(),
+ type: z.string(),
+ page: z.number(),
+ pageSize: z.number(),
+ sort: z.string(),
+ minRating: z.number().optional(),
});
-export async function GET(request: NextRequest) {
- try {
- const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
- const { success } = await baseRateLimit.limit(ip);
+function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
+ if (!query) return `${sort}_desc`;
+ return sort === "score" ? "score" : `${sort}_desc`;
+}
- if (!success) {
- return NextResponse.json({ error: "Too many requests" }, { status: 429 });
- }
+function applyEffectsFilters({
+ params,
+ min_rating,
+ commercial_only,
+}: {
+ params: URLSearchParams;
+ min_rating: number;
+ commercial_only: boolean;
+}) {
+ params.append("filter", "duration:[* TO 30.0]");
+ params.append("filter", `avg_rating:[${min_rating} TO *]`);
- const { searchParams } = new URL(request.url);
+ if (commercial_only) {
+ params.append(
+ "filter",
+ 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
+ );
+ }
+
+ params.append(
+ "filter",
+ "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
+ );
+}
- const validationResult = searchParamsSchema.safeParse({
- q: searchParams.get("q") || undefined,
- type: searchParams.get("type") || undefined,
- page: searchParams.get("page") || undefined,
- page_size: searchParams.get("page_size") || undefined,
- sort: searchParams.get("sort") || undefined,
- min_rating: searchParams.get("min_rating") || undefined,
- });
+function transformFreesoundResult(
+ result: z.infer,
+) {
+ return {
+ id: result.id,
+ name: result.name,
+ description: result.description,
+ url: result.url,
+ previewUrl:
+ result.previews?.["preview-hq-mp3"] ||
+ result.previews?.["preview-lq-mp3"],
+ downloadUrl: result.download,
+ duration: result.duration,
+ filesize: result.filesize,
+ type: result.type,
+ channels: result.channels,
+ bitrate: result.bitrate,
+ bitdepth: result.bitdepth,
+ samplerate: result.samplerate,
+ username: result.username,
+ tags: result.tags,
+ license: result.license,
+ created: result.created,
+ downloads: result.num_downloads || 0,
+ rating: result.avg_rating || 0,
+ ratingCount: result.num_ratings || 0,
+ };
+}
+
+export async function GET(request: NextRequest) {
+ try {
+ const { limited } = await checkRateLimit({ request });
+ if (limited) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 });
+ }
- if (!validationResult.success) {
- return NextResponse.json(
- {
- error: "Invalid parameters",
- details: validationResult.error.flatten().fieldErrors,
- },
- { status: 400 }
- );
- }
+ const { searchParams } = new URL(request.url);
- const {
- q: query,
- type,
- page,
- page_size: pageSize,
- sort,
- min_rating,
- commercial_only,
- } = validationResult.data;
+ const validationResult = searchParamsSchema.safeParse({
+ q: searchParams.get("q") || undefined,
+ type: searchParams.get("type") || undefined,
+ page: searchParams.get("page") || undefined,
+ page_size: searchParams.get("page_size") || undefined,
+ sort: searchParams.get("sort") || undefined,
+ min_rating: searchParams.get("min_rating") || undefined,
+ });
- if (type === "songs") {
- return NextResponse.json(
- {
- error: "Songs are not available yet",
- message:
- "Song search functionality is coming soon. Try searching for sound effects instead.",
- },
- { status: 501 }
- );
- }
+ if (!validationResult.success) {
+ return NextResponse.json(
+ {
+ error: "Invalid parameters",
+ details: validationResult.error.flatten().fieldErrors,
+ },
+ { status: 400 },
+ );
+ }
- const baseUrl = "https://freesound.org/apiv2/search/text/";
+ const {
+ q: query,
+ type,
+ page,
+ page_size: pageSize,
+ sort,
+ min_rating,
+ commercial_only,
+ } = validationResult.data;
- // Use score sorting for search queries, downloads for top sounds
- const sortParam = query
- ? sort === "score"
- ? "score"
- : `${sort}_desc`
- : `${sort}_desc`;
+ if (type === "songs") {
+ return NextResponse.json(
+ {
+ error: "Songs are not available yet",
+ message:
+ "Song search functionality is coming soon. Try searching for sound effects instead.",
+ },
+ { status: 501 },
+ );
+ }
- const params = new URLSearchParams({
- query: query || "",
- token: env.FREESOUND_API_KEY,
- page: page.toString(),
- page_size: pageSize.toString(),
- sort: sortParam,
- fields:
- "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
- });
+ const baseUrl = "https://freesound.org/apiv2/search/text/";
- // Always apply sound effect filters (since we're primarily a sound effects search)
- if (type === "effects" || !type) {
- params.append("filter", "duration:[* TO 30.0]");
- params.append("filter", `avg_rating:[${min_rating} TO *]`);
+ const sortParam = buildSortParameter({ query, sort });
- // Filter by license if commercial_only is true
- if (commercial_only) {
- params.append(
- "filter",
- 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")'
- );
- }
+ const params = new URLSearchParams({
+ query: query || "",
+ token: webEnv.FREESOUND_API_KEY,
+ page: page.toString(),
+ page_size: pageSize.toString(),
+ sort: sortParam,
+ fields:
+ "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
+ });
- params.append(
- "filter",
- "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion"
- );
- }
+ const isEffectsSearch = type === "effects" || !type;
+ if (isEffectsSearch) {
+ applyEffectsFilters({ params, min_rating, commercial_only });
+ }
- const response = await fetch(`${baseUrl}?${params.toString()}`);
+ const response = await fetch(`${baseUrl}?${params.toString()}`);
- if (!response.ok) {
- const errorText = await response.text();
- console.error("Freesound API error:", response.status, errorText);
- return NextResponse.json(
- { error: "Failed to search sounds" },
- { status: response.status }
- );
- }
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error("Freesound API error:", response.status, errorText);
+ return NextResponse.json(
+ { error: "Failed to search sounds" },
+ { status: response.status },
+ );
+ }
- const rawData = await response.json();
+ const rawData = await response.json();
- const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
- if (!freesoundValidation.success) {
- console.error(
- "Invalid Freesound API response:",
- freesoundValidation.error
- );
- return NextResponse.json(
- { error: "Invalid response from Freesound API" },
- { status: 502 }
- );
- }
+ const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
+ if (!freesoundValidation.success) {
+ console.error(
+ "Invalid Freesound API response:",
+ freesoundValidation.error,
+ );
+ return NextResponse.json(
+ { error: "Invalid response from Freesound API" },
+ { status: 502 },
+ );
+ }
- const data = freesoundValidation.data;
+ const data = freesoundValidation.data;
- const transformedResults = data.results.map((result) => ({
- id: result.id,
- name: result.name,
- description: result.description,
- url: result.url,
- previewUrl:
- result.previews?.["preview-hq-mp3"] ||
- result.previews?.["preview-lq-mp3"],
- downloadUrl: result.download,
- duration: result.duration,
- filesize: result.filesize,
- type: result.type,
- channels: result.channels,
- bitrate: result.bitrate,
- bitdepth: result.bitdepth,
- samplerate: result.samplerate,
- username: result.username,
- tags: result.tags,
- license: result.license,
- created: result.created,
- downloads: result.num_downloads || 0,
- rating: result.avg_rating || 0,
- ratingCount: result.num_ratings || 0,
- }));
+ const transformedResults = data.results.map(transformFreesoundResult);
- const responseData = {
- count: data.count,
- next: data.next,
- previous: data.previous,
- results: transformedResults,
- query: query || "",
- type: type || "effects",
- page,
- pageSize,
- sort,
- minRating: min_rating,
- };
+ const responseData = {
+ count: data.count,
+ next: data.next,
+ previous: data.previous,
+ results: transformedResults,
+ query: query || "",
+ type: type || "effects",
+ page,
+ pageSize,
+ sort,
+ minRating: min_rating,
+ };
- const responseValidation = apiResponseSchema.safeParse(responseData);
- if (!responseValidation.success) {
- console.error(
- "Invalid API response structure:",
- responseValidation.error
- );
- return NextResponse.json(
- { error: "Internal response formatting error" },
- { status: 500 }
- );
- }
+ const responseValidation = apiResponseSchema.safeParse(responseData);
+ if (!responseValidation.success) {
+ console.error(
+ "Invalid API response structure:",
+ responseValidation.error,
+ );
+ return NextResponse.json(
+ { error: "Internal response formatting error" },
+ { status: 500 },
+ );
+ }
- return NextResponse.json(responseValidation.data);
- } catch (error) {
- console.error("Error searching sounds:", error);
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 }
- );
- }
+ return NextResponse.json(responseValidation.data);
+ } catch (error) {
+ console.error("Error searching sounds:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
}
diff --git a/apps/web/src/app/api/transcribe/route.ts b/apps/web/src/app/api/transcribe/route.ts
deleted file mode 100644
index 9a497f65..00000000
--- a/apps/web/src/app/api/transcribe/route.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import { z } from "zod";
-import { env } from "@/env";
-import { baseRateLimit } from "@/lib/rate-limit";
-import { isTranscriptionConfigured } from "@/lib/transcription-utils";
-
-const transcribeRequestSchema = z.object({
- filename: z.string().min(1, "Filename is required"),
- language: z.string().optional().default("auto"),
- decryptionKey: z.string().min(1, "Decryption key is required").optional(),
- iv: z.string().min(1, "IV is required").optional(),
-});
-
-const modalResponseSchema = z.object({
- text: z.string(),
- segments: z.array(
- z.object({
- id: z.number(),
- seek: z.number(),
- start: z.number(),
- end: z.number(),
- text: z.string(),
- tokens: z.array(z.number()),
- temperature: z.number(),
- avg_logprob: z.number(),
- compression_ratio: z.number(),
- no_speech_prob: z.number(),
- })
- ),
- language: z.string(),
-});
-
-const apiResponseSchema = z.object({
- text: z.string(),
- segments: z.array(
- z.object({
- id: z.number(),
- seek: z.number(),
- start: z.number(),
- end: z.number(),
- text: z.string(),
- tokens: z.array(z.number()),
- temperature: z.number(),
- avg_logprob: z.number(),
- compression_ratio: z.number(),
- no_speech_prob: z.number(),
- })
- ),
- language: z.string(),
-});
-
-export async function POST(request: NextRequest) {
- try {
- // Rate limiting
- const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
- const { success } = await baseRateLimit.limit(ip);
- const origin = request.headers.get("origin");
-
- if (!success) {
- return NextResponse.json({ error: "Too many requests" }, { status: 429 });
- }
-
- // Check transcription configuration
- const transcriptionCheck = isTranscriptionConfigured();
- if (!transcriptionCheck.configured) {
- console.error(
- "Missing environment variables:",
- JSON.stringify(transcriptionCheck.missingVars)
- );
-
- return NextResponse.json(
- {
- error: "Transcription not configured",
- message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`,
- },
- { status: 503 }
- );
- }
-
- // Parse and validate request body
- const rawBody = await request.json().catch(() => null);
- if (!rawBody) {
- return NextResponse.json(
- { error: "Invalid JSON in request body" },
- { status: 400 }
- );
- }
-
- const validationResult = transcribeRequestSchema.safeParse(rawBody);
- if (!validationResult.success) {
- return NextResponse.json(
- {
- error: "Invalid request parameters",
- details: validationResult.error.flatten().fieldErrors,
- },
- { status: 400 }
- );
- }
-
- const { filename, language, decryptionKey, iv } = validationResult.data;
-
- // Prepare request body for Modal
- const modalRequestBody: any = {
- filename,
- language,
- };
-
- // Add encryption parameters if provided (zero-knowledge)
- if (decryptionKey && iv) {
- modalRequestBody.decryptionKey = decryptionKey;
- modalRequestBody.iv = iv;
- }
-
- // Call Modal transcription service
- const response = await fetch(env.MODAL_TRANSCRIPTION_URL, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(modalRequestBody),
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- console.error("Modal API error:", response.status, errorText);
-
- let errorMessage = "Transcription service unavailable";
- try {
- const errorData = JSON.parse(errorText);
- errorMessage = errorData.error || errorMessage;
- } catch {
- // Use default message if parsing fails
- }
-
- return NextResponse.json(
- {
- error: errorMessage,
- message: "Failed to process transcription request",
- },
- { status: response.status >= 500 ? 502 : response.status }
- );
- }
-
- const rawResult = await response.json();
- console.log("Raw Modal response:", JSON.stringify(rawResult, null, 2));
-
- // Validate Modal response
- const modalValidation = modalResponseSchema.safeParse(rawResult);
- if (!modalValidation.success) {
- console.error("Invalid Modal API response:", modalValidation.error);
- return NextResponse.json(
- { error: "Invalid response from transcription service" },
- { status: 502 }
- );
- }
-
- const result = modalValidation.data;
-
- // Prepare and validate API response
- const responseData = {
- text: result.text,
- segments: result.segments,
- language: result.language,
- };
-
- const responseValidation = apiResponseSchema.safeParse(responseData);
- if (!responseValidation.success) {
- console.error(
- "Invalid API response structure:",
- responseValidation.error
- );
- return NextResponse.json(
- { error: "Internal response formatting error" },
- { status: 500 }
- );
- }
-
- return NextResponse.json(responseValidation.data);
- } catch (error) {
- console.error("Transcription API error:", error);
- return NextResponse.json(
- {
- error: "Internal server error",
- message: "An unexpected error occurred during transcription",
- },
- { status: 500 }
- );
- }
-}
diff --git a/apps/web/src/app/api/waitlist/export/route.ts b/apps/web/src/app/api/waitlist/export/route.ts
deleted file mode 100644
index 0200e255..00000000
--- a/apps/web/src/app/api/waitlist/export/route.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import { baseRateLimit } from "@/lib/rate-limit";
-import { db, exportWaitlist, eq } from "@opencut/db";
-import { randomUUID } from "crypto";
-import {
- exportWaitlistSchema,
- exportWaitlistResponseSchema,
-} from "@/lib/schemas/waitlist";
-
-const requestSchema = exportWaitlistSchema;
-const responseSchema = exportWaitlistResponseSchema;
-
-export async function POST(request: NextRequest) {
- try {
- const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
- const { success } = await baseRateLimit.limit(ip);
- if (!success) {
- return NextResponse.json({ error: "Too many requests" }, { status: 429 });
- }
-
- const body = await request.json().catch(() => null);
- if (!body) {
- return NextResponse.json(
- { error: "Invalid JSON in request body" },
- { status: 400 }
- );
- }
-
- const parsed = requestSchema.safeParse(body);
- if (!parsed.success) {
- return NextResponse.json(
- {
- error: "Invalid request parameters",
- details: parsed.error.flatten().fieldErrors,
- },
- { status: 400 }
- );
- }
-
- const { email } = parsed.data;
-
- const existing = await db
- .select({ id: exportWaitlist.id })
- .from(exportWaitlist)
- .where(eq(exportWaitlist.email, email))
- .limit(1);
-
- if (existing.length > 0) {
- const responseData = { success: true, alreadySubscribed: true } as const;
- const validated = responseSchema.safeParse(responseData);
- if (!validated.success) {
- return NextResponse.json(
- { error: "Internal response formatting error" },
- { status: 500 }
- );
- }
- return NextResponse.json(validated.data);
- }
-
- await db.insert(exportWaitlist).values({
- id: randomUUID(),
- email,
- createdAt: new Date(),
- updatedAt: new Date(),
- });
-
- const responseData = { success: true } as const;
- const validated = responseSchema.safeParse(responseData);
- if (!validated.success) {
- return NextResponse.json(
- { error: "Internal response formatting error" },
- { status: 500 }
- );
- }
- return NextResponse.json(validated.data);
- } catch (error) {
- console.error("Waitlist API error:", error);
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 }
- );
- }
-}
diff --git a/apps/web/src/app/base-page.tsx b/apps/web/src/app/base-page.tsx
new file mode 100644
index 00000000..a3d56fe5
--- /dev/null
+++ b/apps/web/src/app/base-page.tsx
@@ -0,0 +1,53 @@
+import { Header } from "@/components/header";
+import { Footer } from "@/components/footer";
+import { cn } from "@/utils/ui";
+
+interface BasePageProps {
+ children: React.ReactNode;
+ className?: string;
+ mainClassName?: string;
+ maxWidth?: "3xl" | "6xl" | "full";
+ title?: string;
+ description?: string;
+}
+
+export function BasePage({
+ children,
+ className = "",
+ mainClassName = "",
+ maxWidth = "3xl",
+ title,
+ description,
+}: BasePageProps) {
+ const maxWidthClass = {
+ "3xl": "max-w-3xl",
+ "6xl": "max-w-6xl",
+ full: "max-w-full",
+ }[maxWidth];
+
+ return (
+
+
+
+ {title && description && (
+
+
+ {title}
+
+
+ {description}
+
+
+ )}
+ {children}
+
+
+
+ );
+}
diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx
index d759050b..46c6163d 100644
--- a/apps/web/src/app/blog/[slug]/page.tsx
+++ b/apps/web/src/app/blog/[slug]/page.tsx
@@ -1,144 +1,152 @@
-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 type { Metadata } from "next";
import Image from "next/image";
import { notFound } from "next/navigation";
+import { BasePage } from "@/app/base-page";
+import Prose from "@/components/ui/prose";
+import { Separator } from "@/components/ui/separator";
+import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query";
+import type { Author, Post } from "@/types/blog";
type PageProps = {
- params: Promise<{ slug: string }>;
- searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
+ params: Promise<{ slug: string }>;
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};
export async function generateMetadata({
- params,
+ params,
}: PageProps): Promise {
- 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),
- ],
- },
- };
+ 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: Author) => author.name),
+ },
+ };
}
export async function generateStaticParams() {
- const data = await getPosts();
- if (!data || !data.posts.length) return [];
+ const data = await getPosts();
+ if (!data || !data.posts.length) return [];
+
+ return data.posts.map((post) => ({
+ slug: post.slug,
+ }));
+}
+
+export default async function BlogPostPage({ params }: PageProps) {
+ const slug = (await params).slug;
+ const data = await getSinglePost({ slug });
+ if (!data || !data.post) return notFound();
+
+ const html = await processHtmlContent({ html: data.post.content });
- return data.posts.map((post) => ({
- slug: post.slug,
- }));
+ return (
+
+
+
+
+
+ );
}
-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 (
-
-
-
-
-
-
-
-
- {data.post.coverImage && (
-
-
-
- )}
-
-
- {formattedDate}
-
-
-
-
- {data.post.title}
-
-
- {data.post.authors[0] && (
- <>
-
-
- {data.post.authors[0].name}
-
- >
- )}
-
-
-
-
-
-
-
- );
+function PostHeader({ post }: { post: Post }) {
+ const formattedDate = new Date(post.publishedAt).toLocaleDateString("en-US", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ });
+
+ return (
+ <>
+ {post.coverImage && }
+
+
+
+ >
+ );
+}
+
+function PostCoverImage({ post }: { post: Post }) {
+ return (
+
+
+
+ );
}
-export default Page;
+function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) {
+ return (
+
+ {date}
+
+ );
+}
+
+function PostTitle({ title }: { title: string }) {
+ return (
+ {title}
+ );
+}
+
+function PostAuthor({ author }: { author?: Author }) {
+ if (!author) return null;
+
+ return (
+
+ );
+}
+
+function PostContent({ html }: { html: string }) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx
index 08da9fd7..9e3b6d11 100644
--- a/apps/web/src/app/blog/page.tsx
+++ b/apps/web/src/app/blog/page.tsx
@@ -1,98 +1,73 @@
-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 type { Metadata } from "next";
import Link from "next/link";
-import { getPosts } from "@/lib/blog-query";
-import Image from "next/image";
+import { BasePage } from "@/app/base-page";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Separator } from "@/components/ui/separator";
+import { getPosts } from "@/lib/blog/query";
+import type { Author, Post } from "@/types/blog";
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",
- },
+ 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 No posts yet
;
-
- return (
-
-
+ const data = await getPosts();
+ if (!data || !data.posts) return
No posts yet
;
-
-
+ return (
+
+
+ {data.posts.map((post) => (
+
+
+
+
+ ))}
+
+
+ );
+}
-
-
-
- Blog
-
-
- Read the latest news and updates about OpenCut, the free and
- open-source video editor.
-
-
-
- {data.posts.map((post) => (
-
-
- {post.coverImage && (
-
-
-
- )}
+function BlogPostItem({ post }: { post: Post }) {
+ return (
+
+
+
+
{post.title}
+
{post.description}
+
+ {post.authors && post.authors.length > 0 && (
+
+ )}
+
+
+ );
+}
-
- {post.authors && post.authors.length > 0 && (
-
- {post.authors.map((author, index) => (
-
-
-
-
- {author.name.charAt(0).toUpperCase()}
-
-
-
- {author.name}
-
- {index < post.authors.length - 1 && (
-
•
- )}
-
- ))}
-
- )}
- {post.title}
- {post.description}
-
-
-
- ))}
-
-
-
-
- );
+function AuthorList({ authors }: { authors: Author[] }) {
+ return (
+
+ {authors.map((author) => (
+
+
+
+
+ {author.name.charAt(0).toUpperCase()}
+
+
+
+ ))}
+
+ );
}
diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx
index 3ad99023..21703852 100644
--- a/apps/web/src/app/contributors/page.tsx
+++ b/apps/web/src/app/contributors/page.tsx
@@ -1,330 +1,242 @@
-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 { Button } from "@/components/ui/button";
-import { ExternalLink } from "lucide-react";
+import type { Metadata } from "next";
import Link from "next/link";
-import {
- GithubIcon,
- MarbleIcon,
- VercelIcon,
- DataBuddyIcon,
-} from "@/components/icons";
-import { Badge } from "@/components/ui/badge";
-import { EXTERNAL_TOOLS } from "@/constants/site";
+import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Card, CardContent } from "@/components/ui/card";
+import { EXTERNAL_TOOLS } from "@/constants/site-constants";
+import { BasePage } from "../base-page";
export const metadata: Metadata = {
- title: "Contributors - OpenCut",
- description:
- "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.",
- openGraph: {
- title: "Contributors - OpenCut",
- description:
- "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.",
- type: "website",
- },
+ title: "Contributors - OpenCut",
+ description:
+ "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.",
+ openGraph: {
+ title: "Contributors - OpenCut",
+ description:
+ "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.",
+ type: "website",
+ },
};
interface Contributor {
- id: number;
- login: string;
- avatar_url: string;
- html_url: string;
- contributions: number;
- type: string;
+ id: number;
+ login: string;
+ avatar_url: string;
+ html_url: string;
+ contributions: number;
+ type: string;
}
async function getContributors(): Promise {
- try {
- const response = await fetch(
- "https://api.github.com/repos/OpenCut-app/OpenCut/contributors?per_page=100",
- {
- headers: {
- Accept: "application/vnd.github.v3+json",
- "User-Agent": "OpenCut-Web-App",
- },
- next: { revalidate: 600 }, // 10 minutes
- } as RequestInit
- );
-
- if (!response.ok) {
- console.error("Failed to fetch contributors");
- return [];
- }
-
- const contributors = (await response.json()) as Contributor[];
-
- const filteredContributors = contributors.filter(
- (contributor: Contributor) => contributor.type === "User"
- );
-
- return filteredContributors;
- } catch (error) {
- console.error("Error fetching contributors:", error);
- return [];
- }
+ try {
+ const response = await fetch(
+ "https://api.github.com/repos/OpenCut-app/OpenCut/contributors?per_page=100",
+ {
+ headers: {
+ Accept: "application/vnd.github.v3+json",
+ "User-Agent": "OpenCut-Web-App",
+ },
+ next: { revalidate: 600 }, // 10 minutes
+ },
+ );
+
+ if (!response.ok) {
+ console.error("Failed to fetch contributors");
+ return [];
+ }
+
+ const contributors = (await response.json()) as Contributor[];
+
+ const filteredContributors = contributors.filter(
+ (contributor) => contributor.type === "User",
+ );
+
+ return filteredContributors;
+ } catch (error) {
+ console.error("Error fetching contributors:", error);
+ return [];
+ }
}
export default async function ContributorsPage() {
- const contributors = await getContributors();
- const topContributors = contributors.slice(0, 2);
- const otherContributors = contributors.slice(2);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- Open Source
-
-
-
- Contributors
-
-
- Meet the amazing developers who are building the future of video
- editing
-
-
-
-
-
-
{contributors.length}
-
contributors
-
-
-
-
- {contributors.reduce((sum, c) => sum + c.contributions, 0)}
-
-
contributions
-
-
-
-
- {topContributors.length > 0 && (
-
-
-
- Top Contributors
-
-
- Leading the way in contributions
-
-
-
-
- {topContributors.map((contributor, index) => (
-
-
-
-
-
-
-
-
-
- {contributor.login.charAt(0).toUpperCase()}
-
-
-
-
- {contributor.login}
-
-
-
- {contributor.contributions}
-
- contributions
-
-
-
-
-
- ))}
-
-
- )}
-
- {otherContributors.length > 0 && (
-
-
-
- All Contributors
-
-
- Everyone who makes OpenCut better
-
-
-
-
- {otherContributors.map((contributor, index) => (
-
-
-
-
-
- {contributor.login.charAt(0).toUpperCase()}
-
-
-
- {contributor.login}
-
-
- {contributor.contributions}
-
-
-
- ))}
-
-
- )}
-
- {contributors.length === 0 && (
-
-
-
-
-
- No contributors found
-
-
- Unable to load contributors at the moment. Check back later or
- view on GitHub.
-
-
-
-
- View on GitHub
-
-
-
-
- )}
+ const contributors = await getContributors();
+ const topContributors = contributors.slice(0, 2);
+ const otherContributors = contributors.slice(2);
+ const totalContributions = contributors.reduce(
+ (sum, c) => sum + c.contributions,
+ 0,
+ );
+
+ return (
+
+
+
+
+
+
+
+ {topContributors.length > 0 && (
+
+ )}
+ {otherContributors.length > 0 && (
+
+ )}
+
+
+
+
+ );
+}
-
-
-
External Tools
-
- Tools we use to build OpenCut
-
-
+function StatItem({ value, label }: { value: number; label: string }) {
+ return (
+
+ );
+}
-
- {EXTERNAL_TOOLS.map((tool, index) => {
- const IconComponent = {
- MarbleIcon,
- VercelIcon,
- DataBuddyIcon,
- }[tool.icon];
+function TopContributorsSection({
+ contributors,
+}: {
+ contributors: Contributor[];
+}) {
+ return (
+
+
+
Top contributors
+
+ Leading the way in contributions
+
+
+
+
+ {contributors.map((contributor) => (
+
+ ))}
+
+
+ );
+}
- return (
-
-
-
-
-
- {tool.name}
-
-
- {tool.description}
-
-
-
-
- );
- })}
-
-
+function TopContributorCard({ contributor }: { contributor: Contributor }) {
+ return (
+
+
+
+
+
+
+ {contributor.login.charAt(0).toUpperCase()}
+
+
+
+
{contributor.login}
+
+ {contributor.contributions}
+ contributions
+
+
+
+
+
+ );
+}
-
-
-
Join the community
-
- OpenCut is built by developers like you. Every contribution,
- no matter how small, helps make video editing more accessible
- for everyone.
-
+function AllContributorsSection({
+ contributors,
+}: {
+ contributors: Contributor[];
+}) {
+ return (
+
+
+
All contributors
+
+ Everyone who makes OpenCut better
+
+
+
+
+ {contributors.map((contributor) => (
+
+
+
+
+
+ {contributor.login.charAt(0).toUpperCase()}
+
+
+
+
{contributor.login}
+
+ {contributor.contributions}
+
+
+
+
+ ))}
+
+
+ );
+}
-
-
-
-
- Start Contributing
-
-
-
-
- Browse Issues
-
-
-
-
-
-
-
-
-
-
- );
+function ExternalToolsSection() {
+ return (
+
+
+
External tools
+
Tools we use to build OpenCut
+
+
+
+ {EXTERNAL_TOOLS.map((tool, index) => (
+
+
+
+
+
+
{tool.name}
+
+ {tool.description}
+
+
+
+
+
+ ))}
+
+
+ );
}
diff --git a/apps/web/src/app/editor/[project_id]/layout.tsx b/apps/web/src/app/editor/[project_id]/layout.tsx
deleted file mode 100644
index c37e78b7..00000000
--- a/apps/web/src/app/editor/[project_id]/layout.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-"use client";
-
-export default function EditorLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
- return {children}
;
-}
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index 1b53b6b5..e652e928 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -1,459 +1,108 @@
"use client";
-import { useEffect, useRef } from "react";
-import { useParams, useRouter } from "next/navigation";
+import { useParams } from "next/navigation";
import {
- ResizablePanelGroup,
- ResizablePanel,
- ResizableHandle,
+ ResizablePanelGroup,
+ ResizablePanel,
+ ResizableHandle,
} from "@/components/ui/resizable";
-import { MediaPanel } from "@/components/editor/media-panel";
-import { PropertiesPanel } from "@/components/editor/properties-panel";
+import { AssetsPanel } from "@/components/editor/panels/assets";
+import { PropertiesPanel } from "@/components/editor/panels/properties";
import { Timeline } from "@/components/editor/timeline";
-import { PreviewPanel } from "@/components/editor/preview-panel";
+import { PreviewPanel } from "@/components/editor/panels/preview";
import { EditorHeader } from "@/components/editor/editor-header";
-import { usePanelStore } from "@/stores/panel-store";
-import { useProjectStore } from "@/stores/project-store";
import { EditorProvider } from "@/components/providers/editor-provider";
-import { usePlaybackControls } from "@/hooks/use-playback-controls";
import { Onboarding } from "@/components/editor/onboarding";
+import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
+import { usePanelStore } from "@/stores/panel-store";
export default function Editor() {
- const {
- toolsPanel,
- previewPanel,
- mainContent,
- timeline,
- setToolsPanel,
- setPreviewPanel,
- setMainContent,
- setTimeline,
- propertiesPanel,
- setPropertiesPanel,
- activePreset,
- resetCounter,
- } = usePanelStore();
-
- const {
- activeProject,
- loadProject,
- createNewProject,
- isInvalidProjectId,
- markProjectIdAsInvalid,
- } = useProjectStore();
- const params = useParams();
- const router = useRouter();
- const projectId = params.project_id as string;
- const handledProjectIds = useRef>(new Set());
- const isInitializingRef = useRef(false);
-
- usePlaybackControls();
-
- useEffect(() => {
- let isCancelled = false;
-
- const initProject = async () => {
- if (!projectId) {
- return;
- }
-
- // Prevent duplicate initialization
- if (isInitializingRef.current) {
- return;
- }
-
- // Check if project is already loaded
- if (activeProject?.id === projectId) {
- return;
- }
-
- // Check global invalid tracking first (most important for preventing duplicates)
- if (isInvalidProjectId(projectId)) {
- return;
- }
-
- // Check if we've already handled this project ID locally
- if (handledProjectIds.current.has(projectId)) {
- return;
- }
-
- // Mark as initializing to prevent race conditions
- isInitializingRef.current = true;
- handledProjectIds.current.add(projectId);
-
- try {
- await loadProject(projectId);
-
- // Check if component was unmounted during async operation
- if (isCancelled) {
- return;
- }
-
- // Project loaded successfully
- isInitializingRef.current = false;
- } catch (error) {
- // Check if component was unmounted during async operation
- if (isCancelled) {
- return;
- }
-
- // More specific error handling - only create new project for actual "not found" errors
- const isProjectNotFound =
- error instanceof Error &&
- (error.message.includes("not found") ||
- error.message.includes("does not exist") ||
- error.message.includes("Project not found"));
-
- if (isProjectNotFound) {
- // Mark this project ID as invalid globally BEFORE creating project
- markProjectIdAsInvalid(projectId);
-
- try {
- const newProjectId = await createNewProject("Untitled Project");
-
- // Check again if component was unmounted
- if (isCancelled) {
- return;
- }
-
- router.replace(`/editor/${newProjectId}`);
- } catch (createError) {
- console.error("Failed to create new project:", createError);
- }
- } else {
- // For other errors (storage issues, corruption, etc.), don't create new project
- console.error(
- "Project loading failed with recoverable error:",
- error
- );
- // Remove from handled set so user can retry
- handledProjectIds.current.delete(projectId);
- }
-
- isInitializingRef.current = false;
- }
- };
-
- initProject();
-
- // Cleanup function to cancel async operations
- return () => {
- isCancelled = true;
- isInitializingRef.current = false;
- };
- }, [
- projectId,
- loadProject,
- createNewProject,
- router,
- isInvalidProjectId,
- markProjectIdAsInvalid,
- ]);
-
- return (
-
-
-
-
- {activePreset === "media" ? (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ) : activePreset === "inspector" ? (
-
- setPropertiesPanel(100 - size)}
- className="min-w-0 min-h-0"
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ) : activePreset === "vertical-preview" ? (
-
- setPreviewPanel(100 - size)}
- className="min-w-0 min-h-0"
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ) : (
-
-
- {/* Main content area */}
-
- {/* Tools Panel */}
-
-
-
-
-
-
- {/* Preview Area */}
-
-
-
-
-
-
-
-
-
-
-
-
-
+ const params = useParams();
+ const projectId = params.project_id as string;
+
+ return (
+
+
+
+ );
+}
- {/* Timeline */}
-
-
-
-
- )}
-
-
-
-
- );
+function EditorLayout() {
+ const { panels, setPanel } = usePanelStore();
+
+ return (
+ {
+ setPanel("mainContent", sizes[0] ?? panels.mainContent);
+ setPanel("timeline", sizes[1] ?? panels.timeline);
+ }}
+ >
+
+ {
+ setPanel("tools", sizes[0] ?? panels.tools);
+ setPanel("preview", sizes[1] ?? panels.preview);
+ setPanel("properties", sizes[2] ?? panels.properties);
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
}
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index ca78ee26..d0da1f51 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -8,85 +8,88 @@
@plugin "tailwindcss-animate";
:root {
- /* Custom colors - light mode (default) */
- --background: hsl(0, 0%, 100%);
- --foreground: hsl(0 0% 11%);
- --card: hsl(216, 8%, 86%);
- --card-foreground: hsl(0 0% 2%);
- --popover: hsl(0, 0%, 100%);
- --popover-foreground: hsl(0 0% 2%);
- --primary: hsl(205, 84%, 47%);
- --primary-foreground: hsl(0 0% 91%);
- --secondary: hsl(216, 13%, 92%);
- --secondary-foreground: hsl(0 0% 2%);
- --muted: hsl(0 0% 85.1%);
- --muted-foreground: hsl(0 0% 50%);
- --accent: hsl(216, 13%, 92%);
- --accent-foreground: hsl(0 0% 2%);
- --destructive: hsl(0, 83%, 50%);
- --destructive-foreground: hsl(0, 0%, 100%);
- --border: hsl(0 0% 83%);
- --input: hsl(0 0% 85.1%);
- --ring: hsl(0, 0%, 55%);
- --chart-1: hsl(220 70% 50%);
- --chart-2: hsl(160 60% 45%);
- --chart-3: hsl(30 80% 55%);
- --chart-4: hsl(280 65% 60%);
- --chart-5: hsl(340 75% 55%);
- --sidebar-background: hsl(0 0% 96.1%);
- --sidebar-foreground: hsl(0 0% 2%);
- --sidebar-primary: hsl(0 0% 2%);
- --sidebar-primary-foreground: hsl(0 0% 91%);
- --sidebar-accent: hsl(0 0% 85.1%);
- --sidebar-accent-foreground: hsl(0 0% 2%);
- --sidebar-border: hsl(0 0% 85.1%);
- --sidebar-ring: hsl(0 0% 16.9%);
- --panel-background: hsl(216 13% 92%);
- --panel-accent: hsl(216, 8%, 86%);
-
- /* Radius base */
- --radius: 1rem;
+ --background: hsl(0, 0%, 100%);
+ --foreground: hsl(0 0% 11%);
+ --card: hsl(0, 0%, 100%);
+ --card-foreground: hsl(0 0% 11%);
+ --popover: hsl(0, 0%, 100%);
+ --popover-foreground: hsl(0 0% 2%);
+ --primary: hsl(203, 100%, 50%);
+ --primary-hover: hsl(203, 100%, 45%);
+ --primary-foreground: hsl(0, 0%, 100%);
+ --secondary: hsl(216 13% 94%);
+ --secondary-foreground: hsl(0 0% 2%);
+ --muted: hsl(0 0% 85.1%);
+ --muted-foreground: hsl(0 0% 50%);
+ --accent: hsl(216, 13%, 88%);
+ --accent-foreground: hsl(0 0% 2%);
+ --destructive: hsl(0, 83%, 50%);
+ --destructive-foreground: hsl(0, 0%, 100%);
+ --constructive: hsl(141, 71%, 48%);
+ --constructive-foreground: hsl(0, 0%, 100%);
+ --border: hsl(0 0% 88%);
+ --input: hsl(0 0% 85.1%);
+ --ring: hsl(0, 0%, 55%);
+ --chart-1: hsl(220 70% 50%);
+ --chart-2: hsl(160 60% 45%);
+ --chart-3: hsl(30 80% 55%);
+ --chart-4: hsl(280 65% 60%);
+ --chart-5: hsl(340 75% 55%);
+ --sidebar-background: hsl(0 0% 96.1%);
+ --sidebar-foreground: hsl(0 0% 2%);
+ --sidebar-primary: hsl(0 0% 2%);
+ --sidebar-primary-foreground: hsl(0 0% 91%);
+ --sidebar-accent: hsl(0 0% 85.1%);
+ --sidebar-accent-foreground: hsl(0 0% 2%);
+ --sidebar-border: hsl(0 0% 85.1%);
+ --sidebar-ring: hsl(0 0% 16.9%);
+ --panel-background: hsl(216 13% 94%);
+ --panel-accent: hsl(216, 8%, 88%);
+ --sidebar: hsl(0 0% 98%);
}
.dark {
- /* Custom colors - dark mode */
- --background: hsl(0 0% 4%);
- --foreground: hsl(0 0% 89%);
- --card: hsl(0 0% 14.9%);
- --card-foreground: hsl(0 0% 98%);
- --popover: hsl(0 0% 14.9%);
- --popover-foreground: hsl(0 0% 98%);
- --primary: hsl(205, 84%, 53%);
- --primary-foreground: hsl(0 0% 9%);
- --secondary: hsl(0 0% 14.9%);
- --secondary-foreground: hsl(0 0% 98%);
- --muted: hsl(0 0% 14.9%);
- --muted-foreground: hsl(0 0% 63.9%);
- --accent: hsl(0 0% 14.9%);
- --accent-foreground: hsl(0 0% 98%);
- --destructive: hsl(0 100% 60%);
- --destructive-foreground: hsl(0 0% 98%);
- --border: hsl(0 0% 17%);
- --input: hsl(0 0% 14.9%);
- --ring: hsl(0 0% 83.1%);
- --chart-1: hsl(220 70% 50%);
- --chart-2: hsl(160 60% 45%);
- --chart-3: hsl(30 80% 55%);
- --chart-4: hsl(280 65% 60%);
- --chart-5: hsl(340 75% 55%);
- --sidebar-background: hsl(0 0% 3.9%);
- --sidebar-foreground: hsl(0 0% 98%);
- --sidebar-primary: hsl(0 0% 98%);
- --sidebar-primary-foreground: hsl(0 0% 9%);
- --sidebar-accent: hsl(0 0% 14.9%);
- --sidebar-accent-foreground: hsl(0 0% 98%);
- --sidebar-border: hsl(0 0% 14.9%);
- --sidebar-ring: hsl(0 0% 83.1%);
- --panel-background: hsl(0 0% 11%);
- --panel-accent: hsl(0 0% 15%);
+ --background: hsl(0 0% 4%);
+ --foreground: hsl(0 0% 89%);
+ --card: hsl(0 0% 4%);
+ --card-foreground: hsl(0 0% 89%);
+ --popover: hsl(0 0% 14.9%);
+ --popover-foreground: hsl(0 0% 98%);
+ --primary: hsl(203, 100%, 50%);
+ --primary-hover: hsl(203, 100%, 45%);
+ --primary-foreground: hsl(0 0% 9%);
+ --secondary: hsl(0 0% 14.9%);
+ --secondary-foreground: hsl(0 0% 98%);
+ --muted: hsl(0 0% 14.9%);
+ --muted-foreground: hsl(0 0% 63.9%);
+ --accent: hsl(0, 0%, 28%);
+ --accent-foreground: hsl(0 0% 98%);
+ --destructive: hsl(0 83%, 55%);
+ --destructive-foreground: hsl(0 0% 98%);
+ --constructive: hsl(141, 71%, 48%);
+ --constructive-foreground: hsl(0 0% 100%);
+ --border: hsl(0 0% 17%);
+ --input: hsl(0 0% 14.9%);
+ --ring: hsl(0 0% 83.1%);
+ --chart-1: hsl(220 70% 50%);
+ --chart-2: hsl(160 60% 45%);
+ --chart-3: hsl(30 80% 55%);
+ --chart-4: hsl(280 65% 60%);
+ --chart-5: hsl(340 75% 55%);
+ --sidebar-background: hsl(0 0% 3.9%);
+ --sidebar-foreground: hsl(0 0% 98%);
+ --sidebar-primary: hsl(0 0% 98%);
+ --sidebar-primary-foreground: hsl(0 0% 9%);
+ --sidebar-accent: hsl(0 0% 14.9%);
+ --sidebar-accent-foreground: hsl(0 0% 98%);
+ --sidebar-border: hsl(0 0% 14.9%);
+ --sidebar-ring: hsl(0 0% 83.1%);
+ --panel-background: hsl(0 0% 11%);
+ --panel-accent: hsl(0 0% 15%);
+ --sidebar: hsl(240 5.9% 10%);
}
@layer base {
- /*
+ /*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
@@ -94,152 +97,165 @@
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
- *,
- ::after,
- ::before,
- ::backdrop,
- ::file-selector-button {
- border-color: var(--color-gray-200, currentcolor);
- }
- /* Other default base styles */
- * {
- @apply border-border;
- }
- body {
- @apply bg-background text-foreground;
- /* Prevent back/forward swipe */
- overscroll-behavior-x: contain;
- }
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ border-color: var(--color-gray-200, currentcolor);
+ }
+ /* Other default base styles */
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ /* Prevent back/forward swipe */
+ overscroll-behavior-x: contain;
+ }
}
@theme inline {
- /* Responsive breakpoints */
- --breakpoint-xs: 30rem;
-
- /* Typography */
- --font-sans: var(--font-inter), sans-serif;
-
- /* Font sizes */
- --text-base: 0.95rem;
- --text-base--line-height: calc(1.5 / 0.95);
- --text-xs: 0.8rem;
- --text-xs--line-height: calc(1 / 0.8);
-
- /* Border radius */
- --radius-lg: var(--radius);
- --radius-md: calc(var(--radius) - 2px);
- --radius-sm: calc(var(--radius) - 8px);
-
- /* Palette mapped to root design tokens */
- --color-background: var(--background);
- --color-foreground: var(--foreground);
-
- --color-card: var(--card);
- --color-card-foreground: var(--card-foreground);
-
- --color-popover: var(--popover);
- --color-popover-foreground: var(--popover-foreground);
-
- --color-primary: var(--primary);
- --color-primary-foreground: var(--primary-foreground);
-
- --color-secondary: var(--secondary);
- --color-secondary-foreground: var(--secondary-foreground);
-
- --color-muted: var(--muted);
- --color-muted-foreground: var(--muted-foreground);
-
- --color-accent: var(--accent);
- --color-accent-foreground: var(--accent-foreground);
-
- --color-destructive: var(--destructive);
- --color-destructive-foreground: var(--destructive-foreground);
-
- --color-border: var(--border);
- --color-input: var(--input);
- --color-ring: var(--ring);
-
- /* Chart colors */
- --color-chart-1: var(--chart-1);
- --color-chart-2: var(--chart-2);
- --color-chart-3: var(--chart-3);
- --color-chart-4: var(--chart-4);
- --color-chart-5: var(--chart-5);
-
- /* Sidebar */
- --color-sidebar: var(--sidebar-background);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-ring: var(--sidebar-ring);
-
- /* Panel */
- --color-panel: var(--panel-background);
- --color-panel-accent: var(--panel-accent);
-
- /* Animations */
- --animate-accordion-down: accordion-down 0.2s ease-out;
- --animate-accordion-up: accordion-up 0.2s ease-out;
-
- @keyframes accordion-down {
- from {
- height: 0;
- }
- to {
- height: var(--radix-accordion-content-height);
- }
- }
-
- @keyframes accordion-up {
- from {
- height: var(--radix-accordion-content-height);
- }
- to {
- height: 0;
- }
- }
+ /* Responsive breakpoints */
+ --breakpoint-xs: 30rem;
+
+ /* Typography */
+ --font-sans: var(--font-inter), sans-serif;
+
+ /* Font sizes */
+ --text-xl: 1.20rem;
+ --text-base: 0.92rem;
+ --text-base--line-height: calc(1.5 / 0.95);
+ --text-xs: 0.75rem;
+ --text-xs--line-height: calc(1 / 0.8);
+
+ /* Border radius */
+ --radius-lg: 0.82rem;
+ --radius-md: 0.65rem;
+ --radius-sm: 0.35rem;
+
+ /* Palette mapped to root design tokens */
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary-hover: var(--primary-hover);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+
+ --color-constructive: var(--constructive);
+ --color-constructive-foreground: var(--constructive-foreground);
+
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+
+ /* Chart colors */
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+
+ /* Sidebar */
+ --color-sidebar: var(--sidebar-background);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+
+ /* Panel */
+ --color-panel: var(--panel-background);
+ --color-panel-accent: var(--panel-accent);
+
+ /* Animations */
+ --animate-accordion-down: accordion-down 0.2s ease-out;
+ --animate-accordion-up: accordion-up 0.2s ease-out;
+
+ @keyframes accordion-down {
+ from {
+ height: 0;
+ }
+ to {
+ height: var(--radix-accordion-content-height);
+ }
+ }
+
+ @keyframes accordion-up {
+ from {
+ height: var(--radix-accordion-content-height);
+ }
+ to {
+ height: 0;
+ }
+ }
}
@utility scrollbar-hidden {
- -ms-overflow-style: none;
- scrollbar-width: none;
- &::-webkit-scrollbar {
- display: none;
- }
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
}
@utility scrollbar-x-hidden {
- -ms-overflow-style: none;
- scrollbar-width: none;
- &::-webkit-scrollbar:horizontal {
- display: none;
- }
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar:horizontal {
+ display: none;
+ }
}
@utility scrollbar-y-hidden {
- -ms-overflow-style: none;
- scrollbar-width: none;
- &::-webkit-scrollbar:vertical {
- display: none;
- }
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar:vertical {
+ display: none;
+ }
}
@utility scrollbar-thin {
- &::-webkit-scrollbar {
- width: 6px;
- height: 8px;
- }
- &::-webkit-scrollbar-track {
- background: transparent;
- }
- &::-webkit-scrollbar-thumb {
- background: var(--border);
- border-radius: 4px;
- }
- &::-webkit-scrollbar-thumb:hover {
- background: var(--muted-foreground);
- }
+ &::-webkit-scrollbar {
+ width: 6px;
+ height: 8px;
+ }
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 4px;
+ }
+ &::-webkit-scrollbar-thumb:hover {
+ background: var(--muted-foreground);
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 7e32ea22..2b3d0c30 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -1,62 +1,58 @@
import { ThemeProvider } from "next-themes";
-import { Analytics } from "@vercel/analytics/react";
import Script from "next/script";
import "./globals.css";
import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip";
-import { StorageProvider } from "../components/storage-provider";
-import { ScenesMigrator } from "../components/providers/migrators/scenes-migrator";
import { baseMetaData } from "./metadata";
-import { defaultFont } from "../lib/font-config";
import { BotIdClient } from "botid/client";
-import { env } from "@/env";
+import { webEnv } from "@opencut/env/web";
+import { Inter } from "next/font/google";
+
+const siteFont = Inter({ subsets: ["latin"] });
export const metadata = baseMetaData;
const protectedRoutes = [
- {
- path: "/none",
- method: "GET",
- },
- {
- path: "/api/waitlist/export",
- method: "POST",
- },
+ {
+ path: "/none",
+ method: "GET",
+ },
];
export default function RootLayout({
- children,
+ children,
}: Readonly<{
- children: React.ReactNode;
+ children: React.ReactNode;
}>) {
- return (
-
-
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
}
diff --git a/apps/web/src/app/metadata.ts b/apps/web/src/app/metadata.ts
index 0608974d..7c192d2b 100644
--- a/apps/web/src/app/metadata.ts
+++ b/apps/web/src/app/metadata.ts
@@ -1,86 +1,86 @@
import type { Metadata } from "next";
-import { SITE_INFO, SITE_URL } from "@/constants/site";
+import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
export const baseMetaData: Metadata = {
- metadataBase: new URL(SITE_URL),
- title: SITE_INFO.title,
- description: SITE_INFO.description,
- openGraph: {
- title: SITE_INFO.title,
- description: SITE_INFO.description,
- url: SITE_URL,
- siteName: SITE_INFO.title,
- locale: "en_US",
- type: "website",
- images: [
- {
- url: SITE_INFO.openGraphImage,
- width: 1200,
- height: 630,
- alt: "OpenCut Wordmark",
- },
- ],
- },
- twitter: {
- card: "summary_large_image",
- title: SITE_INFO.title,
- description: SITE_INFO.description,
- creator: "@opencutapp",
- images: [SITE_INFO.twitterImage],
- },
- 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: SITE_INFO.title,
- },
- manifest: "/manifest.json",
- other: {
- "msapplication-config": "/browserconfig.xml",
- },
+ metadataBase: new URL(SITE_URL),
+ title: SITE_INFO.title,
+ description: SITE_INFO.description,
+ openGraph: {
+ title: SITE_INFO.title,
+ description: SITE_INFO.description,
+ url: SITE_URL,
+ siteName: SITE_INFO.title,
+ locale: "en_US",
+ type: "website",
+ images: [
+ {
+ url: SITE_INFO.openGraphImage,
+ width: 1200,
+ height: 630,
+ alt: "OpenCut Wordmark",
+ },
+ ],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: SITE_INFO.title,
+ description: SITE_INFO.description,
+ creator: "@opencutapp",
+ images: [SITE_INFO.twitterImage],
+ },
+ 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: SITE_INFO.title,
+ },
+ manifest: "/manifest.json",
+ other: {
+ "msapplication-config": "/browserconfig.xml",
+ },
};
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index 818884ed..d97c9a54 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -2,20 +2,20 @@ import { Hero } from "@/components/landing/hero";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
import type { Metadata } from "next";
-import { SITE_URL } from "@/constants/site";
+import { SITE_URL } from "@/constants/site-constants";
export const metadata: Metadata = {
- alternates: {
- canonical: SITE_URL,
- },
+ alternates: {
+ canonical: SITE_URL,
+ },
};
export default async function Home() {
- return (
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+ );
}
diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx
index 4bb1e023..b71b8d30 100644
--- a/apps/web/src/app/privacy/page.tsx
+++ b/apps/web/src/app/privacy/page.tsx
@@ -1,253 +1,287 @@
-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";
+import type { Metadata } from "next";
+import { BasePage } from "@/app/base-page";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import { Separator } from "@/components/ui/separator";
+import { SOCIAL_LINKS } from "@/constants/site-constants";
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",
- },
+ 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 (
-
-
-
-
-
-
-
-
-
-
- Open Source
-
-
-
- Privacy Policy
-
-
- Learn how we handle your data and privacy. Contact us if you
- have any questions.
-
-
-
-
-
-
- Your Videos Stay Private
-
-
-
- OpenCut processes all videos locally on your device.
- {" "}
- We never upload, store, or have access to your video files.
- Your content remains completely private and under your
- control at all times.
-
-
- All video editing, rendering, and processing happens in your
- browser using WebAssembly and local storage. No video data
- is transmitted to our servers.
-
-
+ return (
+
+
+
+
+ Quick summary
+
+
+
+ Your content stays private and encrypted.
+
+
+
+ Basic editing happens locally in your browser - we never see
+ your files
+
+
+ AI features require encrypted uploads - your content is
+ encrypted before leaving your device
+
+
+ We only collect your email and basic profile info for your
+ account
+
+ Project data stays on your device, not our servers
+
+ We use analytics to improve the app, but no personal video
+ content is tracked
+
+
+ You can delete your account anytime and all data gets removed
+
+ We don't sell your data or share it with advertisers
+
+
+ Questions? Email us at{" "}
+
+ oss@opencut.app
+
+
+
+
+
-
-
- Account Information
-
-
- When you create an account, we only collect:
-
-
- Email address (for account access)
-
- Profile information from Google OAuth (if you choose to
- sign in with Google)
-
-
-
-
- We do NOT store your projects on our servers.
- {" "}
- All project data, including names, thumbnails, and creation
- dates, is stored locally in your browser using IndexedDB.
-
-
- We use{" "}
-
- Better Auth
- {" "}
- for secure authentication and follow industry-standard
- security practices.
-
-
+
+ How We Handle Your Content
+
+ Basic video editing happens locally on your device. {" "}
+ For standard editing features, we never upload, store, or have access
+ to your video files. Your content remains completely private and under
+ your control.
+
+
+ AI features require secure processing: When you
+ choose to use AI features like auto captions, your audio/video content
+ is encrypted on your device before being uploaded to our servers for
+ processing. We use zero-knowledge encryption, meaning we cannot
+ decrypt or view your content.
+
+
+ After AI processing is complete, the encrypted content is immediately
+ deleted from our servers. Only the results (like generated captions)
+ are returned to your device.
+
+
-
- Analytics
-
- We use{" "}
-
- Databuddy
- {" "}
- for completely anonymized and non-invasive analytics to
- understand how people use OpenCut.
-
-
- This helps us improve the editor, but we never collect
- personal information, track individual users, or store any
- data that could identify you.
-
-
+
+ Account Information
+ When you create an account, we only collect:
+
+ Email address (for account access)
+
+ Profile information from Google OAuth (if you choose to sign in with
+ Google)
+
+
+
+ We do NOT store your projects on our servers. All
+ project data, including names, thumbnails, and creation dates, is
+ stored locally in your browser using IndexedDB.
+
+
+ We use{" "}
+
+ Better Auth
+ {" "}
+ for secure authentication and follow industry-standard security
+ practices.
+
+
-
-
- Local Storage & Cookies
-
-
- We use browser local storage and IndexedDB to:
-
-
- Save your projects locally on your device
- Remember your editor preferences and settings
- Keep you logged in across browser sessions
-
-
- All data stays on your device and can be cleared at any time
- through your browser settings.
-
-
+
+ AI Features & Encryption
+
+ When you use AI-powered features (like auto captions, content
+ analysis, or enhancement tools), your content needs to be processed on
+ our servers. Here's how we protect your privacy:
+
+
+
+ Client-side encryption: Your content is encrypted
+ on your device before upload
+
+
+ Zero-knowledge processing: We cannot decrypt or
+ view your original content
+
+
+ Temporary processing: Encrypted content is deleted
+ immediately after processing
+
+
+ Opt-in only: AI features are optional - basic
+ editing remains fully local
+
+
+
+ Different AI features may process different types of content (audio
+ for captions, video for analysis, etc.), but all follow the same
+ zero-knowledge encryption approach.
+
+
-
-
- Third-Party Services
-
-
- OpenCut integrates with these services:
-
-
-
- Google OAuth: For optional Google sign-in
- (governed by Google's privacy policy)
-
-
- Vercel: For hosting and content delivery
-
-
- Databuddy: For anonymized analytics
-
-
-
+
+ Analytics
+
+ We use{" "}
+
+ Databuddy
+ {" "}
+ for completely anonymized and non-invasive analytics to understand how
+ people use OpenCut.
+
+
+ This helps us improve the editor, but we never collect personal
+ information, track individual users, or store any data that could
+ identify you.
+
+
-
- Your Rights
-
- You have complete control over your data:
-
-
-
- Delete your account and all associated data at any time
-
- Export your project data
- Clear local storage to remove all saved projects
- Contact us with any privacy concerns
-
-
+
+ Local Storage & Cookies
+ We use browser local storage and IndexedDB to:
+
+ Save your projects locally on your device
+ Remember your editor preferences and settings
+ Keep you logged in across browser sessions
+
+
+ All data stays on your device and can be cleared at any time through
+ your browser settings.
+
+
-
-
- Open Source Transparency
-
-
- 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.
-
-
- View our source code on{" "}
-
- GitHub
-
- .
-
-
+
+ Third-Party Services
+ OpenCut integrates with these services:
+
+
+ Google OAuth: For optional Google sign-in (governed
+ by Google's privacy policy)
+
+
+ Vercel: For hosting and content delivery
+
+
+ Databuddy: For anonymized analytics
+
+
+
-
+
+ Your Rights
+ You have complete control over your data:
+
+ Delete your account and all associated data at any time
+ Export your project data
+ Clear local storage to remove all saved projects
+ Contact us with any privacy concerns
+
+
-
- Last updated: July 14, 2025
-
-
-
-
-
-
-
-
- );
+
+ Open Source Transparency
+
+ 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.
+
+
+ View our source code on{" "}
+
+ GitHub
+
+ .
+
+
+
+
+
+
+
+
+ Last updated: July 14, 2025
+
+
+ );
}
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx
index 3d66a3c5..d58c3aac 100644
--- a/apps/web/src/app/projects/page.tsx
+++ b/apps/web/src/app/projects/page.tsx
@@ -1,646 +1,956 @@
"use client";
-import {
- Calendar,
- ChevronLeft,
- Loader2,
- MoreHorizontal,
- ArrowDown01,
- Plus,
- Search,
- Trash2,
- Video,
- X,
-} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
-import { useCallback, useEffect, useState } from "react";
-import { DeleteProjectDialog } from "@/components/delete-project-dialog";
-import { RenameProjectDialog } from "@/components/rename-project-dialog";
+import type { KeyboardEvent, MouseEvent } from "react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton";
-import { useProjectStore } from "@/stores/project-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-import type { TProject } from "@/types/project";
+import { useEditor } from "@/hooks/use-editor";
+import { useProjectsStore } from "./store";
+import type {
+ TProjectMetadata,
+ TProjectSortKey,
+ TProjectSortOption,
+} from "@/types/project";
+import { formatTimeCode } from "@/lib/time";
+import { formatDate } from "@/utils/date";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+import {
+ Calendar04Icon,
+ GridViewIcon,
+ LeftToRightListDashIcon,
+ PlusSignIcon,
+ Search01Icon,
+ Video01Icon,
+ MoreHorizontalIcon,
+ Delete02Icon,
+ Copy01Icon,
+ Edit03Icon,
+ ArrowDown02Icon,
+ InformationCircleIcon,
+} from "@hugeicons/core-free-icons";
+import { OcVideoIcon } from "@opencut/ui/icons";
+import { Label } from "@/components/ui/label";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { DeleteProjectDialog } from "@/components/editor/dialogs/delete-project-dialog";
+import { ProjectInfoDialog } from "@/components/editor/dialogs/project-info-dialog";
+import { RenameProjectDialog } from "@/components/editor/dialogs/rename-project-dialog";
+import { cn } from "@/utils/ui";
+
+const formatProjectDuration = ({
+ duration,
+}: {
+ duration: number | undefined;
+}): string | null => {
+ if (duration === undefined) {
+ return null;
+ }
+
+ const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS";
+ return formatTimeCode({ timeInSeconds: duration, format });
+};
export default function ProjectsPage() {
- const {
- savedProjects,
- isLoading,
- isInitialized,
- deleteProject,
- createNewProject,
- getFilteredAndSortedProjects,
- } = useProjectStore();
- const [thumbnailCache, setThumbnailCache] = useState<
- Record
- >({});
- const [_loadingThumbnails, setLoadingThumbnails] = useState>(
- new Set()
- );
- const [isSelectionMode, setIsSelectionMode] = useState(false);
- const [selectedProjects, setSelectedProjects] = useState>(
- new Set()
- );
- const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
- const [searchQuery, setSearchQuery] = useState("");
- const [sortOption, setSortOption] = useState("createdAt-desc");
- const router = useRouter();
-
- const getProjectThumbnail = useCallback(
- async (projectId: string): Promise => {
- if (thumbnailCache[projectId] !== undefined) {
- return thumbnailCache[projectId];
- }
-
- setLoadingThumbnails((prev) => new Set(prev).add(projectId));
-
- try {
- const thumbnail = await useTimelineStore
- .getState()
- .getProjectThumbnail(projectId);
- setThumbnailCache((prev) => ({ ...prev, [projectId]: thumbnail }));
- return thumbnail;
- } finally {
- setLoadingThumbnails((prev) => {
- const newSet = new Set(prev);
- newSet.delete(projectId);
- return newSet;
- });
- }
- },
- []
- );
-
- const handleCreateProject = async () => {
- const projectId = await createNewProject("New Project");
- console.log("projectId", projectId);
- router.push(`/editor/${projectId}`);
- };
-
- const handleSelectProject = (projectId: string, checked: boolean) => {
- const newSelected = new Set(selectedProjects);
- if (checked) {
- newSelected.add(projectId);
- } else {
- newSelected.delete(projectId);
- }
- setSelectedProjects(newSelected);
- };
-
- const handleSelectAll = (checked: boolean) => {
- if (checked) {
- setSelectedProjects(new Set(sortedProjects.map((p) => p.id)));
- } else {
- setSelectedProjects(new Set());
- }
- };
-
- const handleCancelSelection = () => {
- setIsSelectionMode(false);
- setSelectedProjects(new Set());
- };
-
- const handleBulkDelete = async () => {
- await Promise.all(
- Array.from(selectedProjects).map((projectId) => deleteProject(projectId))
- );
- setSelectedProjects(new Set());
- setIsSelectionMode(false);
- setIsBulkDeleteDialogOpen(false);
- };
-
- const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption);
-
- const allSelected =
- sortedProjects.length > 0 &&
- selectedProjects.size === sortedProjects.length;
- const someSelected =
- selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length;
-
- return (
-
-
-
-
-
Back
-
-
- {isSelectionMode ? (
-
-
-
- Cancel
-
- {selectedProjects.size > 0 && (
- setIsBulkDeleteDialogOpen(true)}
- >
-
- Delete ({selectedProjects.size})
-
- )}
-
- ) : (
-
- )}
-
-
-
-
-
-
- Your Projects
-
-
- {savedProjects.length}{" "}
- {savedProjects.length === 1 ? "project" : "projects"}
- {isSelectionMode && selectedProjects.size > 0 && (
-
- • {selectedProjects.size} selected
-
- )}
-
-
-
- {isSelectionMode ? (
-
-
-
- Cancel
-
- {selectedProjects.size > 0 && (
- setIsBulkDeleteDialogOpen(true)}
- >
-
- Delete Selected ({selectedProjects.size})
-
- )}
-
- ) : (
-
- setIsSelectionMode(true)}
- disabled={savedProjects.length === 0}
- >
- Select Projects
-
-
-
- )}
-
-
-
-
-
- setSearchQuery(e.target.value)}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- if (sortOption.startsWith("createdAt")) {
- setSortOption(
- sortOption.endsWith("asc")
- ? "createdAt-desc"
- : "createdAt-asc"
- );
- } else {
- setSortOption("createdAt-asc");
- }
- }}
- >
- Created{" "}
- {sortOption.startsWith("createdAt") &&
- (sortOption.endsWith("asc") ? "↑" : "↓")}
-
- {
- if (sortOption.startsWith("name")) {
- setSortOption(
- sortOption.endsWith("asc")
- ? "name-desc"
- : "name-asc"
- );
- } else {
- setSortOption("name-asc");
- }
- }}
- >
- Name{" "}
- {sortOption.startsWith("name") &&
- (sortOption.endsWith("asc") ? "↑" : "↓")}
-
-
-
-
-
- Sort by{" "}
- {sortOption.startsWith("createdAt") ? "date" : "name"} (
- {sortOption.endsWith("asc") ? "ascending" : "descending"})
-
-
-
-
-
-
-
- {isSelectionMode && sortedProjects.length > 0 && (
- handleSelectAll(!allSelected)}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- handleSelectAll(!allSelected);
- }
- }}
- className="w-full hover:cursor-pointer gap-2 mb-6 p-4 bg-muted/30 rounded-lg border items-center flex"
- tabIndex={0}
- >
-
-
- {allSelected ? "Deselect All" : "Select All"}
-
-
- ({selectedProjects.size} of {sortedProjects.length} selected)
-
-
- )}
-
- {isLoading || !isInitialized ? (
-
- {Array.from({ length: 8 }, (_, index) => (
-
- ))}
-
- ) : savedProjects.length === 0 ? (
-
- ) : sortedProjects.length === 0 ? (
- setSearchQuery("")}
- />
- ) : (
-
- {sortedProjects.map((project) => (
-
- ))}
-
- )}
-
-
-
-
- );
+ const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore();
+ const editor = useEditor();
+
+ useEffect(() => {
+ if (!editor.project.getIsInitialized()) {
+ editor.project.loadAllProjects();
+ }
+ }, [editor.project]);
+
+ const sortOption: TProjectSortOption = `${sortKey}-${sortOrder}`;
+ const projectsToDisplay = editor.project.getFilteredAndSortedProjects({
+ searchQuery,
+ sortOption,
+ });
+
+ const isLoading = editor.project.getIsLoading();
+ const isInitialized = editor.project.getIsInitialized();
+
+ return (
+
+
+
+
p.id)} />
+
+ {isLoading || !isInitialized ? (
+
+ ) : projectsToDisplay.length === 0 ? (
+
+ ) : (
+
+ {projectsToDisplay.map((project) => (
+
p.id)}
+ />
+ ))}
+
+ )}
+
+
+ );
}
-interface ProjectCardProps {
- project: TProject;
- isSelectionMode?: boolean;
- isSelected?: boolean;
- onSelect?: (projectId: string, checked: boolean) => void;
- getProjectThumbnail: (projectId: string) => Promise;
+function ProjectsHeader() {
+ const { viewMode, isHydrated, setViewMode } = useProjectsStore();
+
+ return (
+
+
+
+
+
+
+
+
+ Home
+
+
+
+
+
+
+ All projects
+
+
+
+
+
+
+ setViewMode({ viewMode: "grid" })}
+ onKeyDown={(event) =>
+ event.key === "Enter" && setViewMode({ viewMode: "grid" })
+ }
+ aria-label="Grid view"
+ aria-pressed={isHydrated && viewMode === "grid"}
+ >
+
+
+ setViewMode({ viewMode: "list" })}
+ onKeyDown={(event) =>
+ event.key === "Enter" && setViewMode({ viewMode: "list" })
+ }
+ aria-label="List view"
+ aria-pressed={isHydrated && viewMode === "list"}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ );
}
-function ProjectCard({
- project,
- isSelectionMode = false,
- isSelected = false,
- onSelect,
- getProjectThumbnail,
-}: ProjectCardProps) {
- const [isDropdownOpen, setIsDropdownOpen] = useState(false);
- const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
- const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
- const [dynamicThumbnail, setDynamicThumbnail] = useState(null);
- const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(true);
- const { deleteProject, renameProject, duplicateProject } = useProjectStore();
-
- useEffect(() => {
- const loadThumbnail = async () => {
- setIsLoadingThumbnail(true);
- try {
- const thumbnail = await getProjectThumbnail(project.id);
- setDynamicThumbnail(thumbnail);
- } finally {
- setIsLoadingThumbnail(false);
- }
- };
- loadThumbnail();
- }, [project.id, getProjectThumbnail]);
-
- const formatDate = (date: Date): string => {
- return date.toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- });
- };
-
- const handleDeleteProject = async () => {
- await deleteProject(project.id);
- setIsDropdownOpen(false);
- };
-
- const handleRenameProject = async (newName: string) => {
- await renameProject(project.id, newName);
- setIsRenameDialogOpen(false);
- };
-
- const handleDuplicateProject = async () => {
- setIsDropdownOpen(false);
- await duplicateProject(project.id);
- };
-
- const handleCardClick = (e: React.MouseEvent) => {
- if (isSelectionMode) {
- e.preventDefault();
- onSelect?.(project.id, !isSelected);
- }
- };
-
- const handleCardKeyDown = (e: React.KeyboardEvent) => {
- if (isSelectionMode && (e.key === "Enter" || e.key === " ")) {
- e.preventDefault();
- onSelect?.(project.id, !isSelected);
- }
- };
-
- const cardContent = (
-
-
- {isSelectionMode && (
-
-
-
- onSelect?.(project.id, checked as boolean)
- }
- onClick={(e) => e.stopPropagation()}
- className="w-4 h-4"
- />
-
-
- )}
-
-
- {isLoadingThumbnail ? (
-
-
-
- ) : dynamicThumbnail ? (
-
- ) : (
-
-
-
- )}
-
-
-
-
-
-
- {project.name}
-
- {!isSelectionMode && (
-
-
- e.preventDefault()}
- >
-
-
-
- {
- e.preventDefault();
- e.stopPropagation();
- }}
- >
- {
- e.preventDefault();
- e.stopPropagation();
- setIsDropdownOpen(false);
- setIsRenameDialogOpen(true);
- }}
- >
- Rename
-
- {
- e.preventDefault();
- e.stopPropagation();
- handleDuplicateProject();
- }}
- >
- Duplicate
-
-
- {
- e.preventDefault();
- e.stopPropagation();
- setIsDropdownOpen(false);
- setIsDeleteDialogOpen(true);
- }}
- >
- Delete
-
-
-
- )}
-
-
-
-
-
- Created {formatDate(project.createdAt)}
-
-
-
-
- );
-
- return (
- <>
- {isSelectionMode ? (
-
- {cardContent}
-
- ) : (
-
- {cardContent}
-
- )}
-
-
- >
- );
+const SORT_LABELS: Record = {
+ createdAt: "Created",
+ updatedAt: "Modified",
+ name: "Name",
+ duration: "Duration",
+};
+
+function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
+ const {
+ selectedProjectIds,
+ sortKey,
+ sortOrder,
+ setSortOrder,
+ setSelectedProjects,
+ clearSelectedProjects,
+ viewMode,
+ setViewMode,
+ } = useProjectsStore();
+
+ const selectedProjectCount = selectedProjectIds.length;
+ const isAllSelected =
+ projectIds.length > 0 && selectedProjectCount === projectIds.length;
+ const hasSomeSelected =
+ selectedProjectCount > 0 && selectedProjectCount < projectIds.length;
+
+ const handleSelectAll = ({ checked }: { checked: boolean }) => {
+ if (checked) {
+ setSelectedProjects({ projectIds });
+ return;
+ }
+ clearSelectedProjects();
+ };
+
+ return (
+
+
+
+
+ handleSelectAll({ checked: checked === true })
+ }
+ />
+
+ Select all
+
+
+
+
+
+
+
+ {SORT_LABELS[sortKey]}
+
+
+
+ setSortOrder({
+ sortOrder: sortOrder === "asc" ? "desc" : "asc",
+ })
+ }
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ setSortOrder({
+ sortOrder: sortOrder === "asc" ? "desc" : "asc",
+ });
+ }
+ }}
+ aria-label={`Sort ${sortOrder === "asc" ? "ascending" : "descending"}`}
+ >
+
+
+
+
+
+
+ setViewMode({ viewMode: "grid" })}
+ >
+
+
+ setViewMode({ viewMode: "list" })}
+ >
+
+
+
+
+ {selectedProjectCount > 0 ?
: null}
+
+ );
}
-function CreateButton({ onClick }: { onClick?: () => void }) {
- return (
-
-
- New project
-
- );
+function SearchBar({
+ className,
+ collapsed,
+}: {
+ className?: string;
+ collapsed?: boolean;
+}) {
+ const { searchQuery, setSearchQuery } = useProjectsStore();
+
+ return (
+ <>
+ {collapsed ? (
+
+
+
+
+
+ ) : (
+
+
+ setSearchQuery({ query: event.target.value })}
+ size="lg"
+ className="pl-9"
+ />
+
+ )}
+ >
+ );
}
-function NoProjects({ onCreateProject }: { onCreateProject: () => void }) {
- return (
-
-
-
-
-
No projects yet
-
- Start creating your first video project. Import media, edit, and export
- professional videos.
-
-
-
- Create Your First Project
-
-
- );
+const PROJECT_ACTIONS = [
+ {
+ id: "duplicate",
+ label: "Duplicate",
+ icon: Copy01Icon,
+ variant: "outline" as const,
+ },
+ {
+ id: "delete",
+ label: "Delete",
+ icon: Delete02Icon,
+ variant: "destructive-foreground" as const,
+ },
+] as const;
+
+async function deleteProjects({
+ editor,
+ ids,
+}: {
+ editor: ReturnType;
+ ids: string[];
+}) {
+ await editor.project.deleteProjects({ ids });
+}
+
+async function duplicateProjects({
+ editor,
+ ids,
+}: {
+ editor: ReturnType;
+ ids: string[];
+}) {
+ await editor.project.duplicateProjects({ ids });
}
-function NoResults({
- searchQuery,
- onClearSearch,
+async function renameProject({
+ editor,
+ id,
+ name,
}: {
- searchQuery: string;
- onClearSearch: () => void;
+ editor: ReturnType;
+ id: string;
+ name: string;
}) {
- return (
-
-
-
-
-
No results found
-
- Your search for "{searchQuery}" did not return any results.
-
-
- Clear Search
-
-
- );
+ await editor.project.renameProject({ id, name });
+}
+
+function ProjectActions() {
+ const editor = useEditor();
+ const { selectedProjectIds, clearSelectedProjects } = useProjectsStore();
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+
+ const savedProjects = editor.project.getSavedProjects();
+ const selectedProjectNames = savedProjects
+ .filter((project) => selectedProjectIds.includes(project.id))
+ .map((project) => project.name);
+
+ const handleDuplicate = async () => {
+ await duplicateProjects({ editor, ids: selectedProjectIds });
+ clearSelectedProjects();
+ };
+
+ const handleDeleteClick = () => {
+ setIsDeleteDialogOpen(true);
+ };
+
+ const handleDeleteConfirm = async () => {
+ await deleteProjects({ editor, ids: selectedProjectIds });
+ clearSelectedProjects();
+ setIsDeleteDialogOpen(false);
+ };
+
+ const actionHandlers: Record void> = {
+ duplicate: handleDuplicate,
+ delete: handleDeleteClick,
+ };
+
+ return (
+ <>
+
+
+ {PROJECT_ACTIONS.map((action) => (
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {PROJECT_ACTIONS.map((action) => (
+
+
+ {action.label}
+
+ ))}
+
+
+
+
+
+ >
+ );
+}
+
+function SortDropdown({ children }: { children: React.ReactNode }) {
+ const { sortKey, setSortKey } = useProjectsStore();
+
+ return (
+
+ {children}
+
+ setSortKey({ sortKey: "createdAt" })}
+ >
+ Created
+
+ setSortKey({ sortKey: "updatedAt" })}
+ >
+ Modified
+
+ setSortKey({ sortKey: "name" })}
+ >
+ Name
+
+ setSortKey({ sortKey: "duration" })}
+ >
+ Duration
+
+
+
+ );
+}
+
+function NewProjectButton() {
+ const editor = useEditor();
+ const router = useRouter();
+
+ const handleCreateProject = async () => {
+ const projectId = await editor.project.createNewProject({
+ name: "New project",
+ });
+ router.push(`/editor/${projectId}`);
+ };
+
+ return (
+
+ New project
+ New
+
+ );
+}
+
+function ProjectItem({
+ project,
+ allProjectIds,
+}: {
+ project: TProjectMetadata;
+ allProjectIds: string[];
+}) {
+ const {
+ selectedProjectIds,
+ viewMode,
+ setProjectSelected,
+ selectProjectRange,
+ } = useProjectsStore();
+ const selectedProjectIdSet = new Set(selectedProjectIds);
+ const isSelected = selectedProjectIdSet.has(project.id);
+ const selectedProjectCount = selectedProjectIds.length;
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const durationLabel = formatProjectDuration({ duration: project.duration });
+ const isMultiSelect = selectedProjectCount > 1;
+ const isGridView = viewMode === "grid";
+
+ const handleCheckboxChange = ({
+ checked,
+ shiftKey,
+ }: {
+ checked: boolean;
+ shiftKey: boolean;
+ }) => {
+ if (shiftKey && checked) {
+ selectProjectRange({ projectId: project.id, allProjectIds });
+ return;
+ }
+ setProjectSelected({ projectId: project.id, isSelected: checked });
+ };
+
+ const gridContent = (
+
+
+
+ {project.thumbnail ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {durationLabel && (
+
+ {durationLabel}
+
+ )}
+
+
+
+
+ {project.name}
+
+
+
+ Created {formatDate({ date: project.createdAt })}
+
+
+
+ );
+
+ const listRowContent = (
+
+
+ {project.thumbnail ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {project.name}
+
+
+
+ {durationLabel ?? "—"}
+
+
+
+ {formatDate({ date: project.createdAt })}
+
+
+ );
+
+ const listContent = (
+
+
event.preventDefault()}
+ onClick={(event) => {
+ handleCheckboxChange({
+ checked: !isSelected,
+ shiftKey: event.shiftKey,
+ });
+ }}
+ onCheckedChange={() => {}}
+ className="size-5 shrink-0"
+ />
+
+
+ {listRowContent}
+
+
+ {!isMultiSelect && (
+
+ )}
+
+ );
+
+ const cardContent = isGridView ? gridContent : listContent;
+
+ if (!isGridView) {
+ return {listContent}
;
+ }
+
+ return (
+
+
+ {cardContent}
+
+
+ {isGridView && (
+ <>
+
event.preventDefault()}
+ onClick={(event) => {
+ handleCheckboxChange({
+ checked: !isSelected,
+ shiftKey: event.shiftKey,
+ });
+ }}
+ onCheckedChange={() => {}}
+ className={`absolute z-10 size-5 top-3 left-3 ${
+ isSelected || isDropdownOpen
+ ? "opacity-100"
+ : "opacity-0 group-hover:opacity-100"
+ }`}
+ />
+
+ {!isMultiSelect && (
+
+ )}
+ >
+ )}
+
+ );
+}
+
+function ProjectMenu({
+ isOpen,
+ onOpenChange,
+ project,
+ variant = "grid",
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ project: TProjectMetadata;
+ variant?: "grid" | "list";
+}) {
+ const editor = useEditor();
+ const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
+ const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false);
+
+ const handleMenuClick = ({
+ event,
+ }: {
+ event: MouseEvent;
+ }) => {
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ const handleMenuKeyDown = ({
+ event,
+ }: {
+ event: KeyboardEvent;
+ }) => {
+ if (event.key !== "Enter" && event.key !== " ") {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ const handleRename = () => {
+ setIsRenameDialogOpen(true);
+ onOpenChange(false);
+ };
+
+ const handleDuplicate = async () => {
+ await duplicateProjects({ editor, ids: [project.id] });
+ onOpenChange(false);
+ };
+
+ const handleDeleteClick = () => {
+ setIsDeleteDialogOpen(true);
+ onOpenChange(false);
+ };
+
+ const handleDeleteConfirm = async () => {
+ await deleteProjects({ editor, ids: [project.id] });
+ setIsDeleteDialogOpen(false);
+ };
+
+ const handleInfoClick = () => {
+ setIsInfoDialogOpen(true);
+ onOpenChange(false);
+ };
+
+ const isGrid = variant === "grid";
+
+ return (
+ <>
+
+
+
+ handleMenuClick({
+ event: event as unknown as MouseEvent,
+ })
+ }
+ onMouseDown={(event) => event.stopPropagation()}
+ onKeyDown={(event) =>
+ handleMenuKeyDown({
+ event: event as unknown as KeyboardEvent,
+ })
+ }
+ >
+
+
+
+
+
+
+ Rename
+
+
+
+ Duplicate
+
+
+
+ Info
+
+
+
+ Delete
+
+
+
+
+ {
+ await renameProject({ editor, id: project.id, name: newName });
+ setIsRenameDialogOpen(false);
+ }}
+ />
+
+
+
+
+ >
+ );
+}
+
+function ProjectsSkeleton() {
+ const skeletonIds = Array.from(
+ { length: 24 },
+ (_, index) => `skeleton-${index}`,
+ );
+
+ return (
+
+ {skeletonIds.map((skeletonId) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
+function EmptyState() {
+ const { searchQuery, setSearchQuery } = useProjectsStore();
+ const router = useRouter();
+ const editor = useEditor();
+ const savedProjects = editor.project.getSavedProjects();
+
+ const handleCreateProject = async () => {
+ try {
+ const projectId = await editor.project.createNewProject({
+ name: "New project",
+ });
+ router.push(`/editor/${projectId}`);
+ } catch (error) {
+ toast.error("Failed to create project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ }
+ };
+
+ if (savedProjects.length > 0) {
+ return (
+
+
+
+
+
No results found
+
+ Your search for "{searchQuery}" did not return any results.
+
+
+
+
setSearchQuery({ query: "" })}
+ variant="outline"
+ size="lg"
+ >
+ Clear search
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
No projects yet
+
+ Start creating your first project. Import media, edit, and export your
+ videos. All privately.
+
+
+
+
+ Create your first project
+
+
+ );
}
diff --git a/apps/web/src/app/projects/store.ts b/apps/web/src/app/projects/store.ts
new file mode 100644
index 00000000..eaf8f947
--- /dev/null
+++ b/apps/web/src/app/projects/store.ts
@@ -0,0 +1,129 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import type { TProjectSortKey } from "@/types/project";
+
+export type ProjectsViewMode = "grid" | "list";
+
+interface ProjectsState {
+ searchQuery: string;
+ sortKey: TProjectSortKey;
+ sortOrder: "asc" | "desc";
+ viewMode: ProjectsViewMode;
+ selectedProjectIds: string[];
+ lastSelectedProjectId: string | null;
+ isHydrated: boolean;
+ setIsHydrated: ({ isHydrated }: { isHydrated: boolean }) => void;
+ setSearchQuery: ({ query }: { query: string }) => void;
+ setSortKey: ({ sortKey }: { sortKey: TProjectSortKey }) => void;
+ setSortOrder: ({ sortOrder }: { sortOrder: "asc" | "desc" }) => void;
+ toggleSortOrder: () => void;
+ setViewMode: ({ viewMode }: { viewMode: ProjectsViewMode }) => void;
+ setSelectedProjects: ({ projectIds }: { projectIds: string[] }) => void;
+ clearSelectedProjects: () => void;
+ setProjectSelected: ({
+ projectId,
+ isSelected,
+ }: {
+ projectId: string;
+ isSelected: boolean;
+ }) => void;
+ selectProjectRange: ({
+ projectId,
+ allProjectIds,
+ }: {
+ projectId: string;
+ allProjectIds: string[];
+ }) => void;
+}
+
+const getNextSelectedProjectIds = ({
+ selectedProjectIds,
+ projectId,
+ isSelected,
+}: {
+ selectedProjectIds: string[];
+ projectId: string;
+ isSelected: boolean;
+}): string[] => {
+ const selectedProjectIdSet = new Set(selectedProjectIds);
+
+ if (isSelected) {
+ selectedProjectIdSet.add(projectId);
+ return Array.from(selectedProjectIdSet);
+ }
+
+ selectedProjectIdSet.delete(projectId);
+ return Array.from(selectedProjectIdSet);
+};
+
+export const useProjectsStore = create()(
+ persist(
+ (set) => ({
+ searchQuery: "",
+ sortKey: "createdAt",
+ sortOrder: "desc",
+ viewMode: "grid",
+ selectedProjectIds: [],
+ lastSelectedProjectId: null,
+ isHydrated: false,
+ setIsHydrated: ({ isHydrated }) => set({ isHydrated }),
+ setSearchQuery: ({ query }) => set({ searchQuery: query }),
+ setSortKey: ({ sortKey }) => set({ sortKey }),
+ setSortOrder: ({ sortOrder }) => set({ sortOrder }),
+ toggleSortOrder: () =>
+ set((state) => ({
+ sortOrder: state.sortOrder === "asc" ? "desc" : "asc",
+ })),
+ setViewMode: ({ viewMode }) => set({ viewMode }),
+ setSelectedProjects: ({ projectIds }) =>
+ set({ selectedProjectIds: projectIds }),
+ clearSelectedProjects: () =>
+ set({ selectedProjectIds: [], lastSelectedProjectId: null }),
+ setProjectSelected: ({ projectId, isSelected }) =>
+ set((state) => ({
+ selectedProjectIds: getNextSelectedProjectIds({
+ selectedProjectIds: state.selectedProjectIds,
+ projectId,
+ isSelected,
+ }),
+ lastSelectedProjectId: isSelected ? projectId : state.lastSelectedProjectId,
+ })),
+ selectProjectRange: ({ projectId, allProjectIds }) =>
+ set((state) => {
+ const anchorId = state.lastSelectedProjectId;
+ if (!anchorId) {
+ return {
+ selectedProjectIds: [projectId],
+ lastSelectedProjectId: projectId,
+ };
+ }
+
+ const anchorIndex = allProjectIds.indexOf(anchorId);
+ const targetIndex = allProjectIds.indexOf(projectId);
+
+ if (anchorIndex === -1 || targetIndex === -1) {
+ return {
+ selectedProjectIds: [projectId],
+ lastSelectedProjectId: projectId,
+ };
+ }
+
+ const startIndex = Math.min(anchorIndex, targetIndex);
+ const endIndex = Math.max(anchorIndex, targetIndex);
+ const rangeIds = allProjectIds.slice(startIndex, endIndex + 1);
+
+ const merged = new Set([...state.selectedProjectIds, ...rangeIds]);
+ return {
+ selectedProjectIds: Array.from(merged),
+ };
+ }),
+ }),
+ {
+ name: "projects-view-mode",
+ partialize: (state) => ({ viewMode: state.viewMode }),
+ onRehydrateStorage: () => (state) => {
+ state?.setIsHydrated({ isHydrated: true });
+ },
+ },
+ ),
+);
diff --git a/apps/web/src/app/roadmap/page.tsx b/apps/web/src/app/roadmap/page.tsx
index b5397b63..5227f808 100644
--- a/apps/web/src/app/roadmap/page.tsx
+++ b/apps/web/src/app/roadmap/page.tsx
@@ -1,247 +1,144 @@
-import { Metadata } from "next";
+import type { Metadata } from "next";
+import { BasePage } from "@/app/base-page";
+import { GitHubContributeSection } from "@/components/gitHub-contribute-section";
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";
+import { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper";
+import { cn } from "@/utils/ui";
-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: "Completed",
- type: "complete",
- },
- },
- {
- 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",
- },
- },
+type StatusType = "complete" | "pending" | "default" | "info";
+
+interface Status {
+ text: string;
+ type: StatusType;
+}
+
+interface RoadmapItem {
+ title: string;
+ description: string;
+ status: Status;
+}
+
+const roadmapItems: RoadmapItem[] = [
+ {
+ 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:
+ "Build 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: "Essential functionality",
+ description:
+ "Everything that makes a video editor **useful**. Timeline interactivity, storage, effects, transitions, etc.",
+ status: {
+ text: "In progress",
+ type: "pending",
+ },
+ },
+ {
+ title: "Badge (potentially)",
+ description:
+ 'An "Edit with OpenCut" badge web apps can integrate. Shows on video players.',
+ status: {
+ text: "Not started",
+ type: "default",
+ },
+ },
];
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"],
- },
+ 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 (
-
-
-
-
-
-
-
-
-
-
- Open Source
-
-
-
- Roadmap
-
-
- What's coming next for OpenCut (last updated: July 14, 2025)
-
-
-
- {roadmapItems.map((item, index) => (
-
-
-
- {index + 1}.
-
-
-
-
{item.title}
-
- {item.status.text}
-
-
-
-
(
-
- {children}
-
- ),
- strong: ({ children }) => (
-
- {children}
-
- ),
- }}
- >
- {item.description}
-
-
-
-
-
- ))}
-
+ return (
+
+
+
+ {roadmapItems.map((item, index) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+function RoadmapItem({ item, index }: { item: RoadmapItem; index: number }) {
+ return (
+
+
+ {index + 1}
+
{item.title}
+
+
+
+ {item.description}
+
+
+ );
+}
-
-
-
Want to Help?
-
- OpenCut is open source and built by the community. Every
- contribution, no matter how small, helps us build the best
- free video editor possible.
-
-
-
-
-
- Start Contributing
-
-
-
-
- Report Issues
-
-
-
-
-
-
-
-
-
-
- );
+function StatusBadge({
+ status,
+ className,
+}: {
+ status: Status;
+ className?: string;
+}) {
+ return (
+
+ {status.text}
+
+ );
}
diff --git a/apps/web/src/app/robots.ts b/apps/web/src/app/robots.ts
index ad6c31c8..60c13829 100644
--- a/apps/web/src/app/robots.ts
+++ b/apps/web/src/app/robots.ts
@@ -1,13 +1,13 @@
import type { MetadataRoute } from "next";
-import { SITE_URL } from "@/constants/site";
+import { SITE_URL } from "@/constants/site-constants";
export default function robots(): MetadataRoute.Robots {
- return {
- rules: {
- userAgent: "*",
- allow: "/",
- disallow: ["/_next/", "/projects/", "/editor/"],
- },
- sitemap: `${SITE_URL}/sitemap.xml`,
- };
+ return {
+ rules: {
+ userAgent: "*",
+ allow: "/",
+ disallow: ["/_next/", "/projects/", "/editor/"],
+ },
+ sitemap: `${SITE_URL}/sitemap.xml`,
+ };
}
diff --git a/apps/web/src/app/rss.xml/route.ts b/apps/web/src/app/rss.xml/route.ts
index ab645655..909d5a1a 100644
--- a/apps/web/src/app/rss.xml/route.ts
+++ b/apps/web/src/app/rss.xml/route.ts
@@ -1,46 +1,46 @@
import { Feed } from "feed";
-import { getPosts } from "@/lib/blog-query";
-import { SITE_INFO, SITE_URL } from "@/constants/site";
+import { getPosts } from "@/lib/blog/query";
+import { SITE_INFO, SITE_URL } from "@/constants/site-constants";
export async function GET() {
- try {
- const { posts } = await getPosts();
+ try {
+ const { posts } = await getPosts();
- const feed = new Feed({
- title: `${SITE_INFO.title} Blog`,
- description: SITE_INFO.description,
- id: `${SITE_URL}`,
- link: `${SITE_URL}/blog/`,
- language: "en",
- image: `${SITE_INFO.openGraphImage}`,
- favicon: `${SITE_INFO.favicon}`,
- copyright: `All rights reserved ${new Date().getFullYear()}, ${
- SITE_INFO.title
- }`,
- });
+ const feed = new Feed({
+ title: `${SITE_INFO.title} Blog`,
+ description: SITE_INFO.description,
+ id: `${SITE_URL}`,
+ link: `${SITE_URL}/blog/`,
+ language: "en",
+ image: `${SITE_INFO.openGraphImage}`,
+ favicon: `${SITE_INFO.favicon}`,
+ copyright: `All rights reserved ${new Date().getFullYear()}, ${
+ SITE_INFO.title
+ }`,
+ });
- for (const post of posts) {
- feed.addItem({
- title: post.title,
- id: `${SITE_URL}/blog/${post.slug}`,
- link: `${SITE_URL}/blog/${post.slug}`,
- description: post.description,
- author: post.authors.map((author) => ({
- name: author.name,
- })),
- date: new Date(post.publishedAt),
- image: post.coverImage || SITE_INFO.openGraphImage,
- });
- }
+ for (const post of posts) {
+ feed.addItem({
+ title: post.title,
+ id: `${SITE_URL}/blog/${post.slug}`,
+ link: `${SITE_URL}/blog/${post.slug}`,
+ description: post.description,
+ author: post.authors.map((author) => ({
+ name: author.name,
+ })),
+ date: new Date(post.publishedAt),
+ image: post.coverImage || SITE_INFO.openGraphImage,
+ });
+ }
- return new Response(feed.rss2(), {
- headers: {
- "Content-Type": "text/xml",
- "Cache-Control": "public, max-age=86400, stale-while-revalidate",
- },
- });
- } catch (error) {
- console.error("Error generating RSS feed", error);
- return new Response("Internal Server Error", { status: 500 });
- }
+ return new Response(feed.rss2(), {
+ headers: {
+ "Content-Type": "text/xml",
+ "Cache-Control": "public, max-age=86400, stale-while-revalidate",
+ },
+ });
+ } catch (error) {
+ console.error("Error generating RSS feed", error);
+ return new Response("Internal Server Error", { status: 500 });
+ }
}
diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts
index 81a5ebf4..fed1aed6 100644
--- a/apps/web/src/app/sitemap.ts
+++ b/apps/web/src/app/sitemap.ts
@@ -1,61 +1,61 @@
-import { SITE_URL } from "@/constants/site";
-import { getPosts } from "@/lib/blog-query";
+import { SITE_URL } from "@/constants/site-constants";
+import { getPosts } from "@/lib/blog/query";
import type { MetadataRoute } from "next";
export default async function sitemap(): Promise {
- const data = await getPosts();
+ const data = await getPosts();
- const postPages: MetadataRoute.Sitemap =
- data?.posts?.map((post) => ({
- url: `${SITE_URL}/blog/${post.slug}`,
- lastModified: new Date(post.publishedAt),
- changeFrequency: "weekly",
- priority: 0.8,
- })) ?? [];
+ const postPages: MetadataRoute.Sitemap =
+ data?.posts?.map((post) => ({
+ url: `${SITE_URL}/blog/${post.slug}`,
+ lastModified: new Date(post.publishedAt),
+ changeFrequency: "weekly",
+ priority: 0.8,
+ })) ?? [];
- return [
- {
- url: SITE_URL,
- lastModified: new Date(),
- changeFrequency: "weekly",
- priority: 1,
- },
- {
- url: `${SITE_URL}/contributors`,
- lastModified: new Date(),
- changeFrequency: "daily",
- priority: 0.5,
- },
- {
- url: `${SITE_URL}/roadmap`,
- lastModified: new Date(),
- changeFrequency: "weekly",
- priority: 1,
- },
- {
- url: `${SITE_URL}/privacy`,
- lastModified: new Date(),
- changeFrequency: "monthly",
- priority: 0.5,
- },
- {
- url: `${SITE_URL}/terms`,
- lastModified: new Date(),
- changeFrequency: "monthly",
- priority: 0.5,
- },
- {
- url: `${SITE_URL}/why-not-capcut`,
- lastModified: new Date(),
- changeFrequency: "yearly",
- priority: 1,
- },
- {
- url: `${SITE_URL}/blog`,
- lastModified: new Date(),
- changeFrequency: "weekly",
- priority: 1,
- },
- ...postPages,
- ];
+ return [
+ {
+ url: SITE_URL,
+ lastModified: new Date(),
+ changeFrequency: "weekly",
+ priority: 1,
+ },
+ {
+ url: `${SITE_URL}/contributors`,
+ lastModified: new Date(),
+ changeFrequency: "daily",
+ priority: 0.5,
+ },
+ {
+ url: `${SITE_URL}/roadmap`,
+ lastModified: new Date(),
+ changeFrequency: "weekly",
+ priority: 1,
+ },
+ {
+ url: `${SITE_URL}/privacy`,
+ lastModified: new Date(),
+ changeFrequency: "monthly",
+ priority: 0.5,
+ },
+ {
+ url: `${SITE_URL}/terms`,
+ lastModified: new Date(),
+ changeFrequency: "monthly",
+ priority: 0.5,
+ },
+ {
+ url: `${SITE_URL}/why-not-capcut`,
+ lastModified: new Date(),
+ changeFrequency: "yearly",
+ priority: 1,
+ },
+ {
+ url: `${SITE_URL}/blog`,
+ lastModified: new Date(),
+ changeFrequency: "weekly",
+ priority: 1,
+ },
+ ...postPages,
+ ];
}
diff --git a/apps/web/src/app/sponsors/page.tsx b/apps/web/src/app/sponsors/page.tsx
new file mode 100644
index 00000000..5d8b4e6d
--- /dev/null
+++ b/apps/web/src/app/sponsors/page.tsx
@@ -0,0 +1,82 @@
+import type { Metadata } from "next";
+import Image from "next/image";
+import Link from "next/link";
+import { BasePage } from "@/app/base-page";
+import { Card, CardContent } from "@/components/ui/card";
+import { SPONSORS, type Sponsor } from "@/constants/site-constants";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { LinkSquare02Icon } from "@hugeicons/core-free-icons";
+
+export const metadata: Metadata = {
+ title: "Sponsors - OpenCut",
+ description:
+ "Support OpenCut and help us build the future of free and open-source video editing.",
+ openGraph: {
+ title: "Sponsors - OpenCut",
+ description:
+ "Support OpenCut and help us build the future of free and open-source video editing.",
+ type: "website",
+ },
+};
+
+export default function SponsorsPage() {
+ return (
+
+
+
+ Sponsors
+
+
+ Support OpenCut and help us build the future of privacy-first video
+ editing.
+
+
+
+
+ );
+}
+
+function SponsorsGrid() {
+ return (
+
+ {SPONSORS.map((sponsor) => (
+
+ ))}
+
+ );
+}
+
+function SponsorCard({ sponsor }: { sponsor: Sponsor }) {
+ return (
+
+
+
+
+
+
+
+ {sponsor.name}
+
+
+
+
{sponsor.description}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/terms/page.tsx b/apps/web/src/app/terms/page.tsx
index ee202d8b..d5d43ba7 100644
--- a/apps/web/src/app/terms/page.tsx
+++ b/apps/web/src/app/terms/page.tsx
@@ -1,317 +1,295 @@
-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";
+import type { Metadata } from "next";
+import { BasePage } from "@/app/base-page";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import { Separator } from "@/components/ui/separator";
+import { SOCIAL_LINKS } from "@/constants/site-constants";
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",
- },
+ 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 (
-
-
-
-
-
-
-
-
-
-
- Open Source
-
-
-
- Terms of Service
-
-
- Fair and transparent terms for our free, open-source video
- editor.
-
-
-
-
-
-
- Welcome to OpenCut
-
-
- 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.
-
-
- Key principle: Your content stays on your
- device. We never claim ownership of your videos or projects.
-
-
+ return (
+
+
+
+
+ Quick summary
+
+
+
+ You own your content, we own nothing.
+
+
+
+ Your content stays private - basic editing is local, AI features
+ use encrypted uploads
+
+
+ We never claim ownership of your content, even when processing
+ AI features
+
+
+ Free for personal and commercial use with no watermarks or
+ restrictions
+
+ Don't use OpenCut for illegal activities or harassment
+
+ Service provided "as is" - we can't guarantee perfect uptime
+
+
+ Open source means you can review our code and self-host if
+ needed
+
+
+ You can delete your account anytime and keep using your exported
+ videos
+
+
+
+ Questions? Email us at{" "}
+
+ oss@opencut.app
+
+
+
+
+
-
-
- Your Content, Your Rights
-
-
- You own everything you create. 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.
-
-
-
- Your videos remain completely private and under your
- control
-
-
- You retain all intellectual property rights to your
- content
-
-
- You can export and use your content however you choose
-
-
- No watermarks, no licensing restrictions from OpenCut
-
-
-
+
+ Your Content, Your Rights
+
+ You own everything you create. OpenCut processes
+ basic editing locally on your device. For AI features, content is
+ encrypted before upload and we cannot access your original files. We
+ make no claims to ownership, licensing, or rights over your videos,
+ projects, or any content you create using OpenCut.
+
+
+
+ Your content remains private and under your control at all times
+
+ You retain all intellectual property rights to your content
+
+ Even when using AI features, we cannot access your unencrypted
+ content
+
+ You can export and use your content however you choose
+ No watermarks, no licensing restrictions from OpenCut
+
+
-
-
- How You Can Use OpenCut
-
-
- OpenCut is free for personal and commercial use. You can:
-
-
-
- Create videos for personal, educational, or commercial
- purposes
-
- Use OpenCut for client work and paid projects
- Share and distribute videos created with OpenCut
-
- Modify and distribute the OpenCut software (under MIT
- license)
-
-
-
- What we ask: Don't use OpenCut for illegal
- activities, harassment, or creating harmful content. Be
- respectful of others and follow applicable laws.
-
-
+
+ How You Can Use OpenCut
+ OpenCut is free for personal and commercial use. You can:
+
+
+ Create videos for personal, educational, or commercial purposes
+
+ Use OpenCut for client work and paid projects
+ Share and distribute videos created with OpenCut
+
+ Modify and distribute the OpenCut software (under MIT license)
+
+
+
+ What we ask: Don't use OpenCut for illegal
+ activities, harassment, or creating harmful content. Be respectful of
+ others and follow applicable laws.
+
+
-
-
- Account and Service
-
-
- To use certain features, you may create an account:
-
-
- Provide accurate information when signing up
-
- Keep your account secure and don't share credentials
-
- You're responsible for activity under your account
- You can delete your account at any time
-
-
- OpenCut is provided "as is" without warranties. While we
- strive for reliability, we can't guarantee uninterrupted
- service.
-
-
+
+
+ AI Features and Data Processing
+
+
+ OpenCut offers optional AI-powered features that require server
+ processing:
+
+
+
+ AI features (auto captions, content analysis, etc.) are completely
+ optional
+
+ Your content is encrypted on your device before any upload
+
+ We use zero-knowledge encryption - we cannot decrypt your content
+
+ Encrypted content is deleted immediately after processing
+
+ You maintain full ownership and control of your content throughout
+
+
+
+ By using AI features, you consent to the temporary, encrypted
+ processing of your content as described in our Privacy Policy. You can
+ always choose to use only local editing features.
+
+
-
-
- Open Source Benefits
-
-
- Because OpenCut is open source, you have additional rights:
-
-
-
- Review our code to see exactly how we handle your data
-
- Self-host OpenCut on your own servers
- Modify the software to suit your needs
- Contribute improvements back to the community
-
-
- View our source code and license on{" "}
-
- GitHub
-
- .
-
-
+
+ Account and Service
+ To use certain features, you may create an account:
+
+ Provide accurate information when signing up
+ Keep your account secure and don't share credentials
+ You're responsible for activity under your account
+ You can delete your account at any time
+
+
+ OpenCut is provided "as is" without warranties. While we strive for
+ reliability, we can't guarantee uninterrupted service.
+
+
-
-
- Third-Party Content
-
-
- When using OpenCut, make sure you have the right to use any
- content you import:
-
-
-
- Only upload content you own or have permission to use
-
-
- Respect copyright, trademarks, and other intellectual
- property
-
-
- Don't use copyrighted music, images, or videos without
- permission
-
-
- You're responsible for any claims related to your content
-
-
-
+
+ Open Source Benefits
+ Because OpenCut is open source, you have additional rights:
+
+ Review our code to see exactly how we handle your data
+ Self-host OpenCut on your own servers
+ Modify the software to suit your needs
+ Contribute improvements back to the community
+
+
+ View our source code and license on{" "}
+
+ GitHub
+
+ .
+
+
-
-
- Limitations and Liability
-
-
- OpenCut is provided free of charge. To the extent permitted
- by law:
-
-
- We're not liable for any loss of data or content
-
- Projects are stored in your browser and may be lost if you
- clear browser data
-
- We're not responsible for how you use the service
-
- Our liability is limited to the maximum extent allowed by
- law
-
-
-
- Since your content stays on your device, we have no way to
- recover lost projects. Consider exporting important videos
- when finished editing.
-
-
+
+ Third-Party Content
+
+ When using OpenCut, make sure you have the right to use any content
+ you import:
+
+
+ Only upload content you own or have permission to use
+
+ Respect copyright, trademarks, and other intellectual property
+
+
+ Don't use copyrighted music, images, or videos without permission
+
+ You're responsible for any claims related to your content
+
+
-
-
- Service Changes
-
- We may update OpenCut and these terms:
-
-
- We'll notify you of significant changes to these terms
-
- Continued use means you accept any updates
-
- You can always self-host an older version if you prefer
-
-
- Major changes will be discussed with the community on
- GitHub
-
-
-
+
+ Limitations and Liability
+
+ OpenCut is provided free of charge. To the extent permitted by law:
+
+
+ We're not liable for any loss of data or content
+
+ Projects are stored in your browser and may be lost if you clear
+ browser data
+
+ We're not responsible for how you use the service
+ Our liability is limited to the maximum extent allowed by law
+
+
+ Since your content stays on your device, we have no way to recover
+ lost projects. Consider exporting important videos when finished
+ editing.
+
+
-
- Termination
-
- You can stop using OpenCut at any time:
-
-
- Delete your account through your profile settings
- Clear your browser data to remove local projects
-
- Your content remains yours even if you stop using OpenCut
-
-
- We may suspend accounts for violations of these terms
-
-
-
+
+ Service Changes
+ We may update OpenCut and these terms:
+
+ We'll notify you of significant changes to these terms
+ Continued use means you accept any updates
+ You can always self-host an older version if you prefer
+ Major changes will be discussed with the community on GitHub
+
+
-
-
- Contact and Disputes
-
-
- Questions about these terms or need to report an issue?
-
-
- Contact us through our{" "}
-
- GitHub repository
-
- , email us at{" "}
-
- oss@opencut.app
-
- , or reach out on{" "}
-
- X (Twitter)
-
- .
-
-
- These terms are governed by applicable law in your
- jurisdiction. We prefer to resolve disputes through friendly
- discussion in our open-source community.
-
-
+
+ Termination
+ You can stop using OpenCut at any time:
+
+ Delete your account through your profile settings
+ Clear your browser data to remove local projects
+ Your content remains yours even if you stop using OpenCut
+ We may suspend accounts for violations of these terms
+
+
-
- Last updated: July 14, 2025
-
-
-
-
-
-
-
-
- );
+
+ Contact Us
+ Questions about these terms or need to report an issue?
+
+ Contact us through our{" "}
+
+ GitHub repository
+
+ , email us at{" "}
+
+ oss@opencut.app
+
+ , or reach out on{" "}
+
+ X (Twitter)
+
+ .
+
+
+ These terms are governed by applicable law in your jurisdiction. We
+ prefer to resolve disputes through friendly discussion in our
+ open-source community.
+
+
+
+
+ Last updated: July 14, 2025
+
+
+ );
}
diff --git a/apps/web/src/app/why-not-capcut/page.tsx b/apps/web/src/app/why-not-capcut/page.tsx
deleted file mode 100644
index b05860a6..00000000
--- a/apps/web/src/app/why-not-capcut/page.tsx
+++ /dev/null
@@ -1,191 +0,0 @@
-import { Header } from "@/components/header";
-
-export default function WhyNotCapcut() {
- return (
-
-
-
-
-
-
-
-
-
-
- Fuck CapCut
-
-
- Roasting time, so get ready motherfucker.
-
-
-
-
-
-
- Seriously, what the fuck else do you want?
-
-
- You probably use CapCut and think your video editing is
- special. You think your fucking TikTok with 47 transitions and
- 12 different fonts is going to get you some viral fame. You
- think loading up every goddamn effect in their library makes
- your content better. Wrong, motherfucker. Let me describe what
- CapCut actually gives you:
-
-
- A paywall every time you breathe
- Terms of service that steal your shit
-
- More "Get Pro" dialogs than a Windows 95 error message
-
-
- Features that disappear behind paywalls while you're fucking
- using them
-
- Bugs disguised as "premium features"
-
-
- Well guess what, motherfucker:
-
-
- You. Are. Getting. Scammed. Look at this shit. It's a fucking
- video editor. Why the fuck do you need to pay $20/month just
- to remove a goddamn watermark? You spent hours editing your
- video and they slap their logo on it like they fucking made
- it.
-
-
-
-
-
- The "Get Pro" dialog is everywhere
-
-
- This motherfucking dialog pops up more than ads on a pirated
- movie site. Want to add a transition? Get Pro. Want to export
- without their watermark? Get Pro. Want to use more than 2
- fonts? Get fucking Pro, peasant.
-
-
- Did you seriously think you could edit a video without seeing
- this dialog 47 times? You click one button and BAM - there it
- is again, asking for your credit card like a desperate ex
- asking for money.
-
-
-
-
-
- Everything costs money now
-
-
- You dumbass. You thought CapCut was free, but no. Free means
- they let you open the app. Everything else costs money. Basic
- shake effect? That'll be $20/month. A decent transition that
- isn't "fade"? Pay up, motherfucker.
-
-
- Here's my favorite piece of bullshit: You import an MP3 file -
- you know, AUDIO - and try to export. "Sorry, can't export
- because you're using our premium extract audio feature!"
-
-
-
- My MP3 was already fucking audio, you absolute morons.
-
-
-
- But wait, there's more! If you drag that same MP3 to their
- media panel first, then to the timeline, it magically works.
- This isn't a bug, it's a fucking scam disguised as software
- engineering.
-
-
-
-
-
- Their Terms of Service are insane
-
-
- Look at this shit. You upload your content and they basically
- say "thanks for the free content, we own it now, but if Disney
- sues anyone, that's your problem."
-
-
- CapCut's Terms of Service: We get full rights
- to use, modify, distribute, and monetize everything you upload
- - permanently and without paying you shit. But you're still
- responsible if anything goes wrong.
-
-
- Translation: "We'll make money off your viral video, you
- handle the lawsuits." Brilliant legal strategy, you fucks.
-
-
-
-
-
- The editor is actually good
-
-
- Here's the thing that makes me want to punch my monitor: the
- actual video editor is fucking good. It's intuitive, powerful,
- and anyone can figure it out. When it's not begging for money
- every 30 seconds, it actually works well.
-
-
- Which makes everything else so much worse. They built
- something people want to use, then turned it into a digital
- slot machine. Every click might trigger a payment request.
-
-
-
-
-
- This is a video editor. Look at it. You've never seen one
- before.
-
-
- Like the person who's never used software that doesn't
- constantly beg for money, you have no fucking idea what a
- video editor should be. All you've ever seen are predatory
- apps disguised as creative tools.
-
-
- A real video editor lets you edit videos. It doesn't steal
- your content. It doesn't pop up payment dialogs every 5
- seconds. It doesn't charge you separately for basic features
- that should be free.
-
-
-
-
-
- Yes, this is fucking satire, you fuck
-
-
- I'm not actually saying all video editors should be basic as
- shit. What I'm saying is that all the problems we have with
- video editing apps are{" "}
- ones they create themselves . Video editors
- aren't broken by default - they edit videos, export them, and
- let you use basic features without constantly begging for
- money. CapCut breaks them. They turn them into payment
- processors with video editing as a side feature.
-
-
- "Good software gets out of your way."
- - Some smart motherfucker who definitely wasn't working
- at CapCut
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/delete-project-dialog.tsx b/apps/web/src/components/delete-project-dialog.tsx
deleted file mode 100644
index e8f8703c..00000000
--- a/apps/web/src/components/delete-project-dialog.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- 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 (
-
- {
- e.preventDefault();
- e.stopPropagation();
- }}
- >
-
-
- {projectName ? (
- <>
- {"Delete '"}
-
- {projectName}
-
- {"'?"}
- >
- ) : (
- "Delete Project?"
- )}
-
-
- Are you sure you want to delete this project? This action cannot be
- undone.
-
-
-
- {
- e.preventDefault();
- e.stopPropagation();
- onOpenChange(false);
- }}
- >
- Cancel
-
-
- Delete
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx
new file mode 100644
index 00000000..d32f067a
--- /dev/null
+++ b/apps/web/src/components/editable-timecode.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { formatTimeCode, parseTimeCode } from "@/lib/time";
+import type { TTimeCode } from "@/types/time";
+import { cn } from "@/utils/ui";
+
+interface EditableTimecodeProps {
+ time: number;
+ duration: number;
+ format?: TTimeCode;
+ fps: number;
+ onTimeChange?: ({ time }: { time: number }) => void;
+ className?: string;
+ disabled?: boolean;
+}
+
+export function EditableTimecode({
+ time,
+ duration,
+ format = "HH:MM:SS:FF",
+ fps,
+ onTimeChange,
+ className,
+ disabled = false,
+}: EditableTimecodeProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [inputValue, setInputValue] = useState("");
+ const [hasError, setHasError] = useState(false);
+ const inputRef = useRef(null);
+ const enterPressedRef = useRef(false);
+ const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps });
+
+ const startEditing = () => {
+ if (disabled) return;
+ setIsEditing(true);
+ setInputValue(formattedTime);
+ setHasError(false);
+ enterPressedRef.current = false;
+ };
+
+ const cancelEditing = () => {
+ setIsEditing(false);
+ setInputValue("");
+ setHasError(false);
+ enterPressedRef.current = false;
+ };
+
+ const applyEdit = () => {
+ const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
+
+ if (parsedTime === null) {
+ setHasError(true);
+ return;
+ }
+
+ const clampedTime = Math.max(
+ 0,
+ duration ? Math.min(duration, parsedTime) : parsedTime,
+ );
+
+ onTimeChange?.({ time: clampedTime });
+ setIsEditing(false);
+ setInputValue("");
+ setHasError(false);
+ enterPressedRef.current = false;
+ };
+
+ const handleKeyDown = ({
+ key,
+ preventDefault,
+ }: React.KeyboardEvent) => {
+ if (key === "Enter") {
+ preventDefault();
+ enterPressedRef.current = true;
+ applyEdit();
+ } else if (key === "Escape") {
+ preventDefault();
+ cancelEditing();
+ }
+ };
+
+ const handleInputChange = ({
+ target,
+ }: React.ChangeEvent) => {
+ setInputValue(target.value);
+ setHasError(false);
+ };
+
+ const handleBlur = () => {
+ if (!enterPressedRef.current && isEditing) {
+ applyEdit();
+ }
+ };
+
+ const handleDisplayKeyDown = ({
+ key,
+ preventDefault,
+ }: React.KeyboardEvent) => {
+ if (disabled) return;
+
+ if (key === "Enter" || key === " ") {
+ preventDefault();
+ startEditing();
+ }
+ };
+
+ useEffect(() => {
+ if (isEditing && inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ }, [isEditing]);
+
+ if (isEditing) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {formattedTime}
+
+ );
+}
diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx
deleted file mode 100644
index 60406f61..00000000
--- a/apps/web/src/components/editor/audio-waveform.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-import React, { useEffect, useRef, useState } from "react";
-import WaveSurfer from "wavesurfer.js";
-
-interface AudioWaveformProps {
- audioUrl: string;
- height?: number;
- className?: string;
-}
-
-const AudioWaveform: React.FC = ({
- audioUrl,
- height = 32,
- className = "",
-}) => {
- const waveformRef = useRef(null);
- const wavesurfer = useRef(null);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(false);
-
- useEffect(() => {
- let mounted = true;
- let ws = wavesurfer.current;
-
- const initWaveSurfer = async () => {
- if (!waveformRef.current || !audioUrl) return;
-
- try {
- // Clear any existing instance safely
- if (ws) {
- // Instead of immediately destroying, just set to null
- // We'll destroy it outside this function
- wavesurfer.current = null;
- }
-
- // Create a fresh instance
- const newWaveSurfer = WaveSurfer.create({
- container: waveformRef.current,
- waveColor: "rgba(255, 255, 255, 0.6)",
- progressColor: "rgba(255, 255, 255, 0.9)",
- cursorColor: "transparent",
- barWidth: 2,
- barGap: 1,
- height,
- normalize: true,
- interact: false,
- });
-
- // Assign to ref only if component is still mounted
- if (mounted) {
- wavesurfer.current = newWaveSurfer;
- } else {
- // Component unmounted during initialization, clean up
- try {
- newWaveSurfer.destroy();
- } catch (e) {
- // Ignore destroy errors
- }
- return;
- }
-
- // Event listeners
- newWaveSurfer.on("ready", () => {
- if (mounted) {
- setIsLoading(false);
- setError(false);
- }
- });
-
- newWaveSurfer.on("error", (err) => {
- if (mounted) {
- console.error("WaveSurfer error:", err);
- setError(true);
- setIsLoading(false);
- }
- });
-
- await newWaveSurfer.load(audioUrl);
- } catch (err) {
- if (mounted) {
- console.error("Failed to initialize WaveSurfer:", err);
- setError(true);
- setIsLoading(false);
- }
- }
- };
-
- // First safely destroy previous instance if it exists
- if (ws) {
- // Use this pattern to safely destroy the previous instance
- const wsToDestroy = ws;
- // Detach from ref immediately
- wavesurfer.current = null;
-
- // Wait a tick to destroy so any pending operations can complete
- requestAnimationFrame(() => {
- try {
- wsToDestroy.destroy();
- } catch (e) {
- // Ignore errors during destroy
- }
- // Only initialize new instance after destroying the old one
- if (mounted) {
- initWaveSurfer();
- }
- });
- } else {
- // No previous instance to clean up, initialize directly
- initWaveSurfer();
- }
-
- return () => {
- // Mark component as unmounted
- mounted = false;
-
- // Store reference to current wavesurfer instance
- const wsToDestroy = wavesurfer.current;
-
- // Immediately clear the ref to prevent accessing it after unmount
- wavesurfer.current = null;
-
- // If we have an instance to clean up, do it safely
- if (wsToDestroy) {
- // Delay destruction to avoid race conditions
- requestAnimationFrame(() => {
- try {
- wsToDestroy.destroy();
- } catch (e) {
- // Ignore destroy errors - they're expected
- }
- });
- }
- };
- }, [audioUrl, height]);
-
- if (error) {
- return (
-
- Audio unavailable
-
- );
- }
-
- return (
-
- {isLoading && (
-
- Loading...
-
- )}
-
-
- );
-};
-
-export default AudioWaveform;
diff --git a/apps/web/src/components/editor/dialogs/delete-project-dialog.tsx b/apps/web/src/components/editor/dialogs/delete-project-dialog.tsx
new file mode 100644
index 00000000..0086384e
--- /dev/null
+++ b/apps/web/src/components/editor/dialogs/delete-project-dialog.tsx
@@ -0,0 +1,84 @@
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+
+export function DeleteProjectDialog({
+ isOpen,
+ onOpenChange,
+ onConfirm,
+ projectNames,
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: () => void;
+ projectNames: string[];
+}) {
+ const count = projectNames.length;
+ const isSingle = count === 1;
+ const singleName = isSingle ? projectNames[0] : null;
+
+ return (
+
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ }}
+ >
+
+
+ {singleName ? (
+ <>
+ {"Delete '"}
+
+ {singleName}
+
+ {"'?"}
+ >
+ ) : (
+ `Delete ${count} projects?`
+ )}
+
+
+
+
+ Warning
+
+ This will permanently delete{" "}
+ {singleName ? `"${singleName}"` : `${count} projects`} and all
+ associated files.
+
+
+
+
+ Type "DELETE" to confirm
+
+
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ Delete project
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/dialogs/migration-dialog.tsx b/apps/web/src/components/editor/dialogs/migration-dialog.tsx
new file mode 100644
index 00000000..4c9550c2
--- /dev/null
+++ b/apps/web/src/components/editor/dialogs/migration-dialog.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { useEditor } from "@/hooks/use-editor";
+import { Loader2 } from "lucide-react";
+
+export function MigrationDialog() {
+ const editor = useEditor();
+ const migrationState = editor.project.getMigrationState();
+
+ if (!migrationState.isMigrating) return null;
+
+ const title = migrationState.projectName
+ ? "Updating project"
+ : "Updating projects";
+ const description = migrationState.projectName
+ ? `Upgrading "${migrationState.projectName}" from v${migrationState.fromVersion} to v${migrationState.toVersion}`
+ : `Upgrading projects from v${migrationState.fromVersion} to v${migrationState.toVersion}`;
+
+ return (
+
+ event.preventDefault()}
+ onEscapeKeyDown={(event) => event.preventDefault()}
+ >
+
+ {title}
+ {description}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx
new file mode 100644
index 00000000..7b736b23
--- /dev/null
+++ b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx
@@ -0,0 +1,83 @@
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import type { TProjectMetadata } from "@/types/project";
+import { formatDate } from "@/utils/date";
+import { formatTimeCode } from "@/lib/time";
+import { Button } from "@/components/ui/button";
+
+function InfoRow({
+ label,
+ value,
+}: {
+ label: string;
+ value: string | React.ReactNode;
+}) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+export function ProjectInfoDialog({
+ isOpen,
+ onOpenChange,
+ project,
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ project: TProjectMetadata;
+}) {
+ const durationFormatted =
+ project.duration > 0
+ ? formatTimeCode({
+ timeInSeconds: project.duration,
+ format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS",
+ })
+ : "0:00";
+
+ return (
+
+ event.preventDefault()}>
+
+
+ {project.name}
+
+
+
+
+
+
+
+
+ {project.id.slice(0, 8)}
+
+ }
+ />
+
+
+ onOpenChange(false)}>
+ Close
+
+ onOpenChange(false)}>Done
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/dialogs/rename-project-dialog.tsx b/apps/web/src/components/editor/dialogs/rename-project-dialog.tsx
new file mode 100644
index 00000000..73024d71
--- /dev/null
+++ b/apps/web/src/components/editor/dialogs/rename-project-dialog.tsx
@@ -0,0 +1,72 @@
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { useState } from "react";
+import { Label } from "@/components/ui/label";
+
+export function RenameProjectDialog({
+ isOpen,
+ onOpenChange,
+ onConfirm,
+ projectName,
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ onConfirm: (newName: string) => void;
+ projectName: string;
+}) {
+ const [name, setName] = useState(projectName);
+
+ const handleOpenChange = (open: boolean) => {
+ if (open) {
+ setName(projectName);
+ }
+ onOpenChange(open);
+ };
+
+ return (
+
+
+
+ Rename project
+
+
+
+ New name
+ setName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ onConfirm(name);
+ }
+ }}
+ placeholder="Enter a new name"
+ />
+
+
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ onOpenChange(false);
+ }}
+ >
+ Cancel
+
+ onConfirm(name)}>Rename
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx
new file mode 100644
index 00000000..565fe705
--- /dev/null
+++ b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx
@@ -0,0 +1,229 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import {
+ type KeyboardShortcut,
+ useKeyboardShortcutsHelp,
+} from "@/hooks/use-keyboard-shortcuts-help";
+import { useKeybindingsStore } from "@/stores/keybindings-store";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+export function ShortcutsDialog({
+ isOpen,
+ onOpenChange,
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const [recordingShortcut, setRecordingShortcut] =
+ useState(null);
+
+ const {
+ updateKeybinding,
+ removeKeybinding,
+ getKeybindingString,
+ validateKeybinding,
+ getKeybindingsForAction,
+ setIsRecording,
+ resetToDefaults,
+ isRecording,
+ } = useKeybindingsStore();
+
+ const { shortcuts } = useKeyboardShortcutsHelp();
+
+ const categories = Array.from(new Set(shortcuts.map((s) => s.category)));
+
+ useEffect(() => {
+ if (!isRecording || !recordingShortcut) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const keyString = getKeybindingString(e);
+ if (keyString) {
+ const conflict = validateKeybinding(
+ keyString,
+ recordingShortcut.action,
+ );
+ if (conflict) {
+ toast.error(
+ `Key "${keyString}" is already bound to "${conflict.existingAction}"`,
+ );
+ setRecordingShortcut(null);
+ return;
+ }
+
+ const oldKeys = getKeybindingsForAction(recordingShortcut.action);
+ for (const key of oldKeys) {
+ removeKeybinding(key);
+ }
+
+ updateKeybinding(keyString, recordingShortcut.action);
+
+ setIsRecording(false);
+ setRecordingShortcut(null);
+ }
+ };
+
+ const handleClickOutside = () => {
+ setRecordingShortcut(null);
+ setIsRecording(false);
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ document.addEventListener("click", handleClickOutside);
+
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ document.removeEventListener("click", handleClickOutside);
+ };
+ }, [
+ recordingShortcut,
+ getKeybindingString,
+ updateKeybinding,
+ removeKeybinding,
+ validateKeybinding,
+ getKeybindingsForAction,
+ setIsRecording,
+ isRecording,
+ ]);
+
+ const handleStartRecording = (shortcut: KeyboardShortcut) => {
+ setRecordingShortcut(shortcut);
+ setIsRecording(true);
+ };
+
+ return (
+
+
+
+ Keyboard shortcuts
+
+
+
+
+ {categories.map((category) => (
+
+
+ {category}
+
+
+ {shortcuts
+ .filter((shortcut) => shortcut.category === category)
+ .map((shortcut) => (
+ handleStartRecording(shortcut)}
+ />
+ ))}
+
+
+ ))}
+
+
+
+
+ Reset to default
+
+
+
+
+ );
+}
+
+function ShortcutItem({
+ shortcut,
+ isRecording,
+ onStartRecording,
+}: {
+ shortcut: KeyboardShortcut;
+ isRecording: boolean;
+ onStartRecording: (params: { shortcut: KeyboardShortcut }) => void;
+}) {
+ const displayKeys = shortcut.keys.filter((key: string) => {
+ if (
+ key.includes("Cmd") &&
+ shortcut.keys.includes(key.replace("Cmd", "Ctrl"))
+ )
+ return false;
+
+ return true;
+ });
+
+ return (
+
+
+ {shortcut.icon && (
+
{shortcut.icon}
+ )}
+
{shortcut.description}
+
+
+ {displayKeys.map((key: string, index: number) => (
+
+
+ {key.split("+").map((keyPart: string, partIndex: number) => {
+ const keyId = `${shortcut.id}-${index}-${partIndex}`;
+ return (
+ onStartRecording({ shortcut })}
+ >
+ {keyPart}
+
+ );
+ })}
+
+ {index < displayKeys.length - 1 && (
+
or
+ )}
+
+ ))}
+
+
+ );
+}
+
+function EditableShortcutKey({
+ children,
+ isRecording,
+ onStartRecording,
+}: {
+ children: React.ReactNode;
+ isRecording: boolean;
+ onStartRecording: () => void;
+}) {
+ const handleClick = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onStartRecording();
+ };
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx
index 5363d0c7..d74d78d7 100644
--- a/apps/web/src/components/editor/editor-header.tsx
+++ b/apps/web/src/components/editor/editor-header.tsx
@@ -1,130 +1,187 @@
"use client";
import { Button } from "../ui/button";
-import { ChevronDown, ArrowLeft, SquarePen, Trash } from "lucide-react";
-import { HeaderBase } from "../header-base";
-import { useProjectStore } from "@/stores/project-store";
-import { KeyboardShortcutsHelp } from "../keyboard-shortcuts-help";
+import { ChevronDown } from "lucide-react";
import { useState } from "react";
import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import Link from "next/link";
-import { RenameProjectDialog } from "../rename-project-dialog";
-import { DeleteProjectDialog } from "../delete-project-dialog";
+import { RenameProjectDialog } from "./dialogs/rename-project-dialog";
+import { DeleteProjectDialog } from "./dialogs/delete-project-dialog";
import { useRouter } from "next/navigation";
import { FaDiscord } from "react-icons/fa6";
-import { PanelPresetSelector } from "./panel-preset-selector";
import { ExportButton } from "./export-button";
import { ThemeToggle } from "../theme-toggle";
+import { SOCIAL_LINKS } from "@/constants/site-constants";
+import { toast } from "sonner";
+import { useEditor } from "@/hooks/use-editor";
+import {
+ ArrowLeft02Icon,
+ Edit03Icon,
+ Delete02Icon,
+ CommandIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
export function EditorHeader() {
- const { activeProject, renameProject, deleteProject } = useProjectStore();
- const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
- const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
- const router = useRouter();
+ return (
+
+ );
+}
+
+function ProjectDropdown() {
+ const [openDialog, setOpenDialog] = useState<
+ "delete" | "rename" | "shortcuts" | null
+ >(null);
+ const [isExiting, setIsExiting] = useState(false);
+ const router = useRouter();
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
- const handleNameSave = async (newName: string) => {
- console.log("handleNameSave", newName);
- if (activeProject && newName.trim() && newName !== activeProject.name) {
- try {
- await renameProject(activeProject.id, newName.trim());
- setIsRenameDialogOpen(false);
- } catch (error) {
- console.error("Failed to rename project:", error);
- }
- }
- };
+ const handleExit = async () => {
+ if (isExiting) return;
+ setIsExiting(true);
- const handleDelete = () => {
- if (activeProject) {
- deleteProject(activeProject.id);
- setIsDeleteDialogOpen(false);
- router.push("/projects");
- }
- };
+ try {
+ await editor.project.prepareExit();
+ editor.project.closeProject();
+ } catch (error) {
+ console.error("Failed to prepare project exit:", error);
+ } finally {
+ editor.project.closeProject();
+ router.push("/projects");
+ }
+ };
- const leftContent = (
-
-
-
-
-
- {activeProject?.name}
-
-
-
-
-
-
- Projects
-
-
- setIsRenameDialogOpen(true)}
- >
-
- Rename project
-
- setIsDeleteDialogOpen(true)}
- >
-
- Delete Project
-
-
-
-
-
- Discord
-
-
-
-
-
-
-
- );
+ const handleSaveProjectName = async (newName: string) => {
+ if (
+ activeProject &&
+ newName.trim() &&
+ newName !== activeProject.metadata.name
+ ) {
+ try {
+ await editor.project.renameProject({
+ id: activeProject.metadata.id,
+ name: newName.trim(),
+ });
+ } catch (error) {
+ toast.error("Failed to rename project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ } finally {
+ setOpenDialog(null);
+ }
+ }
+ };
- const rightContent = (
-
-
-
-
-
-
- );
+ const handleDeleteProject = async () => {
+ if (activeProject) {
+ try {
+ await editor.project.deleteProjects({
+ ids: [activeProject.metadata.id],
+ });
+ router.push("/projects");
+ } catch (error) {
+ toast.error("Failed to delete project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ } finally {
+ setOpenDialog(null);
+ }
+ }
+ };
- return (
-
- );
+ return (
+ <>
+
+
+
+
+
+ {activeProject?.metadata.name}
+
+
+
+
+
+
+ Exit project
+
+ setOpenDialog("rename")}
+ >
+
+ Rename project
+
+ setOpenDialog("delete")}
+ >
+
+ Delete project
+
+
+ setOpenDialog("shortcuts")}
+ >
+
+ Keyboard shortcuts
+
+
+
+
+ Discord
+
+
+
+
+ setOpenDialog(isOpen ? "rename" : null)}
+ onConfirm={(newName) => handleSaveProjectName(newName)}
+ projectName={activeProject?.metadata.name || ""}
+ />
+ setOpenDialog(isOpen ? "delete" : null)}
+ onConfirm={handleDeleteProject}
+ projectNames={[activeProject?.metadata.name || ""]}
+ />
+ setOpenDialog(isOpen ? "shortcuts" : null)}
+ />
+ >
+ );
}
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
index 246e8b6f..f79710bf 100644
--- a/apps/web/src/components/editor/export-button.tsx
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -1,317 +1,347 @@
"use client";
-import { useState } from "react";
-import { TransitionUpIcon } from "../icons";
-import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
-import { Button } from "../ui/button";
-import { Label } from "../ui/label";
-import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
-import { Progress } from "../ui/progress";
-import { Checkbox } from "../ui/checkbox";
-import { cn } from "@/lib/utils";
+import { useState, useRef } from "react";
+import { TransitionTopIcon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
import {
- exportProject,
- getExportMimeType,
- getExportFileExtension,
- DEFAULT_EXPORT_OPTIONS,
-} from "@/lib/export";
-import { useProjectStore } from "@/stores/project-store";
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Progress } from "@/components/ui/progress";
+import { Checkbox } from "@/components/ui/checkbox";
+import { cn } from "@/utils/ui";
+import { getExportMimeType, getExportFileExtension } from "@/lib/export";
import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
-import { ExportFormat, ExportQuality, ExportResult } from "@/types/export";
-import { PropertyGroup } from "./properties-panel/property-item";
+import {
+ EXPORT_FORMAT_VALUES,
+ EXPORT_QUALITY_VALUES,
+ type ExportFormat,
+ type ExportQuality,
+ type ExportResult,
+} from "@/types/export";
+import { PropertyGroup } from "@/components/editor/panels/properties/property-item";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
export function ExportButton() {
- const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
- const { activeProject } = useProjectStore();
+ const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
+ const editor = useEditor();
- const handleExport = () => {
- setIsExportPopoverOpen(true);
- };
+ const handleExport = () => {
+ setIsExportPopoverOpen(true);
+ };
- const hasProject = !!activeProject;
+ const hasProject = !!editor.project.getActive();
- return (
-
-
- {
- if (hasProject && (event.key === "Enter" || event.key === " ")) {
- event.preventDefault();
- handleExport();
- }
- }}
- >
-
-
-
- {hasProject && }
-
- );
+ return (
+
+
+ {
+ if (hasProject && (event.key === "Enter" || event.key === " ")) {
+ event.preventDefault();
+ handleExport();
+ }
+ }}
+ >
+
+
+
+ {hasProject && }
+
+ );
}
function ExportPopover({
- onOpenChange,
+ onOpenChange,
}: {
- onOpenChange: (open: boolean) => void;
+ onOpenChange: (open: boolean) => void;
}) {
- const { activeProject } = useProjectStore();
- const [format, setFormat] = useState(
- DEFAULT_EXPORT_OPTIONS.format
- );
- const [quality, setQuality] = useState(
- DEFAULT_EXPORT_OPTIONS.quality
- );
- const [includeAudio, setIncludeAudio] = useState(
- DEFAULT_EXPORT_OPTIONS.includeAudio || true
- );
- const [isExporting, setIsExporting] = useState(false);
- const [progress, setProgress] = useState(0);
- const [exportResult, setExportResult] = useState(null);
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+ const [format, setFormat] = useState(
+ DEFAULT_EXPORT_OPTIONS.format,
+ );
+ const [quality, setQuality] = useState(
+ DEFAULT_EXPORT_OPTIONS.quality,
+ );
+ const [includeAudio, setIncludeAudio] = useState(
+ DEFAULT_EXPORT_OPTIONS.includeAudio || true,
+ );
+ const [isExporting, setIsExporting] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [exportResult, setExportResult] = useState(null);
+ const cancelRequestedRef = useRef(false);
+
+ const handleExport = async () => {
+ if (!activeProject) return;
+
+ cancelRequestedRef.current = false;
+ setIsExporting(true);
+ setProgress(0);
+ setExportResult(null);
+
+ const result = await editor.project.export({
+ options: {
+ format,
+ quality,
+ fps: activeProject.settings.fps,
+ includeAudio,
+ onProgress: ({ progress }) => setProgress(progress),
+ onCancel: () => cancelRequestedRef.current,
+ },
+ });
- const handleExport = async () => {
- if (!activeProject) return;
+ setIsExporting(false);
- setIsExporting(true);
- setProgress(0);
- setExportResult(null);
+ if (result.cancelled) {
+ setExportResult(null);
+ setProgress(0);
+ return;
+ }
- const result = await exportProject({
- format,
- quality,
- fps: activeProject.fps,
- includeAudio,
- onProgress: setProgress,
- onCancel: () => false, // TODO: Add cancel functionality
- });
+ setExportResult(result);
- setIsExporting(false);
- setExportResult(result);
+ if (result.success && result.buffer) {
+ const mimeType = getExportMimeType({ format });
+ const extension = getExportFileExtension({ format });
+ const blob = new Blob([result.buffer], { type: mimeType });
+ const url = URL.createObjectURL(blob);
- if (result.success && result.buffer) {
- // Download the file
- const mimeType = getExportMimeType(format);
- const extension = getExportFileExtension(format);
- const blob = new Blob([result.buffer], { type: mimeType });
- const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `${activeProject.metadata.name}${extension}`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
- const a = document.createElement("a");
- a.href = url;
- a.download = `${activeProject.name}${extension}`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
+ onOpenChange(false);
+ setExportResult(null);
+ setProgress(0);
+ }
+ };
- onOpenChange(false);
- setExportResult(null);
- setProgress(0);
- }
- };
+ const handleClose = () => {
+ if (!isExporting) {
+ onOpenChange(false);
+ setExportResult(null);
+ setProgress(0);
+ }
+ };
- const handleClose = () => {
- if (!isExporting) {
- onOpenChange(false);
- setExportResult(null);
- setProgress(0);
- }
- };
+ const handleCancel = () => {
+ cancelRequestedRef.current = true;
+ };
- return (
-
- <>
- {exportResult && !exportResult.success ? (
-
- ) : (
- <>
-
-
- {isExporting ? "Exporting project" : "Export project"}
-
-
-
-
-
+ return (
+
+ {exportResult && !exportResult.success ? (
+
+ ) : (
+ <>
+
+
+ {isExporting ? "Exporting project" : "Export project"}
+
+
+
+
+
-
- {!isExporting && (
- <>
-
-
-
- setFormat(value as ExportFormat)
- }
- >
-
-
-
- MP4 (H.264) - Better compatibility
-
-
-
-
-
- WebM (VP9) - Smaller file size
-
-
-
-
+
+ {!isExporting && (
+ <>
+
+
+ {
+ if (isExportFormat(value)) {
+ setFormat(value);
+ }
+ }}
+ >
+
+
+
+ MP4 (H.264) - Better compatibility
+
+
+
+
+
+ WebM (VP9) - Smaller file size
+
+
+
+
-
-
- setQuality(value as ExportQuality)
- }
- >
-
-
- Low - Smallest file size
-
-
-
- Medium - Balanced
-
-
-
- High - Recommended
-
-
-
-
- Very High - Largest file size
-
-
-
-
+
+ {
+ if (isExportQuality(value)) {
+ setQuality(value);
+ }
+ }}
+ >
+
+
+ Low - Smallest file size
+
+
+
+ Medium - Balanced
+
+
+
+ High - Recommended
+
+
+
+
+ Very High - Largest file size
+
+
+
+
-
-
-
- setIncludeAudio(!!checked)
- }
- />
-
- Include audio in export
-
-
-
-
+
+
+
+ setIncludeAudio(!!checked)
+ }
+ />
+
+ Include audio in export
+
+
+
+
-
-
- Export
-
- >
- )}
+
+
+ Export
+
+ >
+ )}
- {isExporting && (
-
-
-
-
- {Math.round(progress * 100)}%
-
-
100%
-
-
-
+ {isExporting && (
+
+
+
+
+ {Math.round(progress * 100)}%
+
+
100%
+
+
+
+
+
+ Cancel
+
+
+ )}
+
+ >
+ )}
+
+ );
+}
+
+function isExportFormat(value: string): value is ExportFormat {
+ return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
+}
-
{}}
- >
- Cancel
-
-
- )}
-
- >
- )}
- >
-
- );
+function isExportQuality(value: string): value is ExportQuality {
+ return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value);
}
function ExportError({
- error,
- onRetry,
+ error,
+ onRetry,
}: {
- error: string;
- onRetry: () => void;
+ error: string;
+ onRetry: () => void;
}) {
- const [copied, setCopied] = useState(false);
+ const [copied, setCopied] = useState(false);
- const handleCopy = async () => {
- await navigator.clipboard.writeText(error);
- setCopied(true);
- setTimeout(() => setCopied(false), 1000);
- };
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(error);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1000);
+ };
- return (
-
-
-
Export failed
-
- {error}
-
-
+ return (
+
+
+
Export failed
+
{error}
+
-
-
- {copied ? : }
- Copy
-
-
-
- Retry
-
-
-
- );
+
+
+ {copied ? : }
+ Copy
+
+
+
+ Retry
+
+
+
+ );
}
diff --git a/apps/web/src/components/editor/layout-guide-overlay.tsx b/apps/web/src/components/editor/layout-guide-overlay.tsx
index ff4cde57..0f02da1c 100644
--- a/apps/web/src/components/editor/layout-guide-overlay.tsx
+++ b/apps/web/src/components/editor/layout-guide-overlay.tsx
@@ -4,24 +4,24 @@ import { useEditorStore } from "@/stores/editor-store";
import Image from "next/image";
function TikTokGuide() {
- return (
-
-
-
- );
+ return (
+
+
+
+ );
}
export function LayoutGuideOverlay() {
- const { layoutGuide } = useEditorStore();
+ const { layoutGuide } = useEditorStore();
- if (layoutGuide.platform === null) return null;
- if (layoutGuide.platform === "tiktok") return ;
+ if (layoutGuide.platform === null) return null;
+ if (layoutGuide.platform === "tiktok") return ;
- return null;
+ return null;
}
diff --git a/apps/web/src/components/editor/media-panel/drag-overlay.tsx b/apps/web/src/components/editor/media-panel/drag-overlay.tsx
deleted file mode 100644
index e1fd257e..00000000
--- a/apps/web/src/components/editor/media-panel/drag-overlay.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-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 (
-
-
-
-
-
-
-
- {isProcessing
- ? `Processing your files (${progress}%)`
- : "Drag and drop videos, photos, and audio files here"}
-
-
-
- {isProcessing && (
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx
deleted file mode 100644
index 89b53b64..00000000
--- a/apps/web/src/components/editor/media-panel/index.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-"use client";
-
-import { TabBar } from "./tabbar";
-import { MediaView } from "./views/media";
-import { useMediaPanelStore, Tab } from "./store";
-import { TextView } from "./views/text";
-import { SoundsView } from "./views/sounds";
-import { StickersView } from "./views/stickers";
-import { Separator } from "@/components/ui/separator";
-import { SettingsView } from "./views/settings";
-import { Captions } from "./views/captions";
-
-export function MediaPanel() {
- const { activeTab } = useMediaPanelStore();
-
- const viewMap: Record = {
- media: ,
- sounds: ,
- text: ,
- stickers: ,
- effects: (
-
- Effects view coming soon...
-
- ),
- transitions: (
-
- Transitions view coming soon...
-
- ),
- captions: ,
- filters: (
-
- Filters view coming soon...
-
- ),
- adjustment: (
-
- Adjustment view coming soon...
-
- ),
- settings: ,
- };
-
- return (
-
-
-
-
{viewMap[activeTab]}
-
- );
-}
diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts
deleted file mode 100644
index eb645ec3..00000000
--- a/apps/web/src/components/editor/media-panel/store.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import {
- CaptionsIcon,
- ArrowLeftRightIcon,
- SparklesIcon,
- StickerIcon,
- MusicIcon,
- VideoIcon,
- BlendIcon,
- SlidersHorizontalIcon,
- LucideIcon,
- TypeIcon,
- SettingsIcon,
-} from "lucide-react";
-import { create } from "zustand";
-
-export type Tab =
- | "media"
- | "sounds"
- | "text"
- | "stickers"
- | "effects"
- | "transitions"
- | "captions"
- | "filters"
- | "adjustment"
- | "settings";
-
-export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = {
- media: {
- icon: VideoIcon,
- label: "Media",
- },
- sounds: {
- icon: MusicIcon,
- label: "Sounds",
- },
- text: {
- icon: TypeIcon,
- label: "Text",
- },
- stickers: {
- icon: StickerIcon,
- label: "Stickers",
- },
- effects: {
- icon: SparklesIcon,
- label: "Effects",
- },
- transitions: {
- icon: ArrowLeftRightIcon,
- label: "Transitions",
- },
- captions: {
- icon: CaptionsIcon,
- label: "Captions",
- },
- filters: {
- icon: BlendIcon,
- label: "Filters",
- },
- adjustment: {
- icon: SlidersHorizontalIcon,
- label: "Adjustment",
- },
- settings: {
- icon: SettingsIcon,
- label: "Settings",
- },
-};
-
-interface MediaPanelStore {
- activeTab: Tab;
- setActiveTab: (tab: Tab) => void;
- highlightMediaId: string | null;
- requestRevealMedia: (mediaId: string) => void;
- clearHighlight: () => void;
-}
-
-export const useMediaPanelStore = create((set) => ({
- activeTab: "media",
- setActiveTab: (tab) => set({ activeTab: tab }),
- highlightMediaId: null,
- requestRevealMedia: (mediaId) =>
- set({ activeTab: "media", highlightMediaId: mediaId }),
- clearHighlight: () => set({ highlightMediaId: null }),
-}));
diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx
deleted file mode 100644
index e2bfdff7..00000000
--- a/apps/web/src/components/editor/media-panel/tabbar.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-"use client";
-
-import { cn } from "@/lib/utils";
-import { Tab, tabs, useMediaPanelStore } from "./store";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import { useEffect, useRef, useState } from "react";
-
-export function TabBar() {
- const { activeTab, setActiveTab } = useMediaPanelStore();
- const scrollRef = useRef(null);
- const [showTopFade, setShowTopFade] = useState(false);
- const [showBottomFade, setShowBottomFade] = useState(false);
-
- const checkScrollPosition = () => {
- const element = scrollRef.current;
- if (!element) return;
-
- const { scrollTop, scrollHeight, clientHeight } = element;
- setShowTopFade(scrollTop > 0);
- setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1);
- };
-
- useEffect(() => {
- const element = scrollRef.current;
- if (!element) return;
-
- checkScrollPosition();
- element.addEventListener("scroll", checkScrollPosition);
-
- const resizeObserver = new ResizeObserver(checkScrollPosition);
- resizeObserver.observe(element);
-
- return () => {
- element.removeEventListener("scroll", checkScrollPosition);
- resizeObserver.disconnect();
- };
- }, []);
-
- return (
-
-
- {(Object.keys(tabs) as Tab[]).map((tabKey) => {
- const tab = tabs[tabKey];
- return (
-
setActiveTab(tabKey)}
- key={tabKey}
- >
-
-
-
-
-
-
- {tab.label}
-
-
-
-
- );
- })}
-
-
-
-
-
- );
-}
-
-function FadeOverlay({ direction, show }: { direction: "top" | "bottom", show: boolean }) {
- return (
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/src/components/editor/media-panel/views/captions.tsx b/apps/web/src/components/editor/media-panel/views/captions.tsx
deleted file mode 100644
index fbe5b418..00000000
--- a/apps/web/src/components/editor/media-panel/views/captions.tsx
+++ /dev/null
@@ -1,303 +0,0 @@
-import { Button } from "@/components/ui/button";
-import { PropertyGroup } from "../../properties-panel/property-item";
-import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
-import { Language, LanguageSelect } from "@/components/language-select";
-import { useState, useRef, useEffect } from "react";
-import { extractTimelineAudio } from "@/lib/mediabunny-utils";
-import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
-import { Loader2, Shield, Trash2, Upload } from "lucide-react";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { TextElement } from "@/types/timeline";
-
-export const languages: Language[] = [
- { code: "US", name: "English" },
- { code: "ES", name: "Spanish" },
- { code: "IT", name: "Italian" },
- { code: "FR", name: "French" },
- { code: "DE", name: "German" },
- { code: "PT", name: "Portuguese" },
- { code: "RU", name: "Russian" },
- { code: "JP", name: "Japanese" },
- { code: "CN", name: "Chinese" },
-];
-
-const PRIVACY_DIALOG_KEY = "opencut-transcription-privacy-accepted";
-
-export function Captions() {
- const [selectedCountry, setSelectedCountry] = useState("auto");
- const [isProcessing, setIsProcessing] = useState(false);
- const [processingStep, setProcessingStep] = useState("");
- const [error, setError] = useState(null);
- const [showPrivacyDialog, setShowPrivacyDialog] = useState(false);
- const [hasAcceptedPrivacy, setHasAcceptedPrivacy] = useState(false);
- const containerRef = useRef(null);
- const { insertTrackAt, addElementToTrack } = useTimelineStore();
-
- // Check if user has already accepted privacy on mount
- useEffect(() => {
- const hasAccepted = localStorage.getItem(PRIVACY_DIALOG_KEY) === "true";
- setHasAcceptedPrivacy(hasAccepted);
- }, []);
-
- const handleGenerateTranscript = async () => {
- try {
- setIsProcessing(true);
- setError(null);
- setProcessingStep("Extracting audio...");
-
- const audioBlob = await extractTimelineAudio();
-
- setProcessingStep("Encrypting audio...");
-
- // Encrypt the audio with a random key (zero-knowledge)
- const audioBuffer = await audioBlob.arrayBuffer();
- const encryptionResult = await encryptWithRandomKey(audioBuffer);
-
- // Convert encrypted data to blob for upload
- const encryptedBlob = new Blob([encryptionResult.encryptedData]);
-
- setProcessingStep("Uploading...");
- const uploadResponse = await fetch("/api/get-upload-url", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ fileExtension: "wav" }),
- });
-
- if (!uploadResponse.ok) {
- const error = await uploadResponse.json();
- throw new Error(error.message || "Failed to get upload URL");
- }
-
- const { uploadUrl, fileName } = await uploadResponse.json();
-
- // Upload to R2
- await fetch(uploadUrl, {
- method: "PUT",
- body: encryptedBlob,
- });
-
- setProcessingStep("Transcribing...");
-
- // Call Modal transcription API with encryption parameters
- const transcriptionResponse = await fetch("/api/transcribe", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- filename: fileName,
- language:
- selectedCountry === "auto" ? "auto" : selectedCountry.toLowerCase(),
- // Send the raw encryption key and IV (zero-knowledge)
- decryptionKey: arrayBufferToBase64(encryptionResult.key),
- iv: arrayBufferToBase64(encryptionResult.iv),
- }),
- });
-
- if (!transcriptionResponse.ok) {
- const error = await transcriptionResponse.json();
- throw new Error(error.message || "Transcription failed");
- }
-
- const { text, segments } = await transcriptionResponse.json();
-
- console.log("Transcription completed:", { text, segments });
-
- const shortCaptions: Array<{
- text: string;
- startTime: number;
- duration: number;
- }> = [];
-
- let globalEndTime = 0; // Track the end time of the last caption globally
-
- segments.forEach((segment: any) => {
- const words = segment.text.trim().split(/\s+/);
- const segmentDuration = segment.end - segment.start;
- const wordsPerSecond = words.length / segmentDuration;
-
- // Split into chunks of 2-4 words
- const chunks: string[] = [];
- for (let i = 0; i < words.length; i += 3) {
- chunks.push(words.slice(i, i + 3).join(" "));
- }
-
- // Calculate timing for each chunk to place them sequentially
- let chunkStartTime = segment.start;
- chunks.forEach((chunk) => {
- const chunkWords = chunk.split(/\s+/).length;
- const chunkDuration = Math.max(0.8, chunkWords / wordsPerSecond); // Minimum 0.8s per chunk
-
- let adjustedStartTime = chunkStartTime;
-
- // Prevent overlapping: if this caption would start before the last one ends,
- // start it right after the last one ends
- if (adjustedStartTime < globalEndTime) {
- adjustedStartTime = globalEndTime;
- }
-
- shortCaptions.push({
- text: chunk,
- startTime: adjustedStartTime,
- duration: chunkDuration,
- });
-
- // Update global end time
- globalEndTime = adjustedStartTime + chunkDuration;
-
- // Next chunk starts when this one ends (for within-segment timing)
- chunkStartTime += chunkDuration;
- });
- });
-
- // Create a single track for all captions
- const captionTrackId = insertTrackAt("text", 0);
-
- // Add all caption elements to the same track
- shortCaptions.forEach((caption, index) => {
- addElementToTrack(captionTrackId, {
- ...DEFAULT_TEXT_ELEMENT,
- name: `Caption ${index + 1}`,
- content: caption.text,
- duration: caption.duration,
- startTime: caption.startTime,
- fontSize: 65, // Larger for captions
- fontWeight: "bold", // Bold for captions
- } as TextElement);
- });
-
- console.log(
- `✅ ${shortCaptions.length} short-form caption chunks added to timeline!`
- );
- } catch (error) {
- console.error("Transcription failed:", error);
- setError(
- error instanceof Error ? error.message : "An unexpected error occurred"
- );
- } finally {
- setIsProcessing(false);
- setProcessingStep("");
- }
- };
-
- return (
-
-
-
-
-
-
- {error && (
-
- )}
-
-
{
- if (hasAcceptedPrivacy) {
- handleGenerateTranscript();
- } else {
- setShowPrivacyDialog(true);
- }
- }}
- disabled={isProcessing}
- >
- {isProcessing && }
- {isProcessing ? processingStep : "Generate transcript"}
-
-
-
-
-
-
-
- Audio Processing Notice
-
-
-
- To generate captions, we need to process your timeline audio
- using speech-to-text technology.
-
-
-
-
-
-
- Zero-knowledge encryption - we cannot decrypt your files
- even if we wanted to
-
-
-
-
-
-
- Encryption keys generated randomly in your browser, never
- stored anywhere
-
-
-
-
-
-
- Audio encrypted before upload - raw audio never leaves
- your device
-
-
-
-
-
-
- Everything permanently deleted within seconds after
- transcription
-
-
-
-
-
- True zero-knowledge privacy: Encryption keys
- are generated randomly in your browser and never stored
- anywhere. It's cryptographically impossible for us, our cloud
- providers, or anyone else to decrypt your audio files.
-
-
-
-
- setShowPrivacyDialog(false)}
- disabled={isProcessing}
- >
- Cancel
-
- {
- localStorage.setItem(PRIVACY_DIALOG_KEY, "true");
- setHasAcceptedPrivacy(true);
- setShowPrivacyDialog(false);
- handleGenerateTranscript();
- }}
- disabled={isProcessing}
- >
- Continue & Generate Captions
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx
deleted file mode 100644
index 07a44a7c..00000000
--- a/apps/web/src/components/editor/media-panel/views/media.tsx
+++ /dev/null
@@ -1,545 +0,0 @@
-"use client";
-
-import { useDragDrop } from "@/hooks/use-drag-drop";
-import { processMediaFiles } from "@/lib/media-processing";
-import { useMediaStore } from "@/stores/media-store";
-import { MediaFile } from "@/types/media";
-import {
- ArrowDown01,
- CloudUpload,
- Grid2X2,
- Image,
- List,
- Loader2,
- Music,
- Video,
-} from "lucide-react";
-import { useRef, useState, useMemo } from "react";
-import { useHighlightScroll } from "@/hooks/use-highlight-scroll";
-import { toast } from "sonner";
-import { Button } from "@/components/ui/button";
-import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { DraggableMediaItem } from "@/components/ui/draggable-item";
-import { useProjectStore } from "@/stores/project-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import { usePanelStore } from "@/stores/panel-store";
-import { useMediaPanelStore } from "../store";
-
-function MediaItemWithContextMenu({
- item,
- children,
- onRemove,
-}: {
- item: MediaFile;
- children: React.ReactNode;
- onRemove: (e: React.MouseEvent, id: string) => Promise;
-}) {
- return (
-
- {children}
-
- Export clips
- onRemove(e, item.id)}
- >
- Delete
-
-
-
- );
-}
-
-export function MediaView() {
- const { mediaFiles, addMediaFile, removeMediaFile } = useMediaStore();
- const { activeProject } = useProjectStore();
- const { mediaViewMode, setMediaViewMode } = usePanelStore();
- const fileInputRef = useRef(null);
- const [isProcessing, setIsProcessing] = useState(false);
- const [progress, setProgress] = useState(0);
- const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
- "name"
- );
- const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
- const { highlightMediaId, clearHighlight } = useMediaPanelStore();
- const { highlightedId, registerElement } = useHighlightScroll(
- highlightMediaId,
- clearHighlight
- );
-
- const processFiles = async (files: FileList | File[]) => {
- if (!files || files.length === 0) return;
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- setIsProcessing(true);
- setProgress(0);
- try {
- const processedItems = await processMediaFiles(files, (p) =>
- setProgress(p)
- );
- for (const item of processedItems) {
- await addMediaFile(activeProject.id, item);
- }
- } catch (error) {
- console.error("Error processing files:", error);
- toast.error("Failed to process files");
- } finally {
- setIsProcessing(false);
- setProgress(0);
- }
- };
-
- const { isDragOver, dragProps } = useDragDrop({
- // When files are dropped, process them
- onDrop: processFiles,
- });
-
- const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
-
- const handleFileChange = (e: React.ChangeEvent) => {
- if (e.target.files) processFiles(e.target.files);
- e.target.value = ""; // Reset input
- };
-
- const handleRemove = async (e: React.MouseEvent, id: string) => {
- e.stopPropagation();
-
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- await removeMediaFile(activeProject.id, id);
- };
-
- const formatDuration = (duration: number) => {
- // Format seconds as mm:ss
- const min = Math.floor(duration / 60);
- const sec = Math.floor(duration % 60);
- return `${min}:${sec.toString().padStart(2, "0")}`;
- };
-
- const filteredMediaItems = useMemo(() => {
- let filtered = mediaFiles.filter((item) => {
- if (item.ephemeral) return false;
- return true;
- });
-
- filtered.sort((a, b) => {
- let valueA: string | number;
- let valueB: string | number;
-
- switch (sortBy) {
- case "name":
- valueA = a.name.toLowerCase();
- valueB = b.name.toLowerCase();
- break;
- case "type":
- valueA = a.type;
- valueB = b.type;
- break;
- case "duration":
- valueA = a.duration || 0;
- valueB = b.duration || 0;
- break;
- case "size":
- valueA = a.file.size;
- valueB = b.file.size;
- break;
- default:
- return 0;
- }
-
- if (valueA < valueB) return sortOrder === "asc" ? -1 : 1;
- if (valueA > valueB) return sortOrder === "asc" ? 1 : -1;
- return 0;
- });
-
- return filtered;
- }, [mediaFiles, sortBy, sortOrder]);
-
- const previewComponents = useMemo(() => {
- const previews = new Map();
-
- filteredMediaItems.forEach((item) => {
- let preview: React.ReactNode;
-
- if (item.type === "image") {
- preview = (
-
-
-
- );
- } else if (item.type === "video") {
- if (item.thumbnailUrl) {
- preview = (
-
-
-
-
-
- {item.duration && (
-
- {formatDuration(item.duration)}
-
- )}
-
- );
- } else {
- preview = (
-
-
- Video
- {item.duration && (
-
- {formatDuration(item.duration)}
-
- )}
-
- );
- }
- } else if (item.type === "audio") {
- preview = (
-
-
- Audio
- {item.duration && (
-
- {formatDuration(item.duration)}
-
- )}
-
- );
- } else {
- preview = (
-
-
- Unknown
-
- );
- }
-
- previews.set(item.id, preview);
- });
-
- return previews;
- }, [filteredMediaItems]);
-
- const renderPreview = (item: MediaFile) => previewComponents.get(item.id);
-
- return (
- <>
- {/* Hidden file input for uploading media */}
-
-
-
-
- {/* Search and filter controls */}
-
-
- {isProcessing ? (
-
- ) : (
-
- )}
- Upload
-
-
-
-
-
-
- setMediaViewMode(
- mediaViewMode === "grid" ? "list" : "grid"
- )
- }
- disabled={isProcessing}
- className="justify-center items-center"
- >
- {mediaViewMode === "grid" ? (
-
- ) : (
-
- )}
-
-
-
-
- {mediaViewMode === "grid"
- ? "Switch to list view"
- : "Switch to grid view"}
-
-
-
-
-
-
-
-
-
-
-
-
- {
- if (sortBy === "name") {
- setSortOrder(
- sortOrder === "asc" ? "desc" : "asc"
- );
- } else {
- setSortBy("name");
- setSortOrder("asc");
- }
- }}
- >
- Name{" "}
- {sortBy === "name" &&
- (sortOrder === "asc" ? "↑" : "↓")}
-
- {
- if (sortBy === "type") {
- setSortOrder(
- sortOrder === "asc" ? "desc" : "asc"
- );
- } else {
- setSortBy("type");
- setSortOrder("asc");
- }
- }}
- >
- Type{" "}
- {sortBy === "type" &&
- (sortOrder === "asc" ? "↑" : "↓")}
-
- {
- if (sortBy === "duration") {
- setSortOrder(
- sortOrder === "asc" ? "desc" : "asc"
- );
- } else {
- setSortBy("duration");
- setSortOrder("asc");
- }
- }}
- >
- Duration{" "}
- {sortBy === "duration" &&
- (sortOrder === "asc" ? "↑" : "↓")}
-
- {
- if (sortBy === "size") {
- setSortOrder(
- sortOrder === "asc" ? "desc" : "asc"
- );
- } else {
- setSortBy("size");
- setSortOrder("asc");
- }
- }}
- >
- File Size{" "}
- {sortBy === "size" &&
- (sortOrder === "asc" ? "↑" : "↓")}
-
-
-
-
-
- Sort by {sortBy} (
- {sortOrder === "asc" ? "ascending" : "descending"})
-
-
-
-
-
-
-
-
-
-
-
- {isDragOver || filteredMediaItems.length === 0 ? (
-
- ) : mediaViewMode === "grid" ? (
-
- ) : (
-
- )}
-
-
-
- >
- );
-}
-
-function GridView({
- filteredMediaItems,
- renderPreview,
- handleRemove,
- highlightedId,
- registerElement,
-}: {
- filteredMediaItems: MediaFile[];
- renderPreview: (item: MediaFile) => React.ReactNode;
- handleRemove: (e: React.MouseEvent, id: string) => Promise;
- highlightedId: string | null;
- registerElement: (id: string, element: HTMLElement | null) => void;
-}) {
- const { addElementAtTime } = useTimelineStore();
-
- return (
-
- {filteredMediaItems.map((item) => (
-
registerElement(item.id, el)}>
-
-
- addElementAtTime(item, currentTime)
- }
- rounded={false}
- variant="card"
- isHighlighted={highlightedId === item.id}
- />
-
-
- ))}
-
- );
-}
-
-function ListView({
- filteredMediaItems,
- renderPreview,
- handleRemove,
- highlightedId,
- registerElement,
-}: {
- filteredMediaItems: MediaFile[];
- renderPreview: (item: MediaFile) => React.ReactNode;
- handleRemove: (e: React.MouseEvent, id: string) => Promise;
- highlightedId: string | null;
- registerElement: (id: string, element: HTMLElement | null) => void;
-}) {
- const { addElementAtTime } = useTimelineStore();
-
- return (
-
- {filteredMediaItems.map((item) => (
-
registerElement(item.id, el)}>
-
-
- addElementAtTime(item, currentTime)
- }
- variant="compact"
- isHighlighted={highlightedId === item.id}
- />
-
-
- ))}
-
- );
-}
diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx
deleted file mode 100644
index be54a9c4..00000000
--- a/apps/web/src/components/editor/media-panel/views/settings.tsx
+++ /dev/null
@@ -1,332 +0,0 @@
-"use client";
-
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
-import {
- PropertyItem,
- PropertyItemLabel,
- PropertyItemValue,
- PropertyGroup,
-} from "../../properties-panel/property-item";
-import { FPS_PRESETS } from "@/constants/timeline-constants";
-import { useProjectStore } from "@/stores/project-store";
-import type { BlurIntensity } from "@/types/project";
-import { useEditorStore } from "@/stores/editor-store";
-import { useAspectRatio } from "@/hooks/use-aspect-ratio";
-import Image from "next/image";
-import { cn } from "@/lib/utils";
-import { colors } from "@/data/colors/solid";
-import { patternCraftGradients } from "@/data/colors/pattern-craft";
-import { PipetteIcon, PlusIcon } from "lucide-react";
-import { useMemo, memo, useCallback } from "react";
-import { syntaxUIGradients } from "@/data/colors/syntax-ui";
-import { Button } from "@/components/ui/button";
-import { Separator } from "@/components/ui/separator";
-
-export function SettingsView() {
- return ;
-}
-
-function ProjectSettingsTabs() {
- return (
-
-
-
- ),
- },
- {
- value: "background",
- label: "Background",
- content: (
-
-
-
-
-
-
-
- Custom background
-
-
-
-
- {/* Another UI, looks so beautiful i don't wanna remove it */}
- {/*
*/}
-
- ),
- },
- ]}
- className="flex flex-col justify-between h-full p-0"
- />
- );
-}
-
-function ProjectInfoView() {
- const { activeProject, updateProjectFps, updateCanvasSize } =
- useProjectStore();
- const { canvasPresets } = useEditorStore();
- const { getDisplayName } = useAspectRatio();
-
- const handleAspectRatioChange = (value: string) => {
- const preset = canvasPresets.find((p) => p.name === value);
- if (preset) {
- updateCanvasSize(
- { width: preset.width, height: preset.height },
- "preset"
- );
- }
- };
-
- const handleFpsChange = (value: string) => {
- const fps = parseFloat(value);
- updateProjectFps(fps);
- };
-
- return (
-
-
- Name
-
- {activeProject?.name || "Untitled project"}
-
-
-
-
- Aspect ratio
-
-
-
-
-
-
- {canvasPresets.map((preset) => (
-
- {preset.name}
-
- ))}
-
-
-
-
-
-
- Frame rate
-
-
-
-
-
-
- {FPS_PRESETS.map((preset) => (
-
- {preset.label}
-
- ))}
-
-
-
-
-
- );
-}
-
-const BlurPreview = memo(
- ({
- blur,
- isSelected,
- onSelect,
- }: {
- blur: { label: string; value: number };
- isSelected: boolean;
- onSelect: () => void;
- }) => (
-
-
-
-
- {blur.label}
-
-
-
- )
-);
-
-BlurPreview.displayName = "BlurPreview";
-
-const BackgroundPreviews = memo(
- ({
- backgrounds,
- currentBackgroundColor,
- isColorBackground,
- handleColorSelect,
- useBackgroundColor = false,
- }: {
- backgrounds: string[];
- currentBackgroundColor: string;
- isColorBackground: boolean;
- handleColorSelect: (bg: string) => void;
- useBackgroundColor?: boolean;
- }) => {
- return useMemo(
- () =>
- backgrounds.map((bg, index) => (
- handleColorSelect(bg)}
- />
- )),
- [
- backgrounds,
- isColorBackground,
- currentBackgroundColor,
- handleColorSelect,
- useBackgroundColor,
- ]
- );
- }
-);
-
-BackgroundPreviews.displayName = "BackgroundPreviews";
-
-function BackgroundView() {
- const { activeProject, updateBackgroundType } = useProjectStore();
-
- const blurLevels = useMemo
>(
- () => [
- { label: "Light", value: 4 },
- { label: "Medium", value: 8 },
- { label: "Heavy", value: 18 },
- ],
- []
- );
-
- const handleBlurSelect = useCallback(
- async (blurIntensity: BlurIntensity) => {
- await updateBackgroundType("blur", { blurIntensity });
- },
- [updateBackgroundType]
- );
-
- const handleColorSelect = useCallback(
- async (color: string) => {
- await updateBackgroundType("color", { backgroundColor: color });
- },
- [updateBackgroundType]
- );
-
- const currentBlurIntensity = activeProject?.blurIntensity || 8;
- const isBlurBackground = activeProject?.backgroundType === "blur";
- const currentBackgroundColor = activeProject?.backgroundColor || "#000000";
- const isColorBackground = activeProject?.backgroundType === "color";
-
- const blurPreviews = useMemo(
- () =>
- blurLevels.map((blur) => (
- handleBlurSelect(blur.value)}
- />
- )),
- [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect]
- );
-
- return (
-
-
- {blurPreviews}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx
deleted file mode 100644
index 700eaea7..00000000
--- a/apps/web/src/components/editor/media-panel/views/sounds.tsx
+++ /dev/null
@@ -1,562 +0,0 @@
-"use client";
-
-import { Input } from "@/components/ui/input";
-import { useState, useMemo, useEffect } from "react";
-import { Separator } from "@/components/ui/separator";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import {
- PlayIcon,
- PauseIcon,
- HeartIcon,
- PlusIcon,
- ListFilter,
-} from "lucide-react";
-import { useSoundsStore } from "@/stores/sounds-store";
-import { useSoundSearch } from "@/hooks/use-sound-search";
-import type { SoundEffect, SavedSound } from "@/types/sounds";
-import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuCheckboxItem,
-} from "@/components/ui/dropdown-menu";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import { cn } from "@/lib/utils";
-import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
-
-export function SoundsView() {
- return (
-
-
-
-
- Sound effects
- Songs
- Saved
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-function SoundEffectsView() {
- const {
- topSoundEffects,
- isLoading,
- searchQuery,
- setSearchQuery,
- scrollPosition,
- setScrollPosition,
- loadSavedSounds,
- isSoundSaved,
- toggleSavedSound,
- showCommercialOnly,
- toggleCommercialFilter,
- hasLoaded,
- setTopSoundEffects,
- setLoading,
- setError,
- setHasLoaded,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- } = useSoundsStore();
- const {
- results: searchResults,
- isLoading: isSearching,
- loadMore,
- hasNextPage,
- isLoadingMore,
- } = useSoundSearch(searchQuery, showCommercialOnly);
-
- // Audio playback state
- const [playingId, setPlayingId] = useState(null);
- const [audioElement, setAudioElement] = useState(
- null
- );
-
- const { scrollAreaRef, handleScroll } = useInfiniteScroll({
- onLoadMore: loadMore,
- hasMore: hasNextPage,
- isLoading: isLoadingMore || isSearching,
- });
-
- useEffect(() => {
- loadSavedSounds();
-
- if (!hasLoaded) {
- let ignore = false;
-
- const fetchTopSounds = async () => {
- try {
- if (!ignore) {
- setLoading(true);
- setError(null);
- }
-
- const response = await fetch(
- "/api/sounds/search?page_size=50&sort=downloads"
- );
-
- if (!ignore) {
- if (!response.ok) {
- throw new Error(`Failed to fetch: ${response.status}`);
- }
-
- const data = await response.json();
- setTopSoundEffects(data.results);
- setHasLoaded(true);
-
- setCurrentPage(1);
- setHasNextPage(!!data.next);
- setTotalCount(data.count);
- }
- } catch (error) {
- if (!ignore) {
- console.error("Failed to fetch top sounds:", error);
- setError(
- error instanceof Error ? error.message : "Failed to load sounds"
- );
- }
- } finally {
- if (!ignore) {
- setLoading(false);
- }
- }
- };
-
- const timeoutId = setTimeout(fetchTopSounds, 100);
-
- return () => {
- clearTimeout(timeoutId);
- ignore = true;
- };
- }
-
- if (scrollAreaRef.current && scrollPosition > 0) {
- const timeoutId = setTimeout(() => {
- scrollAreaRef.current?.scrollTo({ top: scrollPosition });
- }, 100);
-
- return () => clearTimeout(timeoutId);
- }
- }, [
- hasLoaded,
- setTopSoundEffects,
- setLoading,
- setError,
- setHasLoaded,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- ]);
-
- const handleScrollWithPosition = (event: React.UIEvent) => {
- const { scrollTop } = event.currentTarget;
- setScrollPosition(scrollTop);
- handleScroll(event);
- };
-
- const displayedSounds = useMemo(() => {
- const sounds = searchQuery ? searchResults : topSoundEffects;
- return sounds;
- }, [searchQuery, searchResults, topSoundEffects]);
-
- const playSound = (sound: SoundEffect) => {
- if (playingId === sound.id) {
- audioElement?.pause();
- setPlayingId(null);
- return;
- }
-
- // Stop previous sound
- audioElement?.pause();
-
- if (sound.previewUrl) {
- const audio = new Audio(sound.previewUrl);
- audio.addEventListener("ended", () => {
- setPlayingId(null);
- });
- audio.addEventListener("error", (e) => {
- setPlayingId(null);
- });
- audio.play().catch((error) => {
- setPlayingId(null);
- });
-
- setAudioElement(audio);
- setPlayingId(sound.id);
- }
- };
-
- return (
-
-
-
setSearchQuery(e.target.value)}
- showClearIcon
- onClear={() => setSearchQuery("")}
- />
-
-
-
-
-
-
-
-
- Show only commercially licensed
-
-
- {showCommercialOnly
- ? "Only showing sounds licensed for commercial use"
- : "Showing all sounds regardless of license"}
-
-
-
-
-
-
-
-
- {isLoading && !searchQuery && (
-
- Loading sounds...
-
- )}
- {isSearching && searchQuery && (
-
Searching...
- )}
- {displayedSounds.map((sound) => (
-
playSound(sound)}
- isSaved={isSoundSaved(sound.id)}
- onToggleSaved={() => toggleSavedSound(sound)}
- />
- ))}
- {!isLoading && !isSearching && displayedSounds.length === 0 && (
-
- {searchQuery ? "No sounds found" : "No sounds available"}
-
- )}
- {isLoadingMore && (
-
- Loading more sounds...
-
- )}
-
-
-
-
- );
-}
-
-function SavedSoundsView() {
- const {
- savedSounds,
- isLoadingSavedSounds,
- savedSoundsError,
- loadSavedSounds,
- isSoundSaved,
- toggleSavedSound,
- clearSavedSounds,
- } = useSoundsStore();
-
- // Audio playback state
- const [playingId, setPlayingId] = useState(null);
- const [audioElement, setAudioElement] = useState(
- null
- );
-
- // Clear confirmation dialog state
- const [showClearDialog, setShowClearDialog] = useState(false);
-
- // Load saved sounds when tab becomes active
- useEffect(() => {
- loadSavedSounds();
- }, [loadSavedSounds]);
-
- const playSound = (sound: SavedSound) => {
- if (playingId === sound.id) {
- audioElement?.pause();
- setPlayingId(null);
- return;
- }
-
- // Stop previous sound
- audioElement?.pause();
-
- if (sound.previewUrl) {
- const audio = new Audio(sound.previewUrl);
- audio.addEventListener("ended", () => {
- setPlayingId(null);
- });
- audio.addEventListener("error", (e) => {
- setPlayingId(null);
- });
- audio.play().catch((error) => {
- setPlayingId(null);
- });
-
- setAudioElement(audio);
- setPlayingId(sound.id);
- }
- };
-
- // Convert SavedSound to SoundEffect for compatibility with AudioItem
- const convertToSoundEffect = (savedSound: SavedSound): SoundEffect => ({
- id: savedSound.id,
- name: savedSound.name,
- description: "",
- url: "",
- previewUrl: savedSound.previewUrl,
- downloadUrl: savedSound.downloadUrl,
- duration: savedSound.duration,
- filesize: 0,
- type: "audio",
- channels: 0,
- bitrate: 0,
- bitdepth: 0,
- samplerate: 0,
- username: savedSound.username,
- tags: savedSound.tags,
- license: savedSound.license,
- created: savedSound.savedAt,
- downloads: 0,
- rating: 0,
- ratingCount: 0,
- });
-
- if (isLoadingSavedSounds) {
- return (
-
-
- Loading saved sounds...
-
-
- );
- }
-
- if (savedSoundsError) {
- return (
-
-
- Error: {savedSoundsError}
-
-
- );
- }
-
- if (savedSounds.length === 0) {
- return (
-
-
-
-
No saved sounds
-
- Click the heart icon on any sound to save it here
-
-
-
- );
- }
-
- return (
-
-
-
- {savedSounds.length} saved{" "}
- {savedSounds.length === 1 ? "sound" : "sounds"}
-
-
-
-
- Clear all
-
-
-
-
- Clear all saved sounds?
-
- This will permanently remove all {savedSounds.length} saved
- sounds from your collection. This action cannot be undone.
-
-
-
- setShowClearDialog(false)}>
- Cancel
-
- {
- await clearSavedSounds();
- setShowClearDialog(false);
- }}
- >
- Clear all sounds
-
-
-
-
-
-
-
-
-
- {savedSounds.map((sound) => (
-
playSound(sound)}
- isSaved={isSoundSaved(sound.id)}
- onToggleSaved={() =>
- toggleSavedSound(convertToSoundEffect(sound))
- }
- />
- ))}
-
-
-
-
- );
-}
-
-function SongsView() {
- return Songs
;
-}
-
-interface AudioItemProps {
- sound: SoundEffect;
- isPlaying: boolean;
- onPlay: () => void;
- isSaved: boolean;
- onToggleSaved: () => void;
-}
-
-function AudioItem({
- sound,
- isPlaying,
- onPlay,
- isSaved,
- onToggleSaved,
-}: AudioItemProps) {
- const { addSoundToTimeline } = useSoundsStore();
-
- const handleClick = () => {
- onPlay();
- };
-
- const handleSaveClick = (e: React.MouseEvent) => {
- e.stopPropagation();
- onToggleSaved();
- };
-
- const handleAddToTimeline = async (e: React.MouseEvent) => {
- e.stopPropagation();
- await addSoundToTimeline(sound);
- };
-
- return (
-
-
-
- {isPlaying ? (
-
- ) : (
-
- )}
-
-
-
-
{sound.name}
-
- {sound.username}
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx
deleted file mode 100644
index f67450cc..00000000
--- a/apps/web/src/components/editor/media-panel/views/stickers.tsx
+++ /dev/null
@@ -1,612 +0,0 @@
-"use client";
-
-import { useEffect, useState, useMemo } from "react";
-import { useStickersStore } from "@/stores/stickers-store";
-import { useMediaStore } from "@/stores/media-store";
-import { useProjectStore } from "@/stores/project-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import {
- Loader2,
- Grid3X3,
- Hash,
- Smile,
- Clock,
- X,
- Sparkles,
- ArrowRight,
- StickerIcon,
-} from "lucide-react";
-import { toast } from "sonner";
-import { Button } from "@/components/ui/button";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-
-import {
- getIconSvgUrl,
- buildIconSvgUrl,
- ICONIFY_HOSTS,
- POPULAR_COLLECTIONS,
-} from "@/lib/iconify-api";
-import { cn } from "@/lib/utils";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import Image from "next/image";
-import type { MediaFile } from "@/types/media";
-import { DraggableMediaItem } from "@/components/ui/draggable-item";
-import { InputWithBack } from "@/components/ui/input-with-back";
-import { StickerCategory } from "@/stores/stickers-store";
-import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
-
-export function StickersView() {
- const { selectedCategory, setSelectedCategory } = useStickersStore();
-
- return (
- {
- if (["all", "general", "brands", "emoji"].includes(v)) {
- setSelectedCategory(v as StickerCategory);
- }
- }}
- tabs={[
- {
- value: "all",
- label: "All",
- icon: ,
- content: ,
- },
- {
- value: "general",
- label: "Icons",
- icon: ,
- content: ,
- },
- {
- value: "brands",
- label: "Brands",
- icon: ,
- content: ,
- },
- {
- value: "emoji",
- label: "Emoji",
- icon: ,
- content: ,
- },
- ]}
- className="flex flex-col h-full p-0 overflow-hidden"
- />
- );
-}
-
-function StickerGrid({
- icons,
- onAdd,
- addingSticker,
- capSize = false,
-}: {
- icons: string[];
- onAdd: (iconName: string) => void;
- addingSticker: string | null;
- capSize?: boolean;
-}) {
- return (
-
- {icons.map((iconName) => (
-
- ))}
-
- );
-}
-
-function CollectionGrid({
- collections,
- onSelectCollection,
-}: {
- collections: Array<{
- prefix: string;
- name: string;
- total: number;
- category?: string;
- }>;
- onSelectCollection: (prefix: string) => void;
-}) {
- return (
-
- {collections.map((collection) => (
- onSelectCollection(collection.prefix)}
- />
- ))}
-
- );
-}
-
-function EmptyView({ message }: { message: string }) {
- return (
-
-
-
-
No stickers found
-
{message}
-
-
- );
-}
-
-function StickersContentView({ category }: { category: StickerCategory }) {
- const { activeProject } = useProjectStore();
- const { addElementAtTime } = useTimelineStore();
- const { currentTime } = usePlaybackStore();
- const { addMediaFile } = useMediaStore();
- const {
- searchQuery,
- selectedCollection,
- viewMode,
- collections,
- currentCollection,
- searchResults,
- recentStickers,
- isLoadingCollections,
- isLoadingCollection,
- isSearching,
- setSearchQuery,
- setSelectedCollection,
- loadCollections,
- searchStickers,
- addStickerToTimeline,
- clearRecentStickers,
- setSelectedCategory,
- addingSticker,
- } = useStickersStore();
-
- const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
- const [collectionsToShow, setCollectionsToShow] = useState(20);
- const [showCollectionItems, setShowCollectionItems] = useState(false);
-
- const filteredCollections = useMemo(() => {
- if (category === "all") {
- return Object.entries(collections).map(([prefix, collection]) => ({
- prefix,
- name: collection.name,
- total: collection.total,
- category: collection.category,
- }));
- }
-
- const collectionList =
- POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS];
- if (!collectionList) return [];
-
- return collectionList
- .map((c) => {
- const collection = collections[c.prefix];
- return collection
- ? {
- prefix: c.prefix,
- name: c.name,
- total: collection.total,
- }
- : null;
- })
- .filter(Boolean) as Array<{
- prefix: string;
- name: string;
- total: number;
- }>;
- }, [collections, category]);
-
- const { scrollAreaRef, handleScroll } = useInfiniteScroll({
- onLoadMore: () => setCollectionsToShow((prev) => prev + 20),
- hasMore: filteredCollections.length > collectionsToShow,
- isLoading: isLoadingCollections,
- enabled: viewMode === "browse" && !selectedCollection && category === "all",
- });
-
- useEffect(() => {
- if (Object.keys(collections).length === 0) {
- loadCollections();
- }
- }, []);
-
- useEffect(() => {
- const timer = setTimeout(() => {
- if (localSearchQuery !== searchQuery) {
- setSearchQuery(localSearchQuery);
- if (localSearchQuery.trim()) {
- searchStickers(localSearchQuery);
- }
- }
- }, 500);
-
- return () => clearTimeout(timer);
- }, [localSearchQuery]);
-
- const handleAddSticker = async (iconName: string) => {
- try {
- await addStickerToTimeline(iconName);
- } catch (error) {
- console.error("Failed to add sticker:", error);
- toast.error("Failed to add sticker to timeline");
- }
- };
-
- const iconsToDisplay = useMemo(() => {
- if (viewMode === "search" && searchResults) {
- return searchResults.icons;
- }
-
- if (viewMode === "collection" && currentCollection) {
- const icons: string[] = [];
-
- if (currentCollection.uncategorized) {
- icons.push(
- ...currentCollection.uncategorized.map(
- (name) => `${currentCollection.prefix}:${name}`
- )
- );
- }
-
- if (currentCollection.categories) {
- Object.values(currentCollection.categories).forEach((categoryIcons) => {
- icons.push(
- ...categoryIcons.map(
- (name) => `${currentCollection.prefix}:${name}`
- )
- );
- });
- }
-
- return icons.slice(0, 100);
- }
-
- return [];
- }, [viewMode, searchResults, currentCollection]);
-
- const isInCollection = viewMode === "collection" && !!selectedCollection;
-
- useEffect(() => {
- if (isInCollection) {
- setShowCollectionItems(false);
- const timer = setTimeout(() => setShowCollectionItems(true), 350);
- return () => clearTimeout(timer);
- } else {
- setShowCollectionItems(false);
- }
- }, [isInCollection]);
-
- return (
-
-
- {
- if (!expanded && isInCollection) {
- setSelectedCollection(null);
- }
- }}
- placeholder={
- category === "all"
- ? "Search all stickers"
- : category === "general"
- ? "Search icons"
- : category === "brands"
- ? "Search brands"
- : "Search Emojis"
- }
- value={localSearchQuery}
- onChange={setLocalSearchQuery}
- disableAnimation={true}
- />
-
-
-
-
-
- {recentStickers.length > 0 && viewMode === "browse" && (
-
-
-
-
Recent
-
-
-
-
-
-
-
-
- Clear recent stickers
-
-
-
-
-
-
- )}
-
- {viewMode === "collection" && selectedCollection && (
-
- {isLoadingCollection ? (
-
-
-
- ) : showCollectionItems ? (
-
- ) : (
-
-
-
- )}
-
- )}
-
- {viewMode === "search" && (
-
- {isSearching ? (
-
-
-
- ) : searchResults?.icons.length ? (
- <>
-
-
- {searchResults.total} results
-
-
-
- >
- ) : searchQuery ? (
-
-
- {category !== "all" && (
- {
- const q = localSearchQuery || searchQuery;
- if (q) {
- setSearchQuery(q);
- }
- setSelectedCategory("all");
- if (q) {
- searchStickers(q);
- }
- }}
- >
- Search in all icons
-
- )}
-
- ) : null}
-
- )}
-
- {viewMode === "browse" && !selectedCollection && (
-
- {isLoadingCollections ? (
-
-
-
- ) : (
- <>
- {category !== "all" && (
-
-
- Popular{" "}
- {category === "general"
- ? "Icon Sets"
- : category === "brands"
- ? "Brand Icons"
- : "Emoji Sets"}
-
-
-
- )}
-
- {category === "all" && filteredCollections.length > 0 && (
-
-
-
- )}
- >
- )}
-
- )}
-
-
-
-
- );
-}
-
-interface CollectionItemProps {
- title: string;
- subtitle: string;
- onClick: () => void;
-}
-
-function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) {
- return (
-
-
-
-
- );
-}
-
-interface StickerItemProps {
- iconName: string;
- onAdd: (iconName: string) => void;
- isAdding?: boolean;
- capSize?: boolean;
-}
-
-function StickerItem({
- iconName,
- onAdd,
- isAdding,
- capSize = false,
-}: StickerItemProps) {
- const [imageError, setImageError] = useState(false);
- const [hostIndex, setHostIndex] = useState(0);
-
- useEffect(() => {
- setImageError(false);
- setHostIndex(0);
- }, [iconName]);
-
- const displayName = iconName.split(":")[1] || iconName;
- const collectionPrefix = iconName.split(":")[0];
-
- const preview = imageError ? (
-
-
- {displayName}
-
-
- ) : (
-
- {
- const next = hostIndex + 1;
- if (next < ICONIFY_HOSTS.length) {
- setHostIndex(next);
- } else {
- setImageError(true);
- }
- }}
- loading="lazy"
- unoptimized
- />
-
- );
-
- return (
-
-
-
-
onAdd(iconName)}
- aspectRatio={1}
- showLabel={false}
- rounded={true}
- variant="card"
- className=""
- containerClassName="w-full"
- isDraggable={false}
- />
- {isAdding && (
-
-
-
- )}
-
-
-
-
-
{displayName}
-
{collectionPrefix}
-
-
-
- );
-}
diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx
deleted file mode 100644
index 22fc2413..00000000
--- a/apps/web/src/components/editor/media-panel/views/text.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { DraggableMediaItem } from "@/components/ui/draggable-item";
-import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
-
-export function TextView() {
- return (
-
-
- Default text
-
- }
- dragData={{
- id: "temp-text-id",
- type: DEFAULT_TEXT_ELEMENT.type,
- name: DEFAULT_TEXT_ELEMENT.name,
- content: DEFAULT_TEXT_ELEMENT.content,
- }}
- aspectRatio={1}
- onAddToTimeline={(currentTime) =>
- useTimelineStore.getState().addElementAtTime(
- {
- ...DEFAULT_TEXT_ELEMENT,
- id: "temp-text-id",
- },
- currentTime
- )
- }
- showLabel={false}
- />
-
- );
-}
diff --git a/apps/web/src/components/editor/onboarding.tsx b/apps/web/src/components/editor/onboarding.tsx
index 8ee60690..c60c2de5 100644
--- a/apps/web/src/components/editor/onboarding.tsx
+++ b/apps/web/src/components/editor/onboarding.tsx
@@ -1,138 +1,137 @@
"use client";
-import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
-import { Button } from "../ui/button";
import { ArrowRightIcon } from "lucide-react";
-import { useState, useEffect } from "react";
+import { useState } from "react";
import ReactMarkdown from "react-markdown";
+import { SOCIAL_LINKS } from "@/constants/site-constants";
+import { useLocalStorage } from "@/hooks/storage/use-local-storage";
+import { Button } from "../ui/button";
+import { Dialog, DialogBody, DialogContent, DialogTitle } from "../ui/dialog";
export function Onboarding() {
- const [step, setStep] = useState(0);
- const [isOpen, setIsOpen] = useState(false);
+ const [step, setStep] = useState(0);
+ const [hasSeenOnboarding, setHasSeenOnboarding] = useLocalStorage({
+ key: "hasSeenOnboarding",
+ defaultValue: false,
+ });
- useEffect(() => {
- const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding");
- if (!hasSeenOnboarding) {
- setIsOpen(true);
- }
- }, []);
+ const isOpen = !hasSeenOnboarding;
- const handleNext = () => {
- setStep(step + 1);
- };
+ const handleNext = () => {
+ setStep(step + 1);
+ };
- const handleClose = () => {
- setIsOpen(false);
- localStorage.setItem("hasSeenOnboarding", "true");
- };
+ const handleClose = () => {
+ setHasSeenOnboarding({ value: true });
+ };
- const getStepTitle = () => {
- switch (step) {
- case 0:
- return "Welcome to OpenCut Beta! 🎉";
- case 1:
- return "⚠️ This is a super early beta!";
- case 2:
- return "🦋 Have fun testing!";
- default:
- return "OpenCut Onboarding";
- }
- };
+ const getStepTitle = () => {
+ switch (step) {
+ case 0:
+ return "Welcome to OpenCut Beta! 🎉";
+ case 1:
+ return "⚠️ This is a super early beta!";
+ case 2:
+ return "🦋 Have fun testing!";
+ default:
+ return "OpenCut Onboarding";
+ }
+ };
- const renderStepContent = () => {
- switch (step) {
- case 0:
- return (
-
- );
- case 1:
- return (
-
- );
- case 2:
- return (
-
- );
- default:
- return null;
- }
- };
+ const renderStepContent = () => {
+ switch (step) {
+ case 0:
+ return (
+
+ );
+ case 1:
+ return (
+
+ );
+ case 2:
+ return (
+
+ );
+ default:
+ return null;
+ }
+ };
- return (
-
-
-
- {getStepTitle()}
-
- {renderStepContent()}
-
-
- );
+ return (
+
+
+
+ {getStepTitle()}
+
+
+ {renderStepContent()}
+
+
+
+ );
}
function Title({ title }: { title: string }) {
- return {title} ;
-}
-
-function Subtitle({ subtitle }: { subtitle: string }) {
- return {subtitle} ;
+ return {title} ;
}
function Description({ description }: { description: string }) {
- return (
-
-
{children}
,
- a: ({ href, children }) => (
-
- {children}
-
- ),
- }}
- >
- {description}
-
-
- );
+ return (
+
+
{children}
,
+ a: ({ href, children }) => (
+
+ {children}
+
+ ),
+ }}
+ >
+ {description}
+
+
+ );
}
function NextButton({
- children,
- onClick,
+ children,
+ onClick,
}: {
- children: React.ReactNode;
- onClick: () => void;
+ children: React.ReactNode;
+ onClick: () => void;
}) {
- return (
-
- {children}
-
-
- );
+ return (
+
+ {children}
+
+
+ );
}
diff --git a/apps/web/src/components/editor/panel-base-view.tsx b/apps/web/src/components/editor/panel-base-view.tsx
deleted file mode 100644
index 6e92c616..00000000
--- a/apps/web/src/components/editor/panel-base-view.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Separator } from "@/components/ui/separator";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { cn } from "@/lib/utils";
-
-interface PanelBaseViewProps {
- children?: React.ReactNode;
- defaultTab?: string;
- value?: string;
- onValueChange?: (value: string) => void;
- tabs?: {
- value: string;
- label: string;
- icon?: React.ReactNode;
- content: React.ReactNode;
- }[];
- className?: string;
- ref?: React.RefObject;
-}
-
-function ViewContent({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return (
-
- {children}
-
- );
-}
-
-export function PanelBaseView({
- children,
- defaultTab,
- value,
- onValueChange,
- tabs,
- className = "",
- ref,
-}: PanelBaseViewProps) {
- return (
-
- {!tabs || tabs.length === 0 ? (
-
{children}
- ) : (
-
-
-
-
- {tabs.map((tab) => (
-
- {tab.icon ? (
-
- {tab.icon}
-
- ) : null}
- {tab.label}
-
- ))}
-
-
-
-
- {tabs.map((tab) => (
-
- {tab.content}
-
- ))}
-
- )}
-
- );
-}
diff --git a/apps/web/src/components/editor/panel-preset-selector.tsx b/apps/web/src/components/editor/panel-preset-selector.tsx
deleted file mode 100644
index af22a171..00000000
--- a/apps/web/src/components/editor/panel-preset-selector.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-"use client";
-
-import { Button } from "../ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "../ui/dropdown-menu";
-import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react";
-import { usePanelStore, type PanelPreset } from "@/stores/panel-store";
-
-const PRESET_LABELS: Record = {
- default: "Default",
- media: "Media",
- inspector: "Inspector",
- "vertical-preview": "Vertical Preview",
-};
-
-const PRESET_DESCRIPTIONS: Record = {
- default: "Media, preview, and inspector on top row, timeline on bottom",
- media: "Full height media on left, preview and inspector on top row",
- inspector: "Full height inspector on right, media and preview on top row",
- "vertical-preview": "Full height preview on right for vertical videos",
-};
-
-export function PanelPresetSelector() {
- const { activePreset, setActivePreset, resetPreset } = usePanelStore();
-
- const handlePresetChange = (preset: PanelPreset) => {
- setActivePreset(preset);
- };
-
- const handleResetPreset = (preset: PanelPreset, event: React.MouseEvent) => {
- event.stopPropagation();
- resetPreset(preset);
- };
-
- return (
-
-
-
-
-
-
-
-
-
- Panel Presets
-
-
- {(Object.keys(PRESET_LABELS) as PanelPreset[]).map((preset) => (
- handlePresetChange(preset)}
- className="flex items-start justify-between gap-2 py-2 px-3 cursor-pointer"
- >
-
-
-
- {PRESET_LABELS[preset]}
-
- {activePreset === preset && (
-
- )}
-
-
- {PRESET_DESCRIPTIONS[preset]}
-
-
- handleResetPreset(preset, e)}
- title={`Reset ${PRESET_LABELS[preset]} preset`}
- >
-
-
-
- ))}
-
-
- );
-}
diff --git a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx
new file mode 100644
index 00000000..c6b0861c
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx
@@ -0,0 +1,61 @@
+import { HugeiconsIcon } from "@hugeicons/react";
+import { UploadIcon } from "@hugeicons/core-free-icons";
+
+interface MediaDragOverlayProps {
+ isVisible: boolean;
+ isProcessing?: boolean;
+ progress?: number;
+ onClick?: () => void;
+}
+
+export function MediaDragOverlay({
+ isVisible,
+ isProcessing = false,
+ progress = 0,
+ onClick,
+}: MediaDragOverlayProps) {
+ if (!isVisible) return null;
+
+ const handleClick = ({
+ event,
+ }: {
+ event: React.MouseEvent;
+ }) => {
+ if (isProcessing || !onClick) return;
+ event.preventDefault();
+ event.stopPropagation();
+ onClick();
+ };
+
+ return (
+ handleClick({ event })}
+ >
+
+
+
+
+
+
+ {isProcessing
+ ? `Processing your files (${progress}%)`
+ : "Drag and drop videos, photos, and audio files here"}
+
+
+
+ {isProcessing && (
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
new file mode 100644
index 00000000..10cb1f8c
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
@@ -0,0 +1,244 @@
+"use client";
+
+import { Plus } from "lucide-react";
+import { type ReactNode, useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { AspectRatio } from "@/components/ui/aspect-ratio";
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useEditor } from "@/hooks/use-editor";
+import { clearDragData, setDragData } from "@/lib/drag-data";
+import type { TimelineDragData } from "@/types/drag";
+import { cn } from "@/utils/ui";
+
+export interface DraggableItemProps {
+ name: string;
+ preview: ReactNode;
+ dragData: TimelineDragData;
+ onDragStart?: ({ e }: { e: React.DragEvent }) => void;
+ onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void;
+ aspectRatio?: number;
+ className?: string;
+ containerClassName?: string;
+ shouldShowPlusOnDrag?: boolean;
+ shouldShowLabel?: boolean;
+ isRounded?: boolean;
+ variant?: "card" | "compact";
+ isDraggable?: boolean;
+ isHighlighted?: boolean;
+}
+
+export function DraggableItem({
+ name,
+ preview,
+ dragData,
+ onDragStart,
+ onAddToTimeline,
+ aspectRatio = 16 / 9,
+ className = "",
+ containerClassName,
+ shouldShowPlusOnDrag = true,
+ shouldShowLabel = true,
+ isRounded = true,
+ variant = "card",
+ isDraggable = true,
+ isHighlighted = false,
+}: DraggableItemProps) {
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
+ const dragRef = useRef(null);
+ const editor = useEditor();
+ const highlightClassName = `ring-2 ring-primary bg-primary/10 ${isRounded ? "rounded-sm" : ""}`;
+
+ const handleAddToTimeline = () => {
+ onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
+ };
+
+ const emptyImg = new window.Image();
+ emptyImg.src =
+ "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
+
+ useEffect(() => {
+ if (!isDragging) return;
+
+ const handleDragOver = (e: DragEvent) => {
+ setDragPosition({ x: e.clientX, y: e.clientY });
+ };
+
+ document.addEventListener("dragover", handleDragOver);
+
+ return () => {
+ document.removeEventListener("dragover", handleDragOver);
+ };
+ }, [isDragging]);
+
+ const handleDragStart = (e: React.DragEvent) => {
+ e.dataTransfer.setDragImage(emptyImg, 0, 0);
+
+ setDragData({ dataTransfer: e.dataTransfer, dragData });
+ e.dataTransfer.effectAllowed = "copy";
+
+ setDragPosition({ x: e.clientX, y: e.clientY });
+ setIsDragging(true);
+
+ onDragStart?.({ e });
+ };
+
+ const handleDragEnd = () => {
+ setIsDragging(false);
+ clearDragData();
+ };
+
+ return (
+ <>
+ {variant === "card" ? (
+
+
+
+ {preview}
+ {!isDragging && (
+
+ )}
+
+ {shouldShowLabel && (
+
+ {name}
+
+ {name.length > 8
+ ? `${name.slice(0, 16)}...${name.slice(-3)}`
+ : name}
+
+
+ )}
+
+
+ ) : (
+
+
+
+ {preview}
+
+ {name}
+
+
+ )}
+
+ {isDraggable &&
+ isDragging &&
+ typeof document !== "undefined" &&
+ createPortal(
+
+
+
+
+ {preview}
+
+ {shouldShowPlusOnDrag && (
+
+ )}
+
+
+
,
+ document.body,
+ )}
+ >
+ );
+}
+
+function PlusButton({
+ className,
+ onClick,
+ tooltipText,
+}: {
+ className?: string;
+ onClick?: () => void;
+ tooltipText?: string;
+}) {
+ const button = (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ onClick?.();
+ }}
+ title={tooltipText}
+ >
+
+
+ );
+
+ if (tooltipText) {
+ return (
+
+ {button}
+
+ {tooltipText}
+
+
+ );
+ }
+
+ return button;
+}
diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx
new file mode 100644
index 00000000..9161cb1d
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/index.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { Separator } from "@/components/ui/separator";
+import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store";
+import { TabBar } from "./tabbar";
+import { Captions } from "./views/captions";
+import { MediaView } from "./views/media";
+import { SettingsView } from "./views/settings";
+import { SoundsView } from "./views/sounds";
+import { StickersView } from "./views/stickers";
+import { TextView } from "./views/text";
+
+export function AssetsPanel() {
+ const { activeTab } = useAssetsPanelStore();
+
+ const viewMap: Record = {
+ media: ,
+ sounds: ,
+ text: ,
+ stickers: ,
+ effects: (
+
+ Effects view coming soon...
+
+ ),
+ transitions: (
+
+ Transitions view coming soon...
+
+ ),
+ captions: ,
+ filters: (
+
+ Filters view coming soon...
+
+ ),
+ adjustment: (
+
+ Adjustment view coming soon...
+
+ ),
+ settings: ,
+ };
+
+ return (
+
+
+
+
{viewMap[activeTab]}
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx
new file mode 100644
index 00000000..2c950fdc
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/utils/ui";
+import {
+ TAB_KEYS,
+ tabs,
+ useAssetsPanelStore,
+} from "@/stores/assets-panel-store";
+
+export function TabBar() {
+ const { activeTab, setActiveTab } = useAssetsPanelStore();
+ const scrollRef = useRef(null);
+ const [showTopFade, setShowTopFade] = useState(false);
+ const [showBottomFade, setShowBottomFade] = useState(false);
+
+ const checkScrollPosition = useCallback(() => {
+ const element = scrollRef.current;
+ if (!element) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = element;
+ setShowTopFade(scrollTop > 0);
+ setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1);
+ }, []);
+
+ useEffect(() => {
+ const element = scrollRef.current;
+ if (!element) return;
+
+ checkScrollPosition();
+ element.addEventListener("scroll", checkScrollPosition);
+
+ const resizeObserver = new ResizeObserver(checkScrollPosition);
+ resizeObserver.observe(element);
+
+ return () => {
+ element.removeEventListener("scroll", checkScrollPosition);
+ resizeObserver.disconnect();
+ };
+ }, [checkScrollPosition]);
+
+ return (
+
+
+ {TAB_KEYS.map((tabKey) => {
+ const tab = tabs[tabKey];
+ return (
+
+
+ svg]:size-4.5! opacity-100 hover:opacity-75",
+ activeTab === tabKey
+ ? "text-primary !opacity-100"
+ : "text-muted-foreground",
+ )}
+ onClick={() => setActiveTab(tabKey)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ setActiveTab(tabKey);
+ }
+ }}
+ >
+
+
+
+
+
+ {tab.label}
+
+
+
+ );
+ })}
+
+
+
+
+
+ );
+}
+
+function FadeOverlay({
+ direction,
+ show,
+}: {
+ direction: "top" | "bottom";
+ show: boolean;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx
new file mode 100644
index 00000000..306bad2a
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx
@@ -0,0 +1,152 @@
+import { Button } from "@/components/ui/button";
+import { PropertyGroup } from "@/components/editor/panels/properties/property-item";
+import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useState, useRef } from "react";
+import { extractTimelineAudio } from "@/lib/media/mediabunny";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
+import { TRANSCRIPTION_LANGUAGES } from "@/constants/transcription-constants";
+import type {
+ TranscriptionLanguage,
+ TranscriptionProgress,
+} from "@/types/transcription";
+import { transcriptionService } from "@/services/transcription";
+import { decodeAudioToFloat32 } from "@/lib/media/audio";
+import { buildCaptionChunks } from "@/lib/transcription/caption";
+import { Spinner } from "@/components/ui/spinner";
+
+export function Captions() {
+ const [selectedLanguage, setSelectedLanguage] =
+ useState("auto");
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [processingStep, setProcessingStep] = useState("");
+ const [error, setError] = useState(null);
+ const containerRef = useRef(null);
+ const editor = useEditor();
+
+ const handleProgress = (progress: TranscriptionProgress) => {
+ if (progress.status === "loading-model") {
+ setProcessingStep(`Loading model ${Math.round(progress.progress)}%`);
+ } else if (progress.status === "transcribing") {
+ setProcessingStep("Transcribing...");
+ }
+ };
+
+ const handleGenerateTranscript = async () => {
+ try {
+ setIsProcessing(true);
+ setError(null);
+ setProcessingStep("Extracting audio...");
+
+ const audioBlob = await extractTimelineAudio({
+ tracks: editor.timeline.getTracks(),
+ mediaAssets: editor.media.getAssets(),
+ totalDuration: editor.timeline.getTotalDuration(),
+ });
+
+ setProcessingStep("Preparing audio...");
+ const { samples } = await decodeAudioToFloat32({ audioBlob });
+
+ const result = await transcriptionService.transcribe({
+ audioData: samples,
+ language: selectedLanguage === "auto" ? undefined : selectedLanguage,
+ onProgress: handleProgress,
+ });
+
+ setProcessingStep("Generating captions...");
+ const captionChunks = buildCaptionChunks({ segments: result.segments });
+
+ const captionTrackId = editor.timeline.addTrack({
+ type: "text",
+ index: 0,
+ });
+
+ for (let i = 0; i < captionChunks.length; i++) {
+ const caption = captionChunks[i];
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: captionTrackId },
+ element: {
+ ...DEFAULT_TEXT_ELEMENT,
+ name: `Caption ${i + 1}`,
+ content: caption.text,
+ duration: caption.duration,
+ startTime: caption.startTime,
+ fontSize: 65,
+ fontWeight: "bold",
+ },
+ });
+ }
+ } catch (error) {
+ console.error("Transcription failed:", error);
+ setError(
+ error instanceof Error ? error.message : "An unexpected error occurred",
+ );
+ } finally {
+ setIsProcessing(false);
+ setProcessingStep("");
+ }
+ };
+
+ const handleLanguageChange = ({ value }: { value: string }) => {
+ if (value === "auto") {
+ setSelectedLanguage("auto");
+ return;
+ }
+
+ const matchedLanguage = TRANSCRIPTION_LANGUAGES.find(
+ (language) => language.code === value,
+ );
+ if (!matchedLanguage) return;
+ setSelectedLanguage(matchedLanguage.code);
+ };
+
+ return (
+
+
+ handleLanguageChange({ value })}
+ >
+
+
+
+
+ Auto detect
+ {TRANSCRIPTION_LANGUAGES.map((language) => (
+
+ {language.name}
+
+ ))}
+
+
+
+
+
+ {error && (
+
+ )}
+
+
+ {isProcessing && }
+ {isProcessing ? processingStep : "Generate transcript"}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/media.tsx b/apps/web/src/components/editor/panels/assets/views/media.tsx
new file mode 100644
index 00000000..76ae0064
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/media.tsx
@@ -0,0 +1,675 @@
+"use client";
+
+import Image from "next/image";
+import { useMemo, useState } from "react";
+import { toast } from "sonner";
+import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay";
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { Button } from "@/components/ui/button";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { useEditor } from "@/hooks/use-editor";
+import { useFileUpload } from "@/hooks/use-file-upload";
+import { useRevealItem } from "@/hooks/use-reveal-item";
+import { processMediaAssets } from "@/lib/media/processing";
+import {
+ buildImageElement,
+ buildUploadAudioElement,
+ buildVideoElement,
+} from "@/lib/timeline/element-utils";
+import { useAssetsPanelStore } from "@/stores/assets-panel-store";
+import type { MediaAsset } from "@/types/assets";
+import type { CreateTimelineElement } from "@/types/timeline";
+import { cn } from "@/utils/ui";
+import {
+ CloudUploadIcon,
+ GridViewIcon,
+ LeftToRightListDashIcon,
+ SortingOneNineIcon,
+ Image02Icon,
+ MusicNote03Icon,
+ Video01Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
+
+export function MediaView() {
+ const editor = useEditor();
+ const mediaFiles = editor.media.getAssets();
+ const activeProject = editor.project.getActive();
+
+ const { mediaViewMode, setMediaViewMode, highlightMediaId, clearHighlight } =
+ useAssetsPanelStore();
+ const { highlightedId, registerElement } = useRevealItem(
+ highlightMediaId,
+ clearHighlight,
+ );
+
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
+ "name",
+ );
+ const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
+
+ const processFiles = async ({ files }: { files: FileList }) => {
+ if (!files || files.length === 0) return;
+ if (!activeProject) {
+ toast.error("No active project");
+ return;
+ }
+
+ setIsProcessing(true);
+ setProgress(0);
+ try {
+ const processedAssets = await processMediaAssets({
+ files,
+ onProgress: (progress: { progress: number }) =>
+ setProgress(progress.progress),
+ });
+ for (const asset of processedAssets) {
+ await editor.media.addMediaAsset({
+ projectId: activeProject.metadata.id,
+ asset,
+ });
+ }
+ } catch (error) {
+ console.error("Error processing files:", error);
+ toast.error("Failed to process files");
+ } finally {
+ setIsProcessing(false);
+ setProgress(0);
+ }
+ };
+
+ const { isDragOver, dragProps, openFilePicker, fileInputProps } =
+ useFileUpload({
+ accept: "image/*,video/*,audio/*",
+ multiple: true,
+ onFilesSelected: (files) => processFiles({ files }),
+ });
+
+ const handleRemove = async ({
+ event,
+ id,
+ }: {
+ event: React.MouseEvent;
+ id: string;
+ }) => {
+ event.stopPropagation();
+
+ if (!activeProject) {
+ toast.error("No active project");
+ return;
+ }
+
+ await editor.media.removeMediaAsset({
+ projectId: activeProject.metadata.id,
+ id,
+ });
+ };
+
+ const addElementAtTime = ({
+ asset,
+ startTime,
+ }: {
+ asset: MediaAsset;
+ startTime: number;
+ }): boolean => {
+ const element = createElementFromMedia({ asset, startTime });
+ editor.timeline.insertElement({
+ element,
+ placement: { mode: "auto" },
+ });
+ return true;
+ };
+
+ const filteredMediaItems = useMemo(() => {
+ const filtered = mediaFiles.filter((item) => !item.ephemeral);
+
+ filtered.sort((a, b) => {
+ let valueA: string | number;
+ let valueB: string | number;
+
+ switch (sortBy) {
+ case "name":
+ valueA = a.name.toLowerCase();
+ valueB = b.name.toLowerCase();
+ break;
+ case "type":
+ valueA = a.type;
+ valueB = b.type;
+ break;
+ case "duration":
+ valueA = a.duration || 0;
+ valueB = b.duration || 0;
+ break;
+ case "size":
+ valueA = a.file.size;
+ valueB = b.file.size;
+ break;
+ default:
+ return 0;
+ }
+
+ if (valueA < valueB) return sortOrder === "asc" ? -1 : 1;
+ if (valueA > valueB) return sortOrder === "asc" ? 1 : -1;
+ return 0;
+ });
+
+ return filtered;
+ }, [mediaFiles, sortBy, sortOrder]);
+
+ const previewComponents = useMemo(() => {
+ const previews = new Map();
+
+ filteredMediaItems.forEach((item) => {
+ previews.set(item.id, );
+ previews.set(
+ `compact-${item.id}`,
+ ,
+ );
+ });
+
+ return previews;
+ }, [filteredMediaItems]);
+
+ const renderPreview = (item: MediaAsset) => previewComponents.get(item.id);
+ const renderCompactPreview = (item: MediaAsset) =>
+ previewComponents.get(`compact-${item.id}`);
+
+ return (
+ <>
+
+
+
+
+
Assets
+
+
+
+
+
+ setMediaViewMode(
+ mediaViewMode === "grid" ? "list" : "grid",
+ )
+ }
+ disabled={isProcessing}
+ className="items-center justify-center"
+ >
+ {mediaViewMode === "grid" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {mediaViewMode === "grid"
+ ? "Switch to list view"
+ : "Switch to grid view"}
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+
+
+
+
+ Sort by {sortBy} (
+ {sortOrder === "asc" ? "ascending" : "descending"})
+
+
+
+
+
+
+
+ Upload
+
+
+
+
+
+
+ {isDragOver || filteredMediaItems.length === 0 ? (
+
+ ) : mediaViewMode === "grid" ? (
+
+ ) : (
+
+ )}
+
+
+
+ >
+ );
+}
+
+function MediaItemWithContextMenu({
+ item,
+ children,
+ onRemove,
+}: {
+ item: MediaAsset;
+ children: React.ReactNode;
+ onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
+}) {
+ return (
+
+ {children}
+
+ Export clips
+ onRemove({ event, id: item.id })}
+ >
+ Delete
+
+
+
+ );
+}
+
+function GridView({
+ items,
+ renderPreview,
+ onRemove,
+ onAddToTimeline,
+ highlightedId,
+ registerElement,
+}: {
+ items: MediaAsset[];
+ renderPreview: (item: MediaAsset) => React.ReactNode;
+ onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
+ onAddToTimeline: ({
+ asset,
+ startTime,
+ }: {
+ asset: MediaAsset;
+ startTime: number;
+ }) => boolean;
+ highlightedId: string | null;
+ registerElement: (id: string, element: HTMLElement | null) => void;
+}) {
+ return (
+
+ {items.map((item) => (
+
registerElement(item.id, el)}>
+
+
+ onAddToTimeline({ asset: item, startTime: currentTime })
+ }
+ isRounded={false}
+ variant="card"
+ isHighlighted={highlightedId === item.id}
+ />
+
+
+ ))}
+
+ );
+}
+
+function ListView({
+ items,
+ renderPreview,
+ onRemove,
+ onAddToTimeline,
+ highlightedId,
+ registerElement,
+}: {
+ items: MediaAsset[];
+ renderPreview: (item: MediaAsset) => React.ReactNode;
+ onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void;
+ onAddToTimeline: ({
+ asset,
+ startTime,
+ }: {
+ asset: MediaAsset;
+ startTime: number;
+ }) => boolean;
+ highlightedId: string | null;
+ registerElement: (id: string, element: HTMLElement | null) => void;
+}) {
+ return (
+
+ {items.map((item) => (
+
registerElement(item.id, el)}>
+
+
+ onAddToTimeline({ asset: item, startTime: currentTime })
+ }
+ variant="compact"
+ isHighlighted={highlightedId === item.id}
+ />
+
+
+ ))}
+
+ );
+}
+
+const formatDuration = ({ duration }: { duration: number }) => {
+ const min = Math.floor(duration / 60);
+ const sec = Math.floor(duration % 60);
+ return `${min}:${sec.toString().padStart(2, "0")}`;
+};
+
+function MediaDurationBadge({ duration }: { duration?: number }) {
+ if (!duration) return null;
+
+ return (
+
+ {formatDuration({ duration })}
+
+ );
+}
+
+function MediaDurationLabel({ duration }: { duration?: number }) {
+ if (!duration) return null;
+
+ return (
+ {formatDuration({ duration })}
+ );
+}
+
+function MediaTypePlaceholder({
+ icon,
+ label,
+ duration,
+ variant,
+}: {
+ icon: IconSvgElement;
+ label: string;
+ duration?: number;
+ variant: "muted" | "bordered";
+}) {
+ const iconClassName = cn("size-6", variant === "bordered" && "mb-1");
+
+ return (
+
+
+ {label}
+
+
+ );
+}
+
+function MediaPreview({
+ item,
+ variant = "grid",
+}: {
+ item: MediaAsset;
+ variant?: "grid" | "compact";
+}) {
+ const shouldShowDurationBadge = variant === "grid";
+
+ if (item.type === "image") {
+ return (
+
+
+
+ );
+ }
+
+ if (item.type === "video") {
+ if (item.thumbnailUrl) {
+ return (
+
+
+ {shouldShowDurationBadge ? (
+
+ ) : null}
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+ if (item.type === "audio") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function SortMenuItem({
+ label,
+ sortKey,
+ currentSortBy,
+ currentSortOrder,
+ onSort,
+}: {
+ label: string;
+ sortKey: "name" | "type" | "duration" | "size";
+ currentSortBy: string;
+ currentSortOrder: "asc" | "desc";
+ onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void;
+}) {
+ const isActive = currentSortBy === sortKey;
+ const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : "";
+
+ return (
+ onSort({ key: sortKey })}>
+ {label} {arrow}
+
+ );
+}
+
+function createElementFromMedia({
+ asset,
+ startTime,
+}: {
+ asset: MediaAsset;
+ startTime: number;
+}): CreateTimelineElement {
+ const duration =
+ asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+
+ switch (asset.type) {
+ case "video":
+ return buildVideoElement({
+ mediaId: asset.id,
+ name: asset.name,
+ duration,
+ startTime,
+ });
+ case "image":
+ return buildImageElement({
+ mediaId: asset.id,
+ name: asset.name,
+ duration,
+ startTime,
+ });
+ case "audio":
+ return buildUploadAudioElement({
+ mediaId: asset.id,
+ name: asset.name,
+ duration,
+ startTime,
+ });
+ default:
+ throw new Error(`Unsupported media type: ${asset.type}`);
+ }
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx
new file mode 100644
index 00000000..b8c54510
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx
@@ -0,0 +1,394 @@
+"use client";
+
+import Image from "next/image";
+import { memo, useCallback, useMemo } from "react";
+import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ BLUR_INTENSITY_PRESETS,
+ DEFAULT_BLUR_INTENSITY,
+ DEFAULT_COLOR,
+ FPS_PRESETS,
+} from "@/constants/project-constants";
+import { patternCraftGradients } from "@/data/colors/pattern-craft";
+import { colors } from "@/data/colors/solid";
+import { syntaxUIGradients } from "@/data/colors/syntax-ui";
+import { useEditor } from "@/hooks/use-editor";
+import { useEditorStore } from "@/stores/editor-store";
+import type { TProject } from "@/types/project";
+import { dimensionToAspectRatio } from "@/utils/geometry";
+import { cn } from "@/utils/ui";
+import {
+ PropertyGroup,
+ PropertyItem,
+ PropertyItemLabel,
+ PropertyItemValue,
+} from "@/components/editor/panels/properties/property-item";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { DropperIcon } from "@hugeicons/core-free-icons";
+
+export function SettingsView() {
+ return ;
+}
+
+function ProjectSettingsTabs() {
+ return (
+
+
+
+ ),
+ },
+ {
+ value: "background",
+ label: "Background",
+ content: (
+
+
+
+
+ {/*
+
+
+ Custom background
+
+
+
*/}
+
+ {/* another ui */}
+ {/*
*/}
+
+ ),
+ },
+ ]}
+ className="flex h-full flex-col justify-between p-0"
+ />
+ );
+}
+
+function getCurrentCanvasSize({ activeProject }: { activeProject: TProject }) {
+ const { canvasSize } = activeProject.settings;
+
+ return {
+ width: canvasSize.width,
+ height: canvasSize.height,
+ };
+}
+
+function ProjectInfoView() {
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+ const { canvasPresets } = useEditorStore();
+
+ const findPresetIndexByAspectRatio = ({
+ presets,
+ targetAspectRatio,
+ }: {
+ presets: Array<{ width: number; height: number }>;
+ targetAspectRatio: string;
+ }) => {
+ for (let index = 0; index < presets.length; index++) {
+ const preset = presets[index];
+ const presetAspectRatio = dimensionToAspectRatio({
+ width: preset.width,
+ height: preset.height,
+ });
+ if (presetAspectRatio === targetAspectRatio) {
+ return index;
+ }
+ }
+ return -1;
+ };
+
+ const currentCanvasSize = getCurrentCanvasSize({ activeProject });
+ const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
+ const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
+ const presetIndex = findPresetIndexByAspectRatio({
+ presets: canvasPresets,
+ targetAspectRatio: currentAspectRatio,
+ });
+ const originalPresetValue = "original";
+ const selectedPresetValue =
+ presetIndex !== -1 ? presetIndex.toString() : originalPresetValue;
+
+ const handleAspectRatioChange = ({ value }: { value: string }) => {
+ if (value === originalPresetValue) {
+ const canvasSize = originalCanvasSize ?? currentCanvasSize;
+ editor.project.updateSettings({
+ settings: { canvasSize },
+ });
+ return;
+ }
+ const index = parseInt(value, 10);
+ const preset = canvasPresets[index];
+ if (preset) {
+ editor.project.updateSettings({ settings: { canvasSize: preset } });
+ }
+ };
+
+ const handleFpsChange = (value: string) => {
+ const fps = parseFloat(value);
+ editor.project.updateSettings({ settings: { fps } });
+ };
+
+ return (
+
+
+ Name
+ {activeProject.metadata.name}
+
+
+
+ Aspect ratio
+
+ handleAspectRatioChange({ value })}
+ >
+
+
+
+
+ Original
+ {canvasPresets.map((preset, index) => {
+ const label = dimensionToAspectRatio({
+ width: preset.width,
+ height: preset.height,
+ });
+ return (
+
+ {label}
+
+ );
+ })}
+
+
+
+
+
+
+ Frame rate
+
+
+
+
+
+
+ {FPS_PRESETS.map((preset) => (
+
+ {preset.label}
+
+ ))}
+
+
+
+
+
+ );
+}
+
+const BlurPreview = memo(
+ ({
+ blur,
+ isSelected,
+ onSelect,
+ }: {
+ blur: { label: string; value: number };
+ isSelected: boolean;
+ onSelect: () => void;
+ }) => (
+
+
+
+
+ {blur.label}
+
+
+
+ ),
+);
+
+BlurPreview.displayName = "BlurPreview";
+
+const BackgroundPreviews = memo(
+ ({
+ backgrounds,
+ currentBackgroundColor,
+ isColorBackground,
+ handleColorSelect,
+ useBackgroundColor = false,
+ }: {
+ backgrounds: string[];
+ currentBackgroundColor: string;
+ isColorBackground: boolean;
+ handleColorSelect: ({ bg }: { bg: string }) => void;
+ useBackgroundColor?: boolean;
+ }) => {
+ return useMemo(
+ () =>
+ backgrounds.map((bg, index) => (
+ handleColorSelect({ bg })}
+ type="button"
+ aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`}
+ />
+ )),
+ [
+ backgrounds,
+ isColorBackground,
+ currentBackgroundColor,
+ handleColorSelect,
+ useBackgroundColor,
+ ],
+ );
+ },
+);
+
+BackgroundPreviews.displayName = "BackgroundPreviews";
+
+function BackgroundView() {
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+ const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
+
+ const handleBlurSelect = useCallback(
+ async ({ blurIntensity }: { blurIntensity: number }) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "blur", blurIntensity } },
+ });
+ },
+ [editor.project],
+ );
+
+ const handleColorSelect = useCallback(
+ async ({ color }: { color: string }) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "color", color } },
+ });
+ },
+ [editor.project],
+ );
+
+ const currentBlurIntensity =
+ activeProject.settings.background.type === "blur"
+ ? activeProject.settings.background.blurIntensity
+ : DEFAULT_BLUR_INTENSITY;
+
+ const currentBackgroundColor =
+ activeProject.settings.background.type === "color"
+ ? activeProject.settings.background.color
+ : DEFAULT_COLOR;
+
+ const isBlurBackground = activeProject.settings.background.type === "blur";
+ const isColorBackground = activeProject.settings.background.type === "color";
+
+ const blurPreviews = useMemo(
+ () =>
+ blurLevels.map((blur) => (
+ handleBlurSelect({ blurIntensity: blur.value })}
+ />
+ )),
+ [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
+ );
+
+ return (
+
+
+ {blurPreviews}
+
+
+
+
+
+
+
+
handleColorSelect({ color: bg })}
+ useBackgroundColor={true}
+ />
+
+
+
+
+
+ handleColorSelect({ color: bg })}
+ />
+
+
+
+
+
+ handleColorSelect({ color: bg })}
+ />
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
new file mode 100644
index 00000000..84a5d0bf
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
@@ -0,0 +1,574 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
+import { useSoundSearch } from "@/hooks/use-sound-search";
+import { useSoundsStore } from "@/stores/sounds-store";
+import type { SavedSound, SoundEffect } from "@/types/sounds";
+import { cn } from "@/utils/ui";
+import {
+ FavouriteIcon,
+ FilterMailIcon,
+ PauseIcon,
+ PlayIcon,
+ PlusSignIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+export function SoundsView() {
+ return (
+
+
+
+
+ Sound effects
+ Songs
+ Saved
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function SoundEffectsView() {
+ const {
+ topSoundEffects,
+ isLoading,
+ searchQuery,
+ setSearchQuery,
+ scrollPosition,
+ setScrollPosition,
+ loadSavedSounds,
+ showCommercialOnly,
+ toggleCommercialFilter,
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ } = useSoundsStore();
+ const {
+ results: searchResults,
+ isLoading: isSearching,
+ loadMore,
+ hasNextPage,
+ isLoadingMore,
+ } = useSoundSearch({
+ query: searchQuery,
+ commercialOnly: showCommercialOnly,
+ });
+
+ const [playingId, setPlayingId] = useState(null);
+ const [audioElement, setAudioElement] = useState(
+ null,
+ );
+
+ const { scrollAreaRef, handleScroll } = useInfiniteScroll({
+ onLoadMore: loadMore,
+ hasMore: hasNextPage,
+ isLoading: isLoadingMore || isSearching,
+ });
+
+ useEffect(() => {
+ loadSavedSounds();
+ }, [loadSavedSounds]);
+
+ useEffect(() => {
+ if (hasLoaded) {
+ return;
+ }
+
+ let shouldIgnore = false;
+
+ const fetchTopSounds = async () => {
+ try {
+ if (!shouldIgnore) {
+ setLoading({ loading: true });
+ setError({ error: null });
+ }
+
+ const response = await fetch(
+ "/api/sounds/search?page_size=50&sort=downloads",
+ );
+
+ if (!shouldIgnore) {
+ if (!response.ok) {
+ throw new Error(`Failed to fetch: ${response.status}`);
+ }
+
+ const data = await response.json();
+ setTopSoundEffects({ sounds: data.results });
+ setHasLoaded({ loaded: true });
+
+ setCurrentPage({ page: 1 });
+ setHasNextPage({ hasNext: !!data.next });
+ setTotalCount({ count: data.count });
+ }
+ } catch (error) {
+ if (!shouldIgnore) {
+ console.error("Failed to fetch top sounds:", error);
+ setError({
+ error:
+ error instanceof Error ? error.message : "Failed to load sounds",
+ });
+ }
+ } finally {
+ if (!shouldIgnore) {
+ setLoading({ loading: false });
+ }
+ }
+ };
+
+ const timeoutId = setTimeout(fetchTopSounds, 100, {});
+
+ return () => {
+ shouldIgnore = true;
+ clearTimeout(timeoutId);
+ };
+ }, [
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ ]);
+
+ useEffect(() => {
+ if (!scrollAreaRef.current || scrollPosition <= 0) {
+ return;
+ }
+
+ const restoreScrollPosition = () => {
+ scrollAreaRef.current?.scrollTo({ top: scrollPosition });
+ };
+
+ const timeoutId = setTimeout(restoreScrollPosition, 100, {});
+
+ return () => clearTimeout(timeoutId);
+ }, [scrollPosition, scrollAreaRef]);
+
+ const handleScrollWithPosition = ({
+ currentTarget,
+ }: React.UIEvent) => {
+ const { scrollTop } = currentTarget;
+ setScrollPosition({ position: scrollTop });
+ handleScroll({ currentTarget } as React.UIEvent);
+ };
+
+ const displayedSounds = searchQuery ? searchResults : topSoundEffects;
+
+ const playSound = ({ sound }: { sound: SoundEffect }) => {
+ if (playingId === sound.id) {
+ audioElement?.pause();
+ setPlayingId(null);
+ return;
+ }
+
+ audioElement?.pause();
+
+ if (sound.previewUrl) {
+ const audio = new Audio(sound.previewUrl);
+ audio.addEventListener("ended", () => {
+ setPlayingId(null);
+ });
+ audio.addEventListener("error", () => {
+ setPlayingId(null);
+ });
+ audio.play().catch((error) => {
+ console.error("Failed to play sound preview:", error);
+ setPlayingId(null);
+ });
+
+ setAudioElement(audio);
+ setPlayingId(sound.id);
+ }
+ };
+
+ return (
+
+
+
+ setSearchQuery({ query: currentTarget.value })
+ }
+ showClearIcon
+ onClear={() => setSearchQuery({ query: "" })}
+ />
+
+
+
+
+
+
+
+ toggleCommercialFilter()}
+ >
+ Show only commercially licensed
+
+
+ {showCommercialOnly
+ ? "Only showing sounds licensed for commercial use"
+ : "Showing all sounds regardless of license"}
+
+
+
+
+
+
+
+
+ {isLoading && !searchQuery && (
+
+ Loading sounds...
+
+ )}
+ {isSearching && searchQuery && (
+
Searching...
+ )}
+ {displayedSounds.map((sound) => (
+
+ ))}
+ {!isLoading && !isSearching && displayedSounds.length === 0 && (
+
+ {searchQuery ? "No sounds found" : "No sounds available"}
+
+ )}
+ {isLoadingMore && (
+
+ Loading more sounds...
+
+ )}
+
+
+
+
+ );
+}
+
+function SavedSoundsView() {
+ const {
+ savedSounds,
+ isLoadingSavedSounds,
+ savedSoundsError,
+ loadSavedSounds,
+ clearSavedSounds,
+ } = useSoundsStore();
+
+ const [playingId, setPlayingId] = useState(null);
+ const [audioElement, setAudioElement] = useState(
+ null,
+ );
+
+ const [showClearDialog, setShowClearDialog] = useState(false);
+
+ useEffect(() => {
+ loadSavedSounds();
+ }, [loadSavedSounds]);
+
+ const playSound = ({ sound }: { sound: SoundEffect }) => {
+ if (playingId === sound.id) {
+ audioElement?.pause();
+ setPlayingId(null);
+ return;
+ }
+
+ audioElement?.pause();
+
+ if (sound.previewUrl) {
+ const audio = new Audio(sound.previewUrl);
+ audio.addEventListener("ended", () => {
+ setPlayingId(null);
+ });
+ audio.addEventListener("error", () => {
+ setPlayingId(null);
+ });
+ audio.play().catch((error) => {
+ console.error("Failed to play sound preview:", error);
+ setPlayingId(null);
+ });
+
+ setAudioElement(audio);
+ setPlayingId(sound.id);
+ }
+ };
+
+ const convertToSoundEffect = ({
+ savedSound,
+ }: {
+ savedSound: SavedSound;
+ }): SoundEffect => ({
+ id: savedSound.id,
+ name: savedSound.name,
+ description: "",
+ url: "",
+ previewUrl: savedSound.previewUrl,
+ downloadUrl: savedSound.downloadUrl,
+ duration: savedSound.duration,
+ filesize: 0,
+ type: "audio",
+ channels: 0,
+ bitrate: 0,
+ bitdepth: 0,
+ samplerate: 0,
+ username: savedSound.username,
+ tags: savedSound.tags,
+ license: savedSound.license,
+ created: savedSound.savedAt,
+ downloads: 0,
+ rating: 0,
+ ratingCount: 0,
+ });
+
+ if (isLoadingSavedSounds) {
+ return (
+
+
+ Loading saved sounds...
+
+
+ );
+ }
+
+ if (savedSoundsError) {
+ return (
+
+
+ Error: {savedSoundsError}
+
+
+ );
+ }
+
+ if (savedSounds.length === 0) {
+ return (
+
+
+
+
No saved sounds
+
+ Click the heart icon on any sound to save it here
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {savedSounds.length} saved{" "}
+ {savedSounds.length === 1 ? "sound" : "sounds"}
+
+
+
+
+ Clear all
+
+
+
+
+ Clear all saved sounds?
+
+ This will permanently remove all {savedSounds.length} saved
+ sounds from your collection. This action cannot be undone.
+
+
+
+ setShowClearDialog(false)}>
+ Cancel
+
+ ) => {
+ stopPropagation();
+ await clearSavedSounds();
+ setShowClearDialog(false);
+ }}
+ >
+ Clear all sounds
+
+
+
+
+
+
+
+
+
+ {savedSounds.map((sound) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+function SongsView() {
+ return Songs
;
+}
+
+interface AudioItemProps {
+ sound: SoundEffect;
+ isPlaying: boolean;
+ onPlay: ({ sound }: { sound: SoundEffect }) => void;
+}
+
+function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) {
+ const { addSoundToTimeline, isSoundSaved, toggleSavedSound } =
+ useSoundsStore();
+ const isSaved = isSoundSaved({ soundId: sound.id });
+
+ const handleClick = () => {
+ onPlay({ sound });
+ };
+
+ const handleSaveClick = ({
+ stopPropagation,
+ }: React.MouseEvent) => {
+ stopPropagation();
+ toggleSavedSound({ soundEffect: sound });
+ };
+
+ const handleAddToTimeline = async ({
+ stopPropagation,
+ }: React.MouseEvent) => {
+ stopPropagation();
+ await addSoundToTimeline({ sound });
+ };
+
+ return (
+
+
+
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+
+
{sound.name}
+
+ {sound.username}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
new file mode 100644
index 00000000..6b48e775
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
@@ -0,0 +1,615 @@
+"use client";
+
+import Image from "next/image";
+import type { CSSProperties } from "react";
+import { useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import { Button } from "@/components/ui/button";
+import { InputWithBack } from "@/components/ui/input-with-back";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { STICKER_CATEGORIES } from "@/constants/stickers-constants";
+import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
+import {
+ buildIconSvgUrl,
+ getIconSvgUrl,
+ ICONIFY_HOSTS,
+ POPULAR_COLLECTIONS,
+} from "@/lib/iconify-api";
+import { useStickersStore } from "@/stores/stickers-store";
+import type { StickerCategory } from "@/types/stickers";
+import { cn } from "@/utils/ui";
+import {
+ ArrowRightIcon,
+ HappyIcon,
+ ClockIcon,
+ LayoutGridIcon,
+ MultiplicationSignIcon,
+ SparklesIcon,
+ HashtagIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Spinner } from "@/components/ui/spinner";
+
+function isStickerCategory(value: string): value is StickerCategory {
+ return STICKER_CATEGORIES.includes(value as StickerCategory);
+}
+
+export function StickersView() {
+ const { selectedCategory, setSelectedCategory } = useStickersStore();
+
+ return (
+ {
+ if (isStickerCategory(v)) {
+ setSelectedCategory({ category: v });
+ }
+ }}
+ tabs={[
+ {
+ value: "all",
+ label: "All",
+ icon: ,
+ content: ,
+ },
+ {
+ value: "general",
+ label: "Icons",
+ icon: ,
+ content: ,
+ },
+ {
+ value: "brands",
+ label: "Brands",
+ icon: ,
+ content: ,
+ },
+ {
+ value: "emoji",
+ label: "Emoji",
+ icon: ,
+ content: ,
+ },
+ ]}
+ className="flex h-full flex-col overflow-hidden p-0"
+ />
+ );
+}
+
+function StickerGrid({
+ icons,
+ onAdd,
+ addingSticker,
+ capSize = false,
+}: {
+ icons: string[];
+ onAdd: (iconName: string) => void;
+ addingSticker: string | null;
+ capSize?: boolean;
+}) {
+ const gridStyle: CSSProperties & {
+ "--sticker-min": string;
+ "--sticker-max"?: string;
+ } = {
+ gridTemplateColumns: capSize
+ ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))"
+ : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))",
+ "--sticker-min": "96px",
+ ...(capSize ? { "--sticker-max": "160px" } : {}),
+ };
+
+ return (
+
+ {icons.map((iconName) => (
+
+ ))}
+
+ );
+}
+
+function CollectionGrid({
+ collections,
+ onSelectCollection,
+}: {
+ collections: Array<{
+ prefix: string;
+ name: string;
+ total: number;
+ category?: string;
+ }>;
+ onSelectCollection: ({ prefix }: { prefix: string }) => void;
+}) {
+ return (
+
+ {collections.map((collection) => (
+ onSelectCollection({ prefix: collection.prefix })}
+ />
+ ))}
+
+ );
+}
+
+function EmptyView({ message }: { message: string }) {
+ return (
+
+
+
+
No stickers found
+
{message}
+
+
+ );
+}
+
+function StickersContentView({ category }: { category: StickerCategory }) {
+ const {
+ searchQuery,
+ selectedCollection,
+ viewMode,
+ collections,
+ currentCollection,
+ searchResults,
+ recentStickers,
+ isLoadingCollections,
+ isLoadingCollection,
+ isSearching,
+ setSearchQuery,
+ setSelectedCollection,
+ loadCollections,
+ searchStickers,
+ addStickerToTimeline,
+ clearRecentStickers,
+ setSelectedCategory,
+ addingSticker,
+ } = useStickersStore();
+
+ const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
+ const [collectionsToShow, setCollectionsToShow] = useState(20);
+ const [showCollectionItems, setShowCollectionItems] = useState(false);
+
+ const filteredCollections = useMemo(() => {
+ if (category === "all") {
+ return Object.entries(collections).map(([prefix, collection]) => ({
+ prefix,
+ name: collection.name,
+ total: collection.total,
+ category: collection.category,
+ }));
+ }
+
+ const collectionList =
+ POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS];
+ if (!collectionList) return [];
+
+ return collectionList
+ .map((c) => {
+ const collection = collections[c.prefix];
+ return collection
+ ? {
+ prefix: c.prefix,
+ name: c.name,
+ total: collection.total,
+ }
+ : null;
+ })
+ .filter(Boolean) as Array<{
+ prefix: string;
+ name: string;
+ total: number;
+ }>;
+ }, [collections, category]);
+
+ const { scrollAreaRef, handleScroll } = useInfiniteScroll({
+ onLoadMore: () => setCollectionsToShow((prev) => prev + 20),
+ hasMore: filteredCollections.length > collectionsToShow,
+ isLoading: isLoadingCollections,
+ enabled: viewMode === "browse" && !selectedCollection && category === "all",
+ });
+
+ useEffect(() => {
+ if (Object.keys(collections).length === 0) {
+ loadCollections();
+ }
+ }, [collections, loadCollections]);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ if (localSearchQuery !== searchQuery) {
+ setSearchQuery({ query: localSearchQuery });
+ if (localSearchQuery.trim()) {
+ searchStickers({ query: localSearchQuery });
+ }
+ }
+ }, 500);
+
+ return () => clearTimeout(timer);
+ }, [localSearchQuery, searchQuery, searchStickers, setSearchQuery]);
+
+ const handleAddSticker = async (iconName: string) => {
+ try {
+ await addStickerToTimeline({ iconName });
+ } catch (error) {
+ console.error("Failed to add sticker:", error);
+ toast.error("Failed to add sticker to timeline");
+ }
+ };
+
+ const iconsToDisplay = useMemo(() => {
+ if (viewMode === "search" && searchResults) {
+ return searchResults.icons;
+ }
+
+ if (viewMode === "collection" && currentCollection) {
+ const icons: string[] = [];
+
+ if (currentCollection.uncategorized) {
+ icons.push(
+ ...currentCollection.uncategorized.map(
+ (name) => `${currentCollection.prefix}:${name}`,
+ ),
+ );
+ }
+
+ if (currentCollection.categories) {
+ Object.values(currentCollection.categories).forEach((categoryIcons) => {
+ icons.push(
+ ...categoryIcons.map(
+ (name) => `${currentCollection.prefix}:${name}`,
+ ),
+ );
+ });
+ }
+
+ return icons.slice(0, 100);
+ }
+
+ return [];
+ }, [viewMode, searchResults, currentCollection]);
+
+ const isInCollection = viewMode === "collection" && !!selectedCollection;
+
+ useEffect(() => {
+ if (isInCollection) {
+ setShowCollectionItems(false);
+ const timer = setTimeout(() => setShowCollectionItems(true), 350);
+ return () => clearTimeout(timer);
+ } else {
+ setShowCollectionItems(false);
+ }
+ }, [isInCollection]);
+
+ return (
+
+
+ {
+ if (!expanded && isInCollection) {
+ setSelectedCollection({ collection: null });
+ }
+ }}
+ placeholder={
+ category === "all"
+ ? "Search all stickers"
+ : category === "general"
+ ? "Search icons"
+ : category === "brands"
+ ? "Search brands"
+ : "Search Emojis"
+ }
+ value={localSearchQuery}
+ onChange={setLocalSearchQuery}
+ disableAnimation={true}
+ />
+
+
+
+
+
+ {recentStickers.length > 0 && viewMode === "browse" && (
+
+
+
+
Recent
+
+
+
+
+
+
+
+
+ Clear recent stickers
+
+
+
+
+
+
+ )}
+
+ {viewMode === "collection" && selectedCollection && (
+
+ {isLoadingCollection ? (
+
+
+
+ ) : showCollectionItems ? (
+
+ ) : (
+
+
+
+ )}
+
+ )}
+
+ {viewMode === "search" && (
+
+ {isSearching ? (
+
+
+
+ ) : searchResults?.icons.length ? (
+ <>
+
+
+ {searchResults.total} results
+
+
+
+ >
+ ) : searchQuery ? (
+
+
+ {category !== "all" && (
+ {
+ const q = localSearchQuery || searchQuery;
+ if (q) {
+ setSearchQuery({ query: q });
+ }
+ setSelectedCategory({ category: "all" });
+ if (q) {
+ searchStickers({ query: q });
+ }
+ }}
+ >
+ Search in all icons
+
+ )}
+
+ ) : null}
+
+ )}
+
+ {viewMode === "browse" && !selectedCollection && (
+
+ {isLoadingCollections ? (
+
+
+
+ ) : (
+ <>
+ {category !== "all" && (
+
+
+ setSelectedCollection({ collection: prefix })
+ }
+ />
+
+ )}
+
+ {category === "all" && filteredCollections.length > 0 && (
+
+
+ setSelectedCollection({ collection: prefix })
+ }
+ />
+
+ )}
+ >
+ )}
+
+ )}
+
+
+
+
+ );
+}
+
+interface CollectionItemProps {
+ title: string;
+ subtitle: string;
+ onClick: () => void;
+}
+
+function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) {
+ return (
+
+
+
+
+ );
+}
+
+interface StickerItemProps {
+ iconName: string;
+ onAdd: (iconName: string) => void;
+ isAdding?: boolean;
+ capSize?: boolean;
+}
+
+function StickerItem({
+ iconName,
+ onAdd,
+ isAdding,
+ capSize = false,
+}: StickerItemProps) {
+ const [imageError, setImageError] = useState(false);
+ const [hostIndex, setHostIndex] = useState(0);
+
+ useEffect(() => {
+ if (!iconName) {
+ return;
+ }
+ setImageError(false);
+ setHostIndex(0);
+ }, [iconName]);
+
+ const displayName = iconName.split(":")[1] || iconName;
+ const collectionPrefix = iconName.split(":")[0];
+
+ const preview = imageError ? (
+
+
+ {displayName}
+
+
+ ) : (
+
+ {
+ const next = hostIndex + 1;
+ if (next < ICONIFY_HOSTS.length) {
+ setHostIndex(next);
+ } else {
+ setImageError(true);
+ }
+ }}
+ loading="lazy"
+ unoptimized
+ />
+
+ );
+
+ return (
+
+
+
+
onAdd(iconName)}
+ aspectRatio={1}
+ shouldShowLabel={false}
+ isRounded={true}
+ variant="card"
+ className=""
+ containerClassName="w-full"
+ />
+ {isAdding && (
+
+
+
+ )}
+
+
+
+
+
{displayName}
+
{collectionPrefix}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx
new file mode 100644
index 00000000..6ccf5d98
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/text.tsx
@@ -0,0 +1,46 @@
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
+import { buildTextElement } from "@/lib/timeline/element-utils";
+
+export function TextView() {
+ const editor = useEditor();
+
+ const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => {
+ const activeScene = editor.scenes.getActiveScene();
+ if (!activeScene) return;
+
+ const element = buildTextElement({
+ raw: DEFAULT_TEXT_ELEMENT,
+ startTime: currentTime,
+ });
+
+ editor.timeline.insertElement({
+ element,
+ placement: { mode: "auto" },
+ });
+ };
+
+ return (
+
+
+ Default text
+
+ }
+ dragData={{
+ id: "temp-text-id",
+ type: DEFAULT_TEXT_ELEMENT.type,
+ name: DEFAULT_TEXT_ELEMENT.name,
+ content: DEFAULT_TEXT_ELEMENT.content,
+ }}
+ aspectRatio={1}
+ onAddToTimeline={handleAddToTimeline}
+ shouldShowLabel={false}
+ />
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/panel-base-view.tsx b/apps/web/src/components/editor/panels/panel-base-view.tsx
new file mode 100644
index 00000000..b16d1db6
--- /dev/null
+++ b/apps/web/src/components/editor/panels/panel-base-view.tsx
@@ -0,0 +1,85 @@
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { cn } from "@/utils/ui";
+
+interface PanelBaseViewProps {
+ children?: React.ReactNode;
+ defaultTab?: string;
+ value?: string;
+ onValueChange?: (value: string) => void;
+ tabs?: {
+ value: string;
+ label: string;
+ icon?: React.ReactNode;
+ content: React.ReactNode;
+ }[];
+ className?: string;
+ ref?: React.RefObject;
+}
+
+function ViewContent({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function PanelBaseView({
+ children,
+ defaultTab,
+ value,
+ onValueChange,
+ tabs,
+ className = "",
+ ref,
+}: PanelBaseViewProps) {
+ return (
+
+ {!tabs || tabs.length === 0 ? (
+
{children}
+ ) : (
+
+
+
+
+ {tabs.map((tab) => (
+
+ {tab.icon ? (
+
+ {tab.icon}
+
+ ) : null}
+ {tab.label}
+
+ ))}
+
+
+
+
+ {tabs.map((tab) => (
+
+ {tab.content}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx
new file mode 100644
index 00000000..5ed9db00
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/index.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import { useCallback, useMemo, useRef } from "react";
+import useDeepCompareEffect from "use-deep-compare-effect";
+import { useEditor } from "@/hooks/use-editor";
+import { useRafLoop } from "@/hooks/use-raf-loop";
+import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
+import type { RootNode } from "@/services/renderer/nodes/root-node";
+import { buildScene } from "@/services/renderer/scene-builder";
+import { getLastFrameTime } from "@/lib/time";
+
+function usePreviewSize() {
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+
+ return {
+ width: activeProject?.settings.canvasSize.width,
+ height: activeProject?.settings.canvasSize.height,
+ };
+}
+
+function RenderTreeController() {
+ const editor = useEditor();
+ const tracks = editor.timeline.getTracks();
+ const mediaAssets = editor.media.getAssets();
+ const activeProject = editor.project.getActive();
+
+ const { width, height } = usePreviewSize();
+
+ useDeepCompareEffect(() => {
+ if (!activeProject) return;
+
+ const duration = editor.timeline.getTotalDuration();
+ const renderTree = buildScene({
+ tracks,
+ mediaAssets,
+ duration,
+ canvasSize: { width, height },
+ background: activeProject.settings.background,
+ });
+
+ editor.renderer.setRenderTree({ renderTree });
+ }, [tracks, mediaAssets, activeProject?.settings.background, width, height]);
+
+ return null;
+}
+
+export function PreviewPanel() {
+ return (
+
+ );
+}
+
+function PreviewCanvas() {
+ const ref = useRef(null);
+ const lastFrameRef = useRef(-1);
+ const lastSceneRef = useRef(null);
+ const renderingRef = useRef(false);
+ const { width, height } = usePreviewSize();
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+
+ const renderer = useMemo(() => {
+ return new CanvasRenderer({
+ width,
+ height,
+ fps: activeProject.settings.fps,
+ });
+ }, [width, height, activeProject.settings.fps]);
+
+ const renderTree = editor.renderer.getRenderTree();
+
+ const render = useCallback(() => {
+ if (ref.current && renderTree && !renderingRef.current) {
+ const time = editor.playback.getCurrentTime();
+ const lastFrameTime = getLastFrameTime({
+ duration: renderTree.duration,
+ fps: renderer.fps,
+ });
+ const renderTime = Math.min(time, lastFrameTime);
+ const frame = Math.floor(renderTime * renderer.fps);
+
+ if (
+ frame !== lastFrameRef.current ||
+ renderTree !== lastSceneRef.current
+ ) {
+ renderingRef.current = true;
+ lastSceneRef.current = renderTree;
+ lastFrameRef.current = frame;
+ renderer
+ .renderToCanvas({
+ node: renderTree,
+ time: renderTime,
+ targetCanvas: ref.current,
+ })
+ .then(() => {
+ renderingRef.current = false;
+ });
+ }
+ }
+ }, [renderer, renderTree, editor.playback]);
+
+ useRafLoop(render);
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/audio-properties.tsx b/apps/web/src/components/editor/panels/properties/audio-properties.tsx
new file mode 100644
index 00000000..0b83a4b6
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/audio-properties.tsx
@@ -0,0 +1,5 @@
+import type { AudioElement } from "@/types/timeline";
+
+export function AudioProperties({ _element }: { _element: AudioElement }) {
+ return Audio properties
;
+}
diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx
new file mode 100644
index 00000000..3a552b93
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/index.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { AudioProperties } from "./audio-properties";
+import { VideoProperties } from "./video-properties";
+import { TextProperties } from "./text-properties";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Settings05Icon } from "@hugeicons/core-free-icons";
+import { useEditor } from "@/hooks/use-editor";
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+
+export function PropertiesPanel() {
+ const editor = useEditor();
+ const { selectedElements } = useElementSelection();
+
+ const elementsWithTracks = editor.timeline.getElementsWithTracks({
+ elements: selectedElements,
+ });
+
+ return (
+ <>
+ {selectedElements.length > 0 ? (
+
+ {elementsWithTracks.map(({ track, element }) => {
+ if (element.type === "text") {
+ return (
+
+
+
+ );
+ }
+ if (element.type === "audio") {
+ return ;
+ }
+ if (element.type === "video" || element.type === "image") {
+ return (
+
+
+
+ );
+ }
+ return null;
+ })}
+
+ ) : (
+
+ )}
+ >
+ );
+}
+
+function EmptyView() {
+ return (
+
+
+
+
It's empty here
+
+ Click an element on the timeline to edit its properties
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/property-item.tsx b/apps/web/src/components/editor/panels/properties/property-item.tsx
new file mode 100644
index 00000000..6d2fe444
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/property-item.tsx
@@ -0,0 +1,91 @@
+import { useState } from "react";
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { ArrowDownIcon } from "@hugeicons/core-free-icons";
+
+interface PropertyItemProps {
+ direction?: "row" | "column";
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function PropertyItem({
+ direction = "row",
+ children,
+ className,
+}: PropertyItemProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function PropertyItemLabel({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function PropertyItemValue({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return {children}
;
+}
+
+interface PropertyGroupProps {
+ title: string;
+ children: React.ReactNode;
+ defaultExpanded?: boolean;
+ className?: string;
+ titleClassName?: string;
+}
+
+export function PropertyGroup({
+ title,
+ children,
+ defaultExpanded = true,
+ className,
+ titleClassName,
+}: PropertyGroupProps) {
+ const [isExpanded, setIsExpanded] = useState(defaultExpanded);
+
+ return (
+
+ setIsExpanded(!isExpanded)}
+ >
+
+ {title}
+
+
+
+ {isExpanded && {children} }
+
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx
new file mode 100644
index 00000000..f8c9bf87
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx
@@ -0,0 +1,410 @@
+import { Textarea } from "@/components/ui/textarea";
+import { FontPicker } from "@/components/ui/font-picker";
+import type { FontFamily } from "@/constants/font-constants";
+import type { TextElement } from "@/types/timeline";
+import { Slider } from "@/components/ui/slider";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { useState, useRef } from "react";
+import { PanelBaseView } from "@/components/editor/panels/panel-base-view";
+import {
+ TEXT_PROPERTIES_TABS,
+ isTextPropertiesTab,
+ useTextPropertiesStore,
+} from "@/stores/text-properties-store";
+import {
+ PropertyItem,
+ PropertyItemLabel,
+ PropertyItemValue,
+} from "./property-item";
+import { ColorPicker } from "@/components/ui/color-picker";
+import { cn } from "@/utils/ui";
+import { capitalizeFirstLetter, uppercase } from "@/utils/string";
+import { clamp } from "@/utils/math";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { LayoutGridIcon } from "@hugeicons/core-free-icons";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULT_COLOR } from "@/constants/project-constants";
+
+export function TextProperties({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const { activeTab, setActiveTab } = useTextPropertiesStore();
+ const containerRef = useRef(null);
+ const [fontSizeInput, setFontSizeInput] = useState(
+ element.fontSize.toString(),
+ );
+ const [opacityInput, setOpacityInput] = useState(
+ Math.round(element.opacity * 100).toString(),
+ );
+
+ const lastSelectedColor = useRef(DEFAULT_COLOR);
+
+ const handleFontSizeChange = ({ value }: { value: string }) => {
+ setFontSizeInput(value);
+
+ if (value.trim() !== "") {
+ const parsed = parseInt(value, 10);
+ const fontSize = Number.isNaN(parsed)
+ ? element.fontSize
+ : clamp({ value: parsed, min: 8, max: 300 });
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: { fontSize },
+ });
+ }
+ };
+
+ const handleFontSizeBlur = () => {
+ const parsed = parseInt(fontSizeInput, 10);
+ const fontSize = Number.isNaN(parsed)
+ ? element.fontSize
+ : clamp({ value: parsed, min: 8, max: 300 });
+ setFontSizeInput(fontSize.toString());
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: { fontSize },
+ });
+ };
+
+ const handleOpacityChange = ({ value }: { value: string }) => {
+ setOpacityInput(value);
+
+ if (value.trim() !== "") {
+ const parsed = parseInt(value, 10);
+ const opacityPercent = Number.isNaN(parsed)
+ ? Math.round(element.opacity * 100)
+ : clamp({ value: parsed, min: 0, max: 100 });
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: { opacity: opacityPercent / 100 },
+ });
+ }
+ };
+
+ const handleOpacityBlur = () => {
+ const parsed = parseInt(opacityInput, 10);
+ const opacityPercent = Number.isNaN(parsed)
+ ? Math.round(element.opacity * 100)
+ : clamp({ value: parsed, min: 0, max: 100 });
+ setOpacityInput(opacityPercent.toString());
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: { opacity: opacityPercent / 100 },
+ });
+ };
+
+ const handleColorChange = ({ color }: { color: string }) => {
+ if (color !== "transparent") {
+ lastSelectedColor.current = color;
+ }
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: { backgroundColor: color },
+ });
+ };
+
+ const handleTransparentToggle = ({
+ isTransparent,
+ }: {
+ isTransparent: boolean;
+ }) => {
+ const newColor = isTransparent ? "transparent" : lastSelectedColor.current;
+ editor.timeline.updateTextElement({
+ trackId,
+ elementId: element.id,
+ updates: { backgroundColor: newColor },
+ });
+ };
+
+ return (
+ {
+ if (isTextPropertiesTab(v)) setActiveTab(v);
+ }}
+ ref={containerRef}
+ tabs={TEXT_PROPERTIES_TABS.map((t) => ({
+ value: t.value,
+ label: t.label,
+ content:
+ t.value === "transform" ? (
+
+ ) : (
+
+ ),
+ }))}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/video-properties.tsx b/apps/web/src/components/editor/panels/properties/video-properties.tsx
new file mode 100644
index 00000000..bf6d92d8
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/video-properties.tsx
@@ -0,0 +1,9 @@
+import type { ImageElement, VideoElement } from "@/types/timeline";
+
+export function VideoProperties({
+ _element,
+}: {
+ _element: VideoElement | ImageElement;
+}) {
+ return Video properties
;
+}
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
deleted file mode 100644
index 36f6a2ba..00000000
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ /dev/null
@@ -1,1134 +0,0 @@
-"use client";
-
-import { useTimelineStore } from "@/stores/timeline-store";
-import { TimelineElement, TimelineTrack } from "@/types/timeline";
-import { useMediaStore } from "@/stores/media-store";
-import { MediaFile } from "@/types/media";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { useEditorStore } from "@/stores/editor-store";
-import { Button } from "@/components/ui/button";
-import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react";
-import { useState, useRef, useEffect, useCallback } from "react";
-import { renderTimelineFrame } from "@/lib/timeline-renderer";
-import { cn } from "@/lib/utils";
-import { formatTimeCode } from "@/lib/time";
-import { EditableTimecode } from "@/components/ui/editable-timecode";
-import { useFrameCache } from "@/hooks/use-frame-cache";
-import { useSceneStore } from "@/stores/scene-store";
-import {
- DEFAULT_CANVAS_SIZE,
- DEFAULT_FPS,
- useProjectStore,
-} from "@/stores/project-store";
-import { TextElementDragState } from "@/types/editor";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-import { Checkbox } from "@/components/ui/checkbox";
-import { LayoutGuideOverlay } from "./layout-guide-overlay";
-import { Label } from "../ui/label";
-import { SocialsIcon } from "../icons";
-import { PLATFORM_LAYOUTS, type PlatformLayout } from "@/stores/editor-store";
-
-interface ActiveElement {
- element: TimelineElement;
- track: TimelineTrack;
- mediaItem: MediaFile | null;
-}
-
-export function PreviewPanel() {
- const { tracks, getTotalDuration, updateTextElement } = useTimelineStore();
- const { mediaFiles } = useMediaStore();
- const { currentTime, toggle, setCurrentTime } = usePlaybackStore();
- const { isPlaying, volume, muted } = usePlaybackStore();
- const { activeProject } = useProjectStore();
- const { currentScene } = useSceneStore();
- const previewRef = useRef(null);
- const canvasRef = useRef(null);
- const { getCachedFrame, cacheFrame, invalidateCache, preRenderNearbyFrames } =
- useFrameCache();
- const lastFrameTimeRef = useRef(0);
- const renderSeqRef = useRef(0);
- const offscreenCanvasRef = useRef(
- null
- );
-
- const audioContextRef = useRef(null);
- const audioGainRef = useRef(null);
- const audioBuffersRef = useRef>(new Map());
- const playingSourcesRef = useRef>(new Set());
- const containerRef = useRef(null);
- const [previewDimensions, setPreviewDimensions] = useState({
- width: 0,
- height: 0,
- });
- const [isExpanded, setIsExpanded] = useState(false);
-
- const canvasSize = activeProject?.canvasSize || DEFAULT_CANVAS_SIZE;
- const [dragState, setDragState] = useState({
- isDragging: false,
- elementId: null,
- trackId: null,
- startX: 0,
- startY: 0,
- initialElementX: 0,
- initialElementY: 0,
- currentX: 0,
- currentY: 0,
- elementWidth: 0,
- elementHeight: 0,
- });
-
- useEffect(() => {
- const updatePreviewSize = () => {
- if (!containerRef.current) return;
-
- let availableWidth, availableHeight;
-
- if (isExpanded) {
- const controlsHeight = 80;
- const marginSpace = 24;
- availableWidth = window.innerWidth - marginSpace;
- availableHeight = window.innerHeight - controlsHeight - marginSpace;
- } else {
- const container = containerRef.current.getBoundingClientRect();
- const computedStyle = getComputedStyle(containerRef.current);
- const paddingTop = parseFloat(computedStyle.paddingTop);
- const paddingBottom = parseFloat(computedStyle.paddingBottom);
- const paddingLeft = parseFloat(computedStyle.paddingLeft);
- const paddingRight = parseFloat(computedStyle.paddingRight);
- const gap = parseFloat(computedStyle.gap) || 16;
- const toolbar = containerRef.current.querySelector("[data-toolbar]");
- const toolbarHeight = toolbar
- ? toolbar.getBoundingClientRect().height
- : 0;
-
- availableWidth = container.width - paddingLeft - paddingRight;
- availableHeight =
- container.height -
- paddingTop -
- paddingBottom -
- toolbarHeight -
- (toolbarHeight > 0 ? gap : 0);
- }
-
- const targetRatio = canvasSize.width / canvasSize.height;
- const containerRatio = availableWidth / availableHeight;
- let width, height;
-
- if (containerRatio > targetRatio) {
- height = availableHeight * (isExpanded ? 0.95 : 1);
- width = height * targetRatio;
- } else {
- width = availableWidth * (isExpanded ? 0.95 : 1);
- height = width / targetRatio;
- }
-
- setPreviewDimensions({ width, height });
- };
-
- updatePreviewSize();
- const resizeObserver = new ResizeObserver(updatePreviewSize);
- if (containerRef.current) {
- resizeObserver.observe(containerRef.current);
- }
- if (isExpanded) {
- window.addEventListener("resize", updatePreviewSize);
- }
-
- return () => {
- resizeObserver.disconnect();
- if (isExpanded) {
- window.removeEventListener("resize", updatePreviewSize);
- }
- };
- }, [canvasSize.width, canvasSize.height, isExpanded]);
-
- useEffect(() => {
- const handleEscapeKey = (event: KeyboardEvent) => {
- if (event.key === "Escape" && isExpanded) {
- setIsExpanded(false);
- }
- };
-
- if (isExpanded) {
- document.addEventListener("keydown", handleEscapeKey);
- document.body.style.overflow = "hidden";
- } else {
- document.body.style.overflow = "";
- }
-
- return () => {
- document.removeEventListener("keydown", handleEscapeKey);
- document.body.style.overflow = "";
- };
- }, [isExpanded]);
-
- useEffect(() => {
- const handleMouseMove = (e: MouseEvent) => {
- if (!dragState.isDragging) return;
-
- const deltaX = e.clientX - dragState.startX;
- const deltaY = e.clientY - dragState.startY;
-
- const scaleRatio = previewDimensions.width / canvasSize.width;
- const newX = dragState.initialElementX + deltaX / scaleRatio;
- const newY = dragState.initialElementY + deltaY / scaleRatio;
-
- const halfWidth = dragState.elementWidth / scaleRatio / 2;
- const halfHeight = dragState.elementHeight / scaleRatio / 2;
-
- const constrainedX = Math.max(
- -canvasSize.width / 2 + halfWidth,
- Math.min(canvasSize.width / 2 - halfWidth, newX)
- );
- const constrainedY = Math.max(
- -canvasSize.height / 2 + halfHeight,
- Math.min(canvasSize.height / 2 - halfHeight, newY)
- );
-
- setDragState((prev) => ({
- ...prev,
- currentX: constrainedX,
- currentY: constrainedY,
- }));
- };
-
- const handleMouseUp = () => {
- if (dragState.isDragging && dragState.trackId && dragState.elementId) {
- updateTextElement(dragState.trackId, dragState.elementId, {
- x: dragState.currentX,
- y: dragState.currentY,
- });
- }
- setDragState((prev) => ({ ...prev, isDragging: false }));
- };
-
- if (dragState.isDragging) {
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
- document.body.style.cursor = "grabbing";
- document.body.style.userSelect = "none";
- }
-
- return () => {
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- document.body.style.cursor = "";
- document.body.style.userSelect = "";
- };
- }, [dragState, previewDimensions, canvasSize, updateTextElement]);
-
- // Clear the frame cache when background settings change since they affect rendering
- useEffect(() => {
- invalidateCache();
- }, [
- mediaFiles,
- activeProject?.backgroundColor,
- activeProject?.backgroundType,
- invalidateCache,
- ]);
-
- const handleTextMouseDown = (
- e: React.MouseEvent,
- element: any,
- trackId: string
- ) => {
- e.preventDefault();
- e.stopPropagation();
-
- const rect = e.currentTarget.getBoundingClientRect();
-
- setDragState({
- isDragging: true,
- elementId: element.id,
- trackId,
- startX: e.clientX,
- startY: e.clientY,
- initialElementX: element.x,
- initialElementY: element.y,
- currentX: element.x,
- currentY: element.y,
- elementWidth: rect.width,
- elementHeight: rect.height,
- });
- };
-
- const toggleExpanded = useCallback(() => {
- setIsExpanded((prev) => !prev);
- }, []);
-
- const hasAnyElements = tracks.some((track) => track.elements.length > 0);
- const shouldRenderPreview = hasAnyElements || activeProject?.backgroundType;
- const getActiveElements = (): ActiveElement[] => {
- const activeElements: ActiveElement[] = [];
-
- // Iterate tracks from bottom to top so topmost track renders last (on top)
- [...tracks].reverse().forEach((track) => {
- track.elements.forEach((element) => {
- if (element.hidden) return;
- const elementStart = element.startTime;
- const elementEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (currentTime >= elementStart && currentTime < elementEnd) {
- let mediaItem = null;
- if (element.type === "media") {
- mediaItem =
- element.mediaId === "test"
- ? null
- : mediaFiles.find((item) => item.id === element.mediaId) ||
- null;
- }
- activeElements.push({ element, track, mediaItem });
- }
- });
- });
-
- return activeElements;
- };
-
- const activeElements = getActiveElements();
-
- // Ensure first frame after mount/seek renders immediately
- useEffect(() => {
- const onSeek = () => {
- lastFrameTimeRef.current = -Infinity;
- renderSeqRef.current++;
- };
- window.addEventListener("playback-seek", onSeek as EventListener);
- lastFrameTimeRef.current = -Infinity;
- return () => {
- window.removeEventListener("playback-seek", onSeek as EventListener);
- };
- }, []);
-
- // Web Audio: schedule only on play/pause/seek/volume/mute changes
- useEffect(() => {
- const stopAll = () => {
- for (const src of playingSourcesRef.current) {
- try {
- src.stop();
- } catch {}
- }
- playingSourcesRef.current.clear();
- };
-
- type WebAudioWindow = Window & {
- AudioContext?: typeof AudioContext;
- webkitAudioContext?: typeof AudioContext;
- };
- const ensureAudioGraph = async () => {
- if (!audioContextRef.current) {
- const win = window as WebAudioWindow;
- const Ctx = win.AudioContext ?? win.webkitAudioContext;
- if (!Ctx) return;
- audioContextRef.current = new Ctx();
- }
- if (!audioGainRef.current) {
- audioGainRef.current = audioContextRef.current!.createGain();
- audioGainRef.current.connect(audioContextRef.current!.destination);
- }
- if (audioContextRef.current!.state === "suspended") {
- try {
- await audioContextRef.current!.resume();
- } catch {}
- }
- const gainValue = muted ? 0 : Math.max(0, Math.min(1, volume));
- audioGainRef.current!.gain.setValueAtTime(
- gainValue,
- audioContextRef.current!.currentTime
- );
- };
-
- const scheduleNow = async () => {
- await ensureAudioGraph();
- const audioCtx = audioContextRef.current!;
- const gain = audioGainRef.current!;
-
- const tracksSnapshot = useTimelineStore.getState().tracks;
- const mediaList = mediaFiles;
- const idToMedia = new Map(mediaList.map((m) => [m.id, m] as const));
- const playbackNow = usePlaybackStore.getState().currentTime;
-
- const audible: Array<{
- id: string;
- elementStart: number;
- trimStart: number;
- trimEnd: number;
- duration: number;
- muted: boolean;
- trackMuted: boolean;
- }> = [];
- const uniqueIds = new Set();
- for (const track of tracksSnapshot) {
- for (const element of track.elements) {
- if (element.type !== "media") continue;
- const media = idToMedia.get(element.mediaId);
- if (!media || media.type !== "audio") continue;
- const visibleDuration =
- element.duration - element.trimStart - element.trimEnd;
- if (visibleDuration <= 0) continue;
- const localTime = playbackNow - element.startTime + element.trimStart;
- if (localTime < 0 || localTime >= visibleDuration) continue;
- audible.push({
- id: media.id,
- elementStart: element.startTime,
- trimStart: element.trimStart,
- trimEnd: element.trimEnd,
- duration: element.duration,
- muted: !!element.muted,
- trackMuted: !!track.muted,
- });
- uniqueIds.add(media.id);
- }
- }
-
- if (audible.length === 0) return;
-
- // Decode buffers as needed
- const decodePromises: Array> = [];
- for (const id of uniqueIds) {
- if (!audioBuffersRef.current.has(id)) {
- const mediaItem = idToMedia.get(id);
- if (!mediaItem) continue;
- const p = (async () => {
- const arr = await mediaItem.file.arrayBuffer();
- const buf = await audioCtx.decodeAudioData(arr.slice(0));
- audioBuffersRef.current.set(id, buf);
- })();
- decodePromises.push(p);
- }
- }
- await Promise.all(decodePromises);
-
- const startAt = audioCtx.currentTime + 0.02;
- for (const entry of audible) {
- if (entry.muted || entry.trackMuted) continue;
- const buffer = audioBuffersRef.current.get(entry.id);
- if (!buffer) continue;
- const visibleDuration =
- entry.duration - entry.trimStart - entry.trimEnd;
- const localTime = Math.max(
- 0,
- playbackNow - entry.elementStart + entry.trimStart
- );
- const playDuration = Math.max(0, visibleDuration - localTime);
- if (playDuration <= 0) continue;
- const src = audioCtx.createBufferSource();
- src.buffer = buffer;
- src.connect(gain);
- try {
- src.start(startAt, localTime, playDuration);
- playingSourcesRef.current.add(src);
- } catch {}
- }
- };
-
- const onSeek = () => {
- if (!isPlaying) return;
- for (const src of playingSourcesRef.current) {
- try {
- src.stop();
- } catch {}
- }
- playingSourcesRef.current.clear();
- void scheduleNow();
- };
-
- // Apply volume/mute changes immediately
- void ensureAudioGraph();
-
- // Start/stop on play state changes
- for (const src of playingSourcesRef.current) {
- try {
- src.stop();
- } catch {}
- }
- playingSourcesRef.current.clear();
- if (isPlaying) {
- void scheduleNow();
- }
-
- window.addEventListener("playback-seek", onSeek as EventListener);
- return () => {
- window.removeEventListener("playback-seek", onSeek as EventListener);
- for (const src of playingSourcesRef.current) {
- try {
- src.stop();
- } catch {}
- }
- playingSourcesRef.current.clear();
- };
- }, [isPlaying, volume, muted, mediaFiles]);
-
- // Canvas: draw current frame with caching
- useEffect(() => {
- const draw = async () => {
- const canvas = canvasRef.current;
- if (!canvas) return;
- const mainCtx = canvas.getContext("2d");
- if (!mainCtx) return;
-
- // Set canvas internal resolution to avoid blurry scaling
- const displayWidth = Math.max(1, Math.floor(previewDimensions.width));
- const displayHeight = Math.max(1, Math.floor(previewDimensions.height));
- if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
- canvas.width = displayWidth;
- canvas.height = displayHeight;
- }
-
- // Throttle rendering to project FPS during playback only
- const fps = activeProject?.fps || DEFAULT_FPS;
- const minDelta = 1 / fps;
- if (isPlaying) {
- if (currentTime - lastFrameTimeRef.current < minDelta) {
- return;
- }
- lastFrameTimeRef.current = currentTime;
- }
-
- const cachedFrame = getCachedFrame(
- currentTime,
- tracks,
- mediaFiles,
- activeProject,
- currentScene?.id
- );
- if (cachedFrame) {
- mainCtx.putImageData(cachedFrame, 0, 0);
-
- // Pre-render nearby frames in background
- if (!isPlaying) {
- // Only during scrubbing to avoid interfering with playback
- preRenderNearbyFrames(
- currentTime,
- tracks,
- mediaFiles,
- activeProject,
- async (time: number) => {
- const tempCanvas = document.createElement("canvas");
- tempCanvas.width = displayWidth;
- tempCanvas.height = displayHeight;
- const tempCtx = tempCanvas.getContext("2d");
- if (!tempCtx)
- throw new Error("Failed to create temp canvas context");
-
- await renderTimelineFrame({
- ctx: tempCtx,
- time,
- canvasWidth: displayWidth,
- canvasHeight: displayHeight,
- tracks,
- mediaFiles,
- backgroundType: activeProject?.backgroundType,
- blurIntensity: activeProject?.blurIntensity,
- backgroundColor:
- activeProject?.backgroundType === "blur"
- ? undefined
- : activeProject?.backgroundColor || "#000000",
- projectCanvasSize: canvasSize,
- });
-
- return tempCtx.getImageData(0, 0, displayWidth, displayHeight);
- },
- currentScene?.id,
- 3
- );
- } else {
- // Small lookahead while playing
- preRenderNearbyFrames(
- currentTime,
- tracks,
- mediaFiles,
- activeProject,
- async (time: number) => {
- const tempCanvas = document.createElement("canvas");
- tempCanvas.width = displayWidth;
- tempCanvas.height = displayHeight;
- const tempCtx = tempCanvas.getContext("2d");
- if (!tempCtx)
- throw new Error("Failed to create temp canvas context");
-
- await renderTimelineFrame({
- ctx: tempCtx,
- time,
- canvasWidth: displayWidth,
- canvasHeight: displayHeight,
- tracks,
- mediaFiles,
- backgroundType: activeProject?.backgroundType,
- blurIntensity: activeProject?.blurIntensity,
- backgroundColor:
- activeProject?.backgroundType === "blur"
- ? undefined
- : activeProject?.backgroundColor || "#000000",
- projectCanvasSize: canvasSize,
- });
-
- return tempCtx.getImageData(0, 0, displayWidth, displayHeight);
- },
- currentScene?.id,
- 1
- );
- }
- return;
- }
-
- // Cache miss - render from scratch
- if (!offscreenCanvasRef.current) {
- const hasOffscreen =
- typeof (globalThis as unknown as { OffscreenCanvas?: unknown })
- .OffscreenCanvas !== "undefined";
- if (hasOffscreen) {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- offscreenCanvasRef.current = new (globalThis as any).OffscreenCanvas(
- displayWidth,
- displayHeight
- ) as OffscreenCanvas;
- } else {
- const c = document.createElement("canvas");
- c.width = displayWidth;
- c.height = displayHeight;
- offscreenCanvasRef.current = c;
- }
- }
- // Ensure size matches
- if (
- offscreenCanvasRef.current &&
- (offscreenCanvasRef.current as HTMLCanvasElement).getContext
- ) {
- const c = offscreenCanvasRef.current as HTMLCanvasElement;
- if (c.width !== displayWidth || c.height !== displayHeight) {
- c.width = displayWidth;
- c.height = displayHeight;
- }
- } else {
- const c = offscreenCanvasRef.current as OffscreenCanvas;
- // @ts-ignore width/height exist on OffscreenCanvas in modern browsers
- if (
- (c as unknown as { width: number }).width !== displayWidth ||
- (c as unknown as { height: number }).height !== displayHeight
- ) {
- // @ts-ignore
- (c as unknown as { width: number }).width = displayWidth;
- // @ts-ignore
- (c as unknown as { height: number }).height = displayHeight;
- }
- }
- const offscreenCanvas = offscreenCanvasRef.current as
- | HTMLCanvasElement
- | OffscreenCanvas;
- const offCtx = (offscreenCanvas as HTMLCanvasElement).getContext
- ? (offscreenCanvas as HTMLCanvasElement).getContext("2d")
- : (offscreenCanvas as OffscreenCanvas).getContext("2d");
- if (!offCtx) return;
-
- await renderTimelineFrame({
- ctx: offCtx as CanvasRenderingContext2D,
- time: currentTime,
- canvasWidth: displayWidth,
- canvasHeight: displayHeight,
- tracks,
- mediaFiles,
- backgroundType: activeProject?.backgroundType,
- blurIntensity: activeProject?.blurIntensity,
- backgroundColor:
- activeProject?.backgroundType === "blur"
- ? undefined
- : activeProject?.backgroundColor || "#000000",
- projectCanvasSize: canvasSize,
- });
-
- const imageData = (offCtx as CanvasRenderingContext2D).getImageData(
- 0,
- 0,
- displayWidth,
- displayHeight
- );
- cacheFrame(
- currentTime,
- imageData,
- tracks,
- mediaFiles,
- activeProject,
- currentScene?.id
- );
-
- // Blit offscreen to visible canvas
- mainCtx.clearRect(0, 0, displayWidth, displayHeight);
- if ((offscreenCanvas as HTMLCanvasElement).getContext) {
- mainCtx.drawImage(offscreenCanvas as HTMLCanvasElement, 0, 0);
- } else {
- mainCtx.drawImage(
- offscreenCanvas as unknown as CanvasImageSource,
- 0,
- 0
- );
- }
- };
-
- void draw();
- }, [
- activeElements,
- currentTime,
- previewDimensions.width,
- previewDimensions.height,
- canvasSize.width,
- canvasSize.height,
- activeProject?.backgroundType,
- activeProject?.backgroundColor,
- getCachedFrame,
- cacheFrame,
- preRenderNearbyFrames,
- isPlaying,
- ]);
-
- // Get media elements for blur background (video/image only)
- const getBlurBackgroundElements = (): ActiveElement[] => {
- return activeElements.filter(
- ({ element, mediaItem }) =>
- element.type === "media" &&
- mediaItem &&
- (mediaItem.type === "video" || mediaItem.type === "image") &&
- element.mediaId !== "test" // Exclude test elements
- );
- };
-
- const blurBackgroundElements = getBlurBackgroundElements();
-
- // Render blur background layer (handled by canvas now)
- const renderBlurBackground = () => null;
-
- // Render an element (canvas handles visuals now). Audio playback to be implemented via Web Audio.
- const renderElement = (_elementData: ActiveElement) => null;
-
- return (
- <>
-
-
-
- {shouldRenderPreview ? (
-
- {renderBlurBackground()}
-
- {activeElements.length === 0 ? (
- <>>
- ) : (
- activeElements.map((elementData) => renderElement(elementData))
- )}
-
-
- ) : null}
-
-
-
-
-
-
-
- {isExpanded && (
-
- )}
- >
- );
-}
-
-function FullscreenToolbar({
- hasAnyElements,
- onToggleExpanded,
- currentTime,
- setCurrentTime,
- toggle,
- getTotalDuration,
-}: {
- hasAnyElements: boolean;
- onToggleExpanded: () => void;
- currentTime: number;
- setCurrentTime: (time: number) => void;
- toggle: () => void;
- getTotalDuration: () => number;
-}) {
- const { isPlaying, seek } = usePlaybackStore();
- const { activeProject } = useProjectStore();
- const [isDragging, setIsDragging] = useState(false);
-
- const totalDuration = getTotalDuration();
- const progress = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0;
-
- const handleTimelineClick = (e: React.MouseEvent) => {
- if (!hasAnyElements) return;
- const rect = e.currentTarget.getBoundingClientRect();
- const clickX = e.clientX - rect.left;
- const percentage = Math.max(0, Math.min(1, clickX / rect.width));
- const newTime = percentage * totalDuration;
- setCurrentTime(Math.max(0, Math.min(newTime, totalDuration)));
- };
-
- const handleTimelineDrag = (e: React.MouseEvent) => {
- if (!hasAnyElements) return;
- e.preventDefault();
- e.stopPropagation();
- const rect = e.currentTarget.getBoundingClientRect();
- setIsDragging(true);
-
- const handleMouseMove = (moveEvent: MouseEvent) => {
- moveEvent.preventDefault();
- const dragX = moveEvent.clientX - rect.left;
- const percentage = Math.max(0, Math.min(1, dragX / rect.width));
- const newTime = percentage * totalDuration;
- setCurrentTime(Math.max(0, Math.min(newTime, totalDuration)));
- };
-
- const handleMouseUp = () => {
- setIsDragging(false);
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- document.body.style.userSelect = "";
- };
-
- document.body.style.userSelect = "none";
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
- handleMouseMove(e.nativeEvent);
- };
-
- const skipBackward = () => {
- const newTime = Math.max(0, currentTime - 1);
- setCurrentTime(newTime);
- };
-
- const skipForward = () => {
- const newTime = Math.min(totalDuration, currentTime + 1);
- setCurrentTime(newTime);
- };
-
- return (
-
-
-
- /
-
- {formatTimeCode(
- totalDuration,
- "HH:MM:SS:FF",
- activeProject?.fps || DEFAULT_FPS
- )}
-
-
-
-
-
-
-
-
- {isPlaying ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-function FullscreenPreview({
- previewDimensions,
- activeProject,
- renderBlurBackground,
- activeElements,
- renderElement,
- blurBackgroundElements,
- hasAnyElements,
- toggleExpanded,
- currentTime,
- setCurrentTime,
- toggle,
- getTotalDuration,
-}: {
- previewDimensions: { width: number; height: number };
- activeProject: any;
- renderBlurBackground: () => React.ReactNode;
- activeElements: ActiveElement[];
- renderElement: (elementData: ActiveElement, index: number) => React.ReactNode;
- blurBackgroundElements: ActiveElement[];
- hasAnyElements: boolean;
- toggleExpanded: () => void;
- currentTime: number;
- setCurrentTime: (time: number) => void;
- toggle: () => void;
- getTotalDuration: () => number;
-}) {
- return (
-
-
-
- {renderBlurBackground()}
- {activeElements.length === 0 ? (
-
- No elements at current time
-
- ) : (
- activeElements.map((elementData, index) =>
- renderElement(elementData, index)
- )
- )}
-
-
-
-
-
-
-
- );
-}
-
-function PreviewToolbar({
- hasAnyElements,
- onToggleExpanded,
- isExpanded,
- currentTime,
- setCurrentTime,
- toggle,
- getTotalDuration,
-}: {
- hasAnyElements: boolean;
- onToggleExpanded: () => void;
- isExpanded: boolean;
- currentTime: number;
- setCurrentTime: (time: number) => void;
- toggle: () => void;
- getTotalDuration: () => number;
-}) {
- const { isPlaying } = usePlaybackStore();
- const { layoutGuide, toggleLayoutGuide } = useEditorStore();
-
- if (isExpanded) {
- return (
-
- );
- }
-
- return (
-
-
-
- {isPlaying ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
Layout guide
-
- Show platform-specific layout guides to help align your
- content with interface elements like profile pictures,
- usernames, and interaction buttons.
-
-
-
-
-
- toggleLayoutGuide(layoutGuide.platform || "tiktok")
- }
- />
- None
-
- {Object.entries(PLATFORM_LAYOUTS).map(([platform, label]) => (
-
-
- toggleLayoutGuide(platform as PlatformLayout)
- }
- />
- {label}
-
- ))}
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/editor/properties-panel/audio-properties.tsx b/apps/web/src/components/editor/properties-panel/audio-properties.tsx
deleted file mode 100644
index 8d69e36a..00000000
--- a/apps/web/src/components/editor/properties-panel/audio-properties.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { MediaElement } from "@/types/timeline";
-
-export function AudioProperties({ element }: { element: MediaElement }) {
- return Audio properties
;
-}
diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx
deleted file mode 100644
index 150f9512..00000000
--- a/apps/web/src/components/editor/properties-panel/index.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-"use client";
-
-import { useMediaStore } from "@/stores/media-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { ScrollArea } from "../../ui/scroll-area";
-import { AudioProperties } from "./audio-properties";
-import { MediaProperties } from "./media-properties";
-import { TextProperties } from "./text-properties";
-import { SquareSlashIcon } from "lucide-react";
-
-export function PropertiesPanel() {
- const { selectedElements, tracks } = useTimelineStore();
- const { mediaFiles } = useMediaStore();
-
- return (
- <>
- {selectedElements.length > 0 ? (
-
- {selectedElements.map(({ trackId, elementId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
-
- if (element?.type === "text") {
- return (
-
-
-
- );
- }
- if (element?.type === "media") {
- const mediaFile = mediaFiles.find(
- (file) => file.id === element.mediaId
- );
-
- if (mediaFile?.type === "audio") {
- return ;
- }
-
- return (
-
-
-
- );
- }
- return null;
- })}
-
- ) : (
-
- )}
- >
- );
-}
-
-function EmptyView() {
- return (
-
-
-
-
It’s empty here
-
- Click an element on the timeline to edit its properties
-
-
-
- );
-}
diff --git a/apps/web/src/components/editor/properties-panel/media-properties.tsx b/apps/web/src/components/editor/properties-panel/media-properties.tsx
deleted file mode 100644
index 4ea28bb2..00000000
--- a/apps/web/src/components/editor/properties-panel/media-properties.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { MediaElement } from "@/types/timeline";
-
-export function MediaProperties({ element }: { element: MediaElement }) {
- return Media properties
;
-}
diff --git a/apps/web/src/components/editor/properties-panel/property-item.tsx b/apps/web/src/components/editor/properties-panel/property-item.tsx
deleted file mode 100644
index f7d5dbda..00000000
--- a/apps/web/src/components/editor/properties-panel/property-item.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-import { cn } from "@/lib/utils";
-import { ChevronDown } from "lucide-react";
-import { useState } from "react";
-
-interface PropertyItemProps {
- direction?: "row" | "column";
- children: React.ReactNode;
- className?: string;
-}
-
-export function PropertyItem({
- direction = "row",
- children,
- className,
-}: PropertyItemProps) {
- return (
-
- {children}
-
- );
-}
-
-export function PropertyItemLabel({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return (
-
- {children}
-
- );
-}
-
-export function PropertyItemValue({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return {children}
;
-}
-
-interface PropertyGroupProps {
- title: string;
- children: React.ReactNode;
- defaultExpanded?: boolean;
- className?: string;
- titleClassName?: string;
-}
-
-export function PropertyGroup({
- title,
- children,
- defaultExpanded = true,
- className,
- titleClassName,
-}: PropertyGroupProps) {
- const [isExpanded, setIsExpanded] = useState(defaultExpanded);
-
- return (
-
- setIsExpanded(!isExpanded)}
- >
-
- {title}
-
-
-
- {isExpanded && {children} }
-
- );
-}
diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx
deleted file mode 100644
index deaee157..00000000
--- a/apps/web/src/components/editor/properties-panel/text-properties.tsx
+++ /dev/null
@@ -1,370 +0,0 @@
-import { Textarea } from "@/components/ui/textarea";
-import { FontPicker } from "@/components/ui/font-picker";
-import { FontFamily } from "@/constants/font-constants";
-import { TextElement } from "@/types/timeline";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { Slider } from "@/components/ui/slider";
-import { Input } from "@/components/ui/input";
-import { Button } from "@/components/ui/button";
-import { useState, useRef } from "react";
-import { PanelBaseView } from "@/components/editor/panel-base-view";
-import {
- TEXT_PROPERTIES_TABS,
- isTextPropertiesTab,
- useTextPropertiesStore,
-} from "@/stores/text-properties-store";
-import {
- PropertyItem,
- PropertyItemLabel,
- PropertyItemValue,
-} from "./property-item";
-import { ColorPicker } from "@/components/ui/color-picker";
-import { cn, uppercase } from "@/lib/utils";
-import { Grid2x2 } from "lucide-react";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-
-export function TextProperties({
- element,
- trackId,
-}: {
- element: TextElement;
- trackId: string;
-}) {
- const { updateTextElement } = useTimelineStore();
- const { activeTab, setActiveTab } = useTextPropertiesStore();
- const containerRef = useRef(null);
- // Local state for input values to allow temporary empty/invalid states
- const [fontSizeInput, setFontSizeInput] = useState(
- element.fontSize.toString()
- );
- const [opacityInput, setOpacityInput] = useState(
- Math.round(element.opacity * 100).toString()
- );
-
- // Track the last selected color for toggling
- const lastSelectedColor = useRef("#000000");
-
- const parseAndValidateNumber = (
- value: string,
- min: number,
- max: number,
- fallback: number
- ): number => {
- const parsed = parseInt(value, 10);
- if (isNaN(parsed)) return fallback;
- return Math.max(min, Math.min(max, parsed));
- };
-
- const handleFontSizeChange = (value: string) => {
- setFontSizeInput(value);
-
- if (value.trim() !== "") {
- const fontSize = parseAndValidateNumber(value, 8, 300, element.fontSize);
- updateTextElement(trackId, element.id, { fontSize });
- }
- };
-
- const handleFontSizeBlur = () => {
- const fontSize = parseAndValidateNumber(
- fontSizeInput,
- 8,
- 300,
- element.fontSize
- );
- setFontSizeInput(fontSize.toString());
- updateTextElement(trackId, element.id, { fontSize });
- };
-
- const handleOpacityChange = (value: string) => {
- setOpacityInput(value);
-
- if (value.trim() !== "") {
- const opacityPercent = parseAndValidateNumber(
- value,
- 0,
- 100,
- Math.round(element.opacity * 100)
- );
- updateTextElement(trackId, element.id, { opacity: opacityPercent / 100 });
- }
- };
-
- const handleOpacityBlur = () => {
- const opacityPercent = parseAndValidateNumber(
- opacityInput,
- 0,
- 100,
- Math.round(element.opacity * 100)
- );
- setOpacityInput(opacityPercent.toString());
- updateTextElement(trackId, element.id, { opacity: opacityPercent / 100 });
- };
-
- // Update last selected color when a new color is picked
- const handleColorChange = (color: string) => {
- if (color !== "transparent") {
- lastSelectedColor.current = color;
- }
- updateTextElement(trackId, element.id, { backgroundColor: color });
- };
-
- // Toggle between transparent and last selected color
- const handleTransparentToggle = (isTransparent: boolean) => {
- const newColor = isTransparent ? "transparent" : lastSelectedColor.current;
- updateTextElement(trackId, element.id, { backgroundColor: newColor });
- };
-
- return (
- {
- if (isTextPropertiesTab(v)) setActiveTab(v);
- }}
- ref={containerRef}
- tabs={TEXT_PROPERTIES_TABS.map((t) => ({
- value: t.value,
- label: t.label,
- content:
- t.value === "transform" ? (
-
- ) : (
-
- ),
- }))}
- />
- );
-}
diff --git a/apps/web/src/components/editor/scenes-view.tsx b/apps/web/src/components/editor/scenes-view.tsx
index b5432b53..f7576377 100644
--- a/apps/web/src/components/editor/scenes-view.tsx
+++ b/apps/web/src/components/editor/scenes-view.tsx
@@ -1,198 +1,219 @@
"use client";
import {
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
- SheetTrigger,
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
-import { useSceneStore } from "@/stores/scene-store";
import { Check, ListCheck, Trash2 } from "lucide-react";
-import { cn } from "@/lib/utils";
+import { cn } from "@/utils/ui";
import { useState } from "react";
import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
- DialogFooter,
- DialogTrigger,
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+ DialogTrigger,
} from "@/components/ui/dialog";
+import { canDeleteScene, getMainScene } from "@/lib/scenes";
+import { toast } from "sonner";
+import { useEditor } from "@/hooks/use-editor";
export function ScenesView({ children }: { children: React.ReactNode }) {
- const { scenes, currentScene, switchToScene, deleteScene } = useSceneStore();
- const [isSelectMode, setIsSelectMode] = useState(false);
- const [selectedScenes, setSelectedScenes] = useState>(new Set());
+ const editor = useEditor();
+ const scenes = editor.scenes.getScenes();
+ const currentScene = editor.scenes.getActiveScene();
+ const [isSelectMode, setIsSelectMode] = useState(false);
+ const [selectedScenes, setSelectedScenes] = useState>(new Set());
- const handleSceneSwitch = async (sceneId: string) => {
- if (isSelectMode) {
- toggleSceneSelection(sceneId);
- return;
- }
+ const handleSceneSwitch = async (sceneId: string) => {
+ if (isSelectMode) {
+ toggleSceneSelection({ sceneId });
+ return;
+ }
- try {
- await switchToScene({ sceneId });
- } catch (error) {
- console.error("Failed to switch scene:", error);
- }
- };
+ try {
+ await editor.scenes.switchToScene({ sceneId });
+ } catch (error) {
+ console.error("Failed to switch scene:", error);
+ }
+ };
- const toggleSceneSelection = (sceneId: string) => {
- setSelectedScenes((prev) => {
- const newSet = new Set(prev);
- if (newSet.has(sceneId)) {
- newSet.delete(sceneId);
- } else {
- newSet.add(sceneId);
- }
- return newSet;
- });
- };
+ const toggleSceneSelection = ({ sceneId }: { sceneId: string }) => {
+ setSelectedScenes((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(sceneId)) {
+ newSet.delete(sceneId);
+ } else {
+ newSet.add(sceneId);
+ }
+ return newSet;
+ });
+ };
- const handleSelectMode = () => {
- setIsSelectMode(!isSelectMode);
- setSelectedScenes(new Set());
- };
+ const handleSelectMode = () => {
+ setIsSelectMode(!isSelectMode);
+ setSelectedScenes(new Set());
+ };
- const handleDeleteSelected = async () => {
- for (const sceneId of selectedScenes) {
- const scene = scenes.find((s) => s.id === sceneId);
- if (scene && !scene.isMain) {
- try {
- await deleteScene({ sceneId });
- } catch (error) {
- console.error("Failed to delete scene:", error);
- }
- }
- }
- setSelectedScenes(new Set());
- setIsSelectMode(false);
- };
+ const handleDeleteSelected = async () => {
+ for (const sceneId of selectedScenes) {
+ const scene = scenes.find((scene) => scene.id === sceneId);
+ if (!scene) {
+ continue;
+ }
- return (
-
- {children}
-
-
-
- {isSelectMode ? `Select scenes (${selectedScenes.size})` : "Scenes"}
-
-
- {isSelectMode
- ? "Select scenes to delete"
- : "Switch between scenes in your project"}
-
-
-
-
-
-
- {isSelectMode ? "Cancel" : "Select"}
-
- {isSelectMode && (
- scenes.find((s) => s.id === id)?.isMain
- )}
- >
-
-
- Delete ({selectedScenes.size})
-
-
- )}
-
- {scenes.length === 0 ? (
-
- No scenes available
-
- ) : (
-
- {scenes.map((scene) => (
-
handleSceneSwitch(scene.id)}
- >
- {scene.name}
-
- {((isSelectMode && selectedScenes.has(scene.id)) ||
- (!isSelectMode && currentScene?.id === scene.id)) && (
-
- )}
-
-
- ))}
-
- )}
-
-
-
- );
+ const { canDelete, reason } = canDeleteScene({ scene });
+ if (!canDelete) {
+ toast.error(reason || "Failed to delete scene");
+ continue;
+ }
+
+ try {
+ await editor.scenes.deleteScene({ sceneId });
+ } catch (error) {
+ console.error("Failed to delete scene:", error);
+ }
+ }
+ setSelectedScenes(new Set());
+ setIsSelectMode(false);
+ };
+
+ const isMainSceneSelected = (() => {
+ const mainScene = getMainScene({ scenes });
+ return Boolean(mainScene?.id && selectedScenes.has(mainScene.id));
+ })();
+
+ return (
+
+ {children}
+
+
+
+ {isSelectMode ? `Select scenes (${selectedScenes.size})` : "Scenes"}
+
+
+ {isSelectMode
+ ? "Select scenes to delete"
+ : "Switch between scenes in your project"}
+
+
+
+
+
+
+ {isSelectMode ? "Cancel" : "Select"}
+
+ {isSelectMode && (
+
+
+ Delete ({selectedScenes.size})
+
+ }
+ />
+ )}
+
+ {scenes.length === 0 ? (
+
+ No scenes available
+
+ ) : (
+
+ {scenes.map((scene) => (
+
handleSceneSwitch(scene.id)}
+ >
+ {scene.name}
+
+ {((isSelectMode && selectedScenes.has(scene.id)) ||
+ (!isSelectMode && currentScene?.id === scene.id)) && (
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+ );
}
function DeleteDialog({
- count,
- onDelete,
- disabled,
- children,
+ count,
+ onDelete,
+ disabled,
+ trigger,
}: {
- count: number;
- onDelete: () => void;
- disabled?: boolean;
- children: React.ReactNode;
+ count: number;
+ onDelete: () => void;
+ disabled?: boolean;
+ trigger: React.ReactNode;
}) {
- const [open, setOpen] = useState(false);
+ const [open, setOpen] = useState(false);
- const handleDelete = () => {
- onDelete();
- setOpen(false);
- };
+ const handleDelete = () => {
+ onDelete();
+ setOpen(false);
+ };
- return (
-
- {children}
-
-
- Delete Scenes
-
- Are you sure you want to delete {count} scene
- {count === 1 ? "" : "s"}? This action cannot be undone.
-
-
-
- setOpen(false)}>
- Cancel
-
-
- Delete
-
-
-
-
- );
+ return (
+
+ {trigger}
+
+
+ Delete Scenes
+
+ Are you sure you want to delete {count} scene
+ {count === 1 ? "" : "s"}? This action cannot be undone.
+
+
+
+ setOpen(false)}>
+ Cancel
+
+
+ Delete
+
+
+
+
+ );
}
diff --git a/apps/web/src/components/editor/selection-box.tsx b/apps/web/src/components/editor/selection-box.tsx
index ca9d799f..32adbfd8 100644
--- a/apps/web/src/components/editor/selection-box.tsx
+++ b/apps/web/src/components/editor/selection-box.tsx
@@ -1,55 +1,50 @@
"use client";
-import { useEffect, useRef } from "react";
+import { useMemo } from "react";
interface SelectionBoxProps {
- startPos: { x: number; y: number } | null;
- currentPos: { x: number; y: number } | null;
- containerRef: React.RefObject;
- isActive: boolean;
+ startPos: { x: number; y: number } | null;
+ currentPos: { x: number; y: number } | null;
+ containerRef: React.RefObject;
+ isActive: boolean;
}
export function SelectionBox({
- startPos,
- currentPos,
- containerRef,
- isActive,
+ startPos,
+ currentPos,
+ containerRef,
+ isActive,
}: SelectionBoxProps) {
- const selectionBoxRef = useRef(null);
-
- useEffect(() => {
- if (!isActive || !startPos || !currentPos || !containerRef.current) return;
-
- const container = containerRef.current;
- const containerRect = container.getBoundingClientRect();
-
- // Calculate relative positions within the container
- const startX = startPos.x - containerRect.left;
- const startY = startPos.y - containerRect.top;
- const currentX = currentPos.x - containerRect.left;
- const currentY = currentPos.y - containerRect.top;
-
- // Calculate the selection rectangle bounds
- const left = Math.min(startX, currentX);
- const top = Math.min(startY, currentY);
- const width = Math.abs(currentX - startX);
- const height = Math.abs(currentY - startY);
-
- // Update the selection box position and size
- if (selectionBoxRef.current) {
- selectionBoxRef.current.style.left = `${left}px`;
- selectionBoxRef.current.style.top = `${top}px`;
- selectionBoxRef.current.style.width = `${width}px`;
- selectionBoxRef.current.style.height = `${height}px`;
- }
- }, [startPos, currentPos, isActive, containerRef]);
-
- if (!isActive || !startPos || !currentPos) return null;
-
- return (
-
- );
+ const selectionBoxStyle = useMemo(() => {
+ if (!isActive || !startPos || !currentPos || !containerRef.current) {
+ return null;
+ }
+
+ const containerRect = containerRef.current.getBoundingClientRect();
+ const startX = startPos.x - containerRect.left;
+ const startY = startPos.y - containerRect.top;
+ const currentX = currentPos.x - containerRect.left;
+ const currentY = currentPos.y - containerRect.top;
+
+ const left = Math.min(startX, currentX);
+ const top = Math.min(startY, currentY);
+ const width = Math.abs(currentX - startX);
+ const height = Math.abs(currentY - startY);
+
+ return {
+ left: `${left}px`,
+ top: `${top}px`,
+ width: `${width}px`,
+ height: `${height}px`,
+ };
+ }, [containerRef, currentPos, isActive, startPos]);
+
+ if (!selectionBoxStyle) return null;
+
+ return (
+
+ );
}
diff --git a/apps/web/src/components/editor/snap-indicator.tsx b/apps/web/src/components/editor/snap-indicator.tsx
deleted file mode 100644
index ac884b71..00000000
--- a/apps/web/src/components/editor/snap-indicator.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-"use client";
-
-import { SnapPoint } from "@/hooks/use-timeline-snapping";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import type { TimelineTrack } from "@/types/timeline";
-import { useState, useEffect } from "react";
-
-interface SnapIndicatorProps {
- snapPoint: SnapPoint | null;
- zoomLevel: number;
- isVisible: boolean;
- tracks: TimelineTrack[];
- timelineRef: React.RefObject;
- trackLabelsRef?: React.RefObject;
- tracksScrollRef: React.RefObject;
-}
-
-export function SnapIndicator({
- snapPoint,
- zoomLevel,
- isVisible,
- tracks,
- timelineRef,
- trackLabelsRef,
- tracksScrollRef,
-}: SnapIndicatorProps) {
- const [scrollLeft, setScrollLeft] = useState(0);
-
- // Track scroll position to lock snap indicator to frame
- useEffect(() => {
- const tracksViewport = tracksScrollRef.current;
-
- if (!tracksViewport) return;
-
- const handleScroll = () => {
- setScrollLeft(tracksViewport.scrollLeft);
- };
-
- // Set initial scroll position
- setScrollLeft(tracksViewport.scrollLeft);
-
- tracksViewport.addEventListener("scroll", handleScroll);
- return () => tracksViewport.removeEventListener("scroll", handleScroll);
- }, [tracksScrollRef]);
-
- 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;
-
- // Calculate position locked to timeline content (accounting for scroll)
- const timelinePosition =
- snapPoint.time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const leftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
-
- return (
-
- );
-}
diff --git a/apps/web/src/components/editor/speed-control.tsx b/apps/web/src/components/editor/speed-control.tsx
deleted file mode 100644
index f2b0568d..00000000
--- a/apps/web/src/components/editor/speed-control.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { Slider } from "../ui/slider";
-import { Label } from "../ui/label";
-import { Button } from "../ui/button";
-import { usePlaybackStore } from "@/stores/playback-store";
-
-const SPEED_PRESETS = [
- { label: "0.5x", value: 0.5 },
- { label: "1x", value: 1.0 },
- { label: "1.5x", value: 1.5 },
- { label: "2x", value: 2.0 },
-];
-
-export function SpeedControl() {
- const { speed, setSpeed } = usePlaybackStore();
-
- return (
-
-
Playback Speed
-
-
- {SPEED_PRESETS.map((preset) => (
- setSpeed(preset.value)}
- >
- {preset.label}
-
- ))}
-
-
- Custom ({speed.toFixed(1)}x)
- setSpeed(value[0])}
- className="mt-2"
- />
-
-
-
- );
-}
diff --git a/apps/web/src/components/editor/timeline/audio-waveform.tsx b/apps/web/src/components/editor/timeline/audio-waveform.tsx
new file mode 100644
index 00000000..bbc5cb83
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/audio-waveform.tsx
@@ -0,0 +1,176 @@
+import { useEffect, useRef, useState } from "react";
+import WaveSurfer from "wavesurfer.js";
+
+interface AudioWaveformProps {
+ audioUrl?: string;
+ audioBuffer?: AudioBuffer;
+ height?: number;
+ className?: string;
+}
+
+function extractPeaks({
+ buffer,
+ length = 512,
+}: {
+ buffer: AudioBuffer;
+ length?: number;
+}): number[][] {
+ const channels = buffer.numberOfChannels;
+ const peaks: number[][] = [];
+
+ for (let c = 0; c < channels; c++) {
+ const data = buffer.getChannelData(c);
+ const step = Math.floor(data.length / length);
+ const channelPeaks: number[] = [];
+
+ for (let i = 0; i < length; i++) {
+ const start = i * step;
+ const end = Math.min(start + step, data.length);
+ let max = 0;
+ for (let j = start; j < end; j++) {
+ const abs = Math.abs(data[j]);
+ if (abs > max) max = abs;
+ }
+ channelPeaks.push(max);
+ }
+ peaks.push(channelPeaks);
+ }
+
+ return peaks;
+}
+
+export function AudioWaveform({
+ audioUrl,
+ audioBuffer,
+ height = 32,
+ className = "",
+}: AudioWaveformProps) {
+ const waveformRef = useRef(null);
+ const wavesurfer = useRef(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(false);
+
+ useEffect(() => {
+ let mounted = true;
+ const ws = wavesurfer.current;
+
+ const initWaveSurfer = async () => {
+ if (!waveformRef.current || (!audioUrl && !audioBuffer)) return;
+
+ try {
+ if (ws) {
+ wavesurfer.current = null;
+ }
+
+ const newWaveSurfer = WaveSurfer.create({
+ container: waveformRef.current,
+ waveColor: "rgba(255, 255, 255, 0.6)",
+ progressColor: "rgba(255, 255, 255, 0.9)",
+ cursorColor: "transparent",
+ barWidth: 2,
+ barGap: 1,
+ height,
+ normalize: true,
+ interact: false,
+ });
+
+ if (mounted) {
+ wavesurfer.current = newWaveSurfer;
+ } else {
+ try {
+ newWaveSurfer.destroy();
+ } catch {}
+ return;
+ }
+
+ newWaveSurfer.on("ready", () => {
+ if (mounted) {
+ setIsLoading(false);
+ setError(false);
+ }
+ });
+
+ newWaveSurfer.on("error", (err) => {
+ if (mounted) {
+ console.error("WaveSurfer error:", err);
+ setError(true);
+ setIsLoading(false);
+ }
+ });
+
+ if (audioBuffer) {
+ const peaks = extractPeaks({ buffer: audioBuffer });
+ newWaveSurfer.load("", peaks, audioBuffer.duration);
+ } else if (audioUrl) {
+ await newWaveSurfer.load(audioUrl);
+ }
+ } catch (err) {
+ if (mounted) {
+ console.error("Failed to initialize WaveSurfer:", err);
+ setError(true);
+ setIsLoading(false);
+ }
+ }
+ };
+
+ if (ws) {
+ const wsToDestroy = ws;
+ wavesurfer.current = null;
+
+ requestAnimationFrame(() => {
+ try {
+ wsToDestroy.destroy();
+ } catch {}
+ if (mounted) {
+ initWaveSurfer();
+ }
+ });
+ } else {
+ initWaveSurfer();
+ }
+
+ return () => {
+ mounted = false;
+
+ const wsToDestroy = wavesurfer.current;
+
+ wavesurfer.current = null;
+
+ if (wsToDestroy) {
+ requestAnimationFrame(() => {
+ try {
+ wsToDestroy.destroy();
+ } catch {}
+ });
+ }
+ };
+ }, [audioUrl, audioBuffer, height]);
+
+ if (error) {
+ return (
+
+ Audio unavailable
+
+ );
+ }
+
+ return (
+
+ {isLoading && (
+
+ Loading...
+
+ )}
+
+
+ );
+}
+
+export default AudioWaveform;
diff --git a/apps/web/src/components/editor/timeline/bookmarks.tsx b/apps/web/src/components/editor/timeline/bookmarks.tsx
new file mode 100644
index 00000000..cfcb1e23
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/bookmarks.tsx
@@ -0,0 +1,111 @@
+import { useEditor } from "@/hooks/use-editor";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { getSnappedSeekTime } from "@/lib/time";
+import { Bookmark02Icon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+interface TimelineBookmarksRowProps {
+ zoomLevel: number;
+ dynamicTimelineWidth: number;
+ handleWheel: (e: React.WheelEvent) => void;
+ handleTimelineContentClick: (e: React.MouseEvent) => void;
+ handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
+ handleRulerMouseDown: (e: React.MouseEvent) => void;
+}
+
+export function TimelineBookmarksRow({
+ zoomLevel,
+ dynamicTimelineWidth,
+ handleWheel,
+ handleTimelineContentClick,
+ handleRulerTrackingMouseDown,
+ handleRulerMouseDown,
+}: TimelineBookmarksRowProps) {
+ const editor = useEditor();
+ const activeScene = editor.scenes.getActiveScene();
+
+ return (
+
+ {
+ handleRulerMouseDown(event);
+ handleRulerTrackingMouseDown(event);
+ }}
+ >
+ {activeScene.bookmarks.map((time: number) => (
+
+ ))}
+
+
+ );
+}
+
+export function TimelineBookmark({
+ time,
+ zoomLevel,
+}: {
+ time: number;
+ zoomLevel: number;
+}) {
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+ const duration = editor.timeline.getTotalDuration();
+
+ const handleBookmarkActivate = ({
+ event,
+ }: {
+ event:
+ | React.MouseEvent
+ | React.KeyboardEvent;
+ }) => {
+ event.stopPropagation();
+ const framesPerSecond = activeProject?.settings.fps ?? 30;
+ const snappedTime = getSnappedSeekTime({
+ rawTime: time,
+ duration,
+ fps: framesPerSecond,
+ });
+ editor.playback.seek({ time: snappedTime });
+ };
+
+ return (
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ }}
+ onClick={(event) => handleBookmarkActivate({ event })}
+ onKeyDown={(event) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ event.preventDefault();
+ handleBookmarkActivate({ event });
+ }}
+ >
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/drag-line.tsx b/apps/web/src/components/editor/timeline/drag-line.tsx
new file mode 100644
index 00000000..f31c6e28
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/drag-line.tsx
@@ -0,0 +1,28 @@
+import { getDropLineY } from "@/lib/timeline/drop-utils";
+import type { TimelineTrack, DropTarget } from "@/types/timeline";
+
+interface DragLineProps {
+ dropTarget: DropTarget | null;
+ tracks: TimelineTrack[];
+ isVisible: boolean;
+ headerHeight?: number;
+}
+
+export function DragLine({
+ dropTarget,
+ tracks,
+ isVisible,
+ headerHeight = 0,
+}: DragLineProps) {
+ if (!isVisible || !dropTarget) return null;
+
+ const y = getDropLineY({ dropTarget, tracks });
+ const lineTop = y + headerHeight;
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx
index 4a59fd21..7e2b2b66 100644
--- a/apps/web/src/components/editor/timeline/index.tsx
+++ b/apps/web/src/components/editor/timeline/index.tsx
@@ -1,925 +1,530 @@
"use client";
import { ScrollArea } from "@/components/ui/scroll-area";
-import { Button } from "../../ui/button";
import {
- Scissors,
- ArrowLeftToLine,
- ArrowRightToLine,
- Trash2,
- Snowflake,
- Copy,
- SplitSquareHorizontal,
- Pause,
- Play,
- SkipBack,
- Video,
- Music,
- TypeIcon,
- Magnet,
- Link,
- ZoomIn,
- ZoomOut,
- Bookmark,
- Eye,
- VolumeOff,
- Volume2,
-} from "lucide-react";
+ Delete02Icon,
+ TaskAdd02Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ VolumeHighIcon,
+ VolumeOffIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
- TooltipProvider,
-} from "../../ui/tooltip";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuTrigger,
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
} from "../../ui/context-menu";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store";
-
-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 { useTimelineZoom } from "@/hooks/timeline/use-timeline-zoom";
+import { useState, useRef, useCallback } from "react";
import { TimelineTrackContent } from "./timeline-track";
-import {
- TimelinePlayhead,
- useTimelinePlayheadRuler,
-} from "./timeline-playhead";
+import { TimelinePlayhead } 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, TrackType } from "@/types/timeline";
-import { TimelineCacheIndicator } from "./timeline-cache-indicator";
-import { TimelineMarker } from "./timeline-marker";
-import { useFrameCache } from "@/hooks/use-frame-cache";
+import { useSelectionBox } from "@/hooks/timeline/use-selection-box";
+import { SnapIndicator } from "./snap-indicator";
+import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+import type { TimelineTrack } from "@/types/timeline";
import {
- getTrackHeight,
- getCumulativeHeightBefore,
- getTotalTracksHeight,
- TIMELINE_CONSTANTS,
- snapTimeToFrame,
+ TIMELINE_CONSTANTS,
+ TRACK_ICONS,
} from "@/constants/timeline-constants";
-import { Slider } from "@/components/ui/slider";
-import { formatTimeCode } from "@/lib/time";
-import { EditableTimecode } from "@/components/ui/editable-timecode";
+import { useElementInteraction } from "@/hooks/timeline/element/use-element-interaction";
+import {
+ getTrackHeight,
+ getCumulativeHeightBefore,
+ getTotalTracksHeight,
+ canTracktHaveAudio,
+ canTrackBeHidden,
+ getTimelineZoomMin,
+ getTimelinePaddingPx,
+ isMainTrack,
+} from "@/lib/timeline";
import { TimelineToolbar } from "./timeline-toolbar";
+import { useScrollSync } from "@/hooks/timeline/use-scroll-sync";
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
+import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
+import { TimelineRuler } from "./timeline-ruler";
+import { TimelineBookmarksRow } from "./bookmarks";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { useEditor } from "@/hooks/use-editor";
+import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
+import { DragLine } from "./drag-line";
+import { invokeAction } from "@/lib/actions";
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,
- getTotalDuration,
- clearSelectedElements,
- snappingEnabled,
- setSelectedElements,
- toggleTrackMute,
- dragState,
- } = useTimelineStore();
- const { mediaFiles, addMediaFile } = useMediaStore();
- const { activeProject } = useProjectStore();
- const { currentTime, duration, seek, setDuration } = usePlaybackStore();
- const { getRenderStatus } = useFrameCache();
- const [isDragOver, setIsDragOver] = useState(false);
- const { addElementToNewTrack } = useTimelineStore();
- const dragCounterRef = useRef(0);
- const timelineRef = useRef(null);
- const rulerRef = useRef(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,
- isInTimeline,
- });
-
- // Old marquee selection removed - using new SelectionBox component instead
-
- // Dynamic timeline width calculation based on playhead position and duration
- const dynamicTimelineWidth = Math.max(
- (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration
- (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer
- timelineRef.current?.clientWidth || 1000 // Minimum width
- );
-
- // Scroll synchronization and auto-scroll to playhead
- const rulerScrollRef = useRef(null);
- const tracksScrollRef = useRef(null);
- const trackLabelsRef = useRef(null);
- const playheadRef = useRef(null);
- const trackLabelsScrollRef = useRef(null);
- const isUpdatingRef = useRef(false);
- const lastRulerSync = useRef(0);
- const lastTracksSync = useRef(0);
- const lastVerticalSync = useRef(0);
-
- // Timeline playhead ruler handlers
- const { handleRulerMouseDown } = useTimelinePlayheadRuler({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
- });
-
- // Selection box functionality
- const tracksContainerRef = useRef(null);
- const {
- selectionBox,
- handleMouseDown: handleSelectionMouseDown,
- isSelecting,
- justFinishedSelecting,
- } = useSelectionBox({
- containerRef: tracksContainerRef,
- playheadRef,
- onSelectionComplete: (elements) => {
- console.log(JSON.stringify({ onSelectionComplete: elements.length }));
- setSelectedElements(elements);
- },
- });
-
- // Calculate snap indicator state
- const [currentSnapPoint, setCurrentSnapPoint] = useState(
- 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;
- console.log(
- JSON.stringify({
- debug_mousedown: "START",
- target_class: target.className,
- target_parent_class: target.parentElement?.className,
- clientX: e.clientX,
- clientY: e.clientY,
- timeStamp: e.timeStamp,
- })
- );
-
- const isTimelineBackground =
- !target.closest(".timeline-element") &&
- !playheadRef.current?.contains(target) &&
- !target.closest("[data-track-labels]");
-
- console.log(
- JSON.stringify({
- debug_mousedown: "CHECK",
- isTimelineBackground,
- hasTimelineElement: !!target.closest(".timeline-element"),
- hasPlayhead: !!playheadRef.current?.contains(target),
- hasTrackLabels: !!target.closest("[data-track-labels]"),
- })
- );
-
- if (isTimelineBackground) {
- mouseTrackingRef.current = {
- isMouseDown: true,
- downX: e.clientX,
- downY: e.clientY,
- downTime: e.timeStamp,
- };
- console.log(
- JSON.stringify({
- debug_mousedown: "TRACKED",
- mouseTracking: mouseTrackingRef.current,
- })
- );
- } else {
- console.log(
- JSON.stringify({
- debug_mousedown: "IGNORED - not timeline background",
- })
- );
- }
- }, []);
-
- // Timeline content click to seek handler
- const handleTimelineContentClick = useCallback(
- (e: React.MouseEvent) => {
- console.log(
- JSON.stringify({
- debug_click: "START",
- target: (e.target as HTMLElement).className,
- target_parent: (e.target as HTMLElement).parentElement?.className,
- mouseTracking: mouseTrackingRef.current,
- isSelecting,
- justFinishedSelecting,
- clickX: e.clientX,
- clickY: e.clientY,
- timeStamp: e.timeStamp,
- })
- );
-
- 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({
- debug_click: "REJECTED - no mousedown",
- mouseTracking: mouseTrackingRef.current,
- })
- );
- 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({
- debug_click: "REJECTED - movement too large",
- deltaX,
- deltaY,
- deltaTime,
- })
- );
- return;
- }
-
- // Don't seek if this was a selection box operation
- if (isSelecting || justFinishedSelecting) {
- console.log(
- JSON.stringify({
- debug_click: "REJECTED - selection operation",
- isSelecting,
- justFinishedSelecting,
- })
- );
- return;
- }
-
- // Don't seek if clicking on timeline elements, but still deselect
- if ((e.target as HTMLElement).closest(".timeline-element")) {
- console.log(
- JSON.stringify({
- debug_click: "REJECTED - clicked timeline element",
- })
- );
- return;
- }
-
- // Don't seek if clicking on playhead
- if (playheadRef.current?.contains(e.target as Node)) {
- console.log(
- JSON.stringify({
- debug_click: "REJECTED - clicked playhead",
- })
- );
- return;
- }
-
- // Don't seek if clicking on track labels
- if ((e.target as HTMLElement).closest("[data-track-labels]")) {
- console.log(
- JSON.stringify({
- debug_click: "REJECTED - clicked track labels",
- })
- );
- clearSelectedElements();
- return;
- }
-
- // Clear selected elements when clicking empty timeline area
- console.log(
- JSON.stringify({
- debug_click: "PROCEEDING - clearing elements",
- clearingSelectedElements: true,
- })
- );
- clearSelectedElements();
-
- // Determine if we're clicking in ruler or tracks area
- const isRulerClick = (e.target as HTMLElement).closest(
- "[data-ruler-area]"
- );
-
- console.log(
- JSON.stringify({
- debug_click: "CALCULATING POSITION",
- isRulerClick,
- clientX: e.clientX,
- clientY: e.clientY,
- target_element: (e.target as HTMLElement).tagName,
- target_class: (e.target as HTMLElement).className,
- })
- );
-
- let mouseX: number;
- let scrollLeft = 0;
-
- if (isRulerClick) {
- // Calculate based on ruler position
- const rulerContent = rulerScrollRef.current;
- if (!rulerContent) {
- console.log(
- JSON.stringify({
- debug_click: "ERROR - no ruler container found",
- })
- );
- return;
- }
- const rect = rulerContent.getBoundingClientRect();
- mouseX = e.clientX - rect.left;
- scrollLeft = rulerContent.scrollLeft;
- } else {
- const tracksContent = tracksScrollRef.current;
- if (!tracksContent) {
- return;
- }
- const rect = tracksContent.getBoundingClientRect();
- mouseX = e.clientX - rect.left;
- scrollLeft = tracksContent.scrollLeft;
- }
-
- const rawTime = Math.max(
- 0,
- Math.min(
- duration,
- (mouseX + scrollLeft) /
- (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
- )
- );
-
- // Use frame snapping for timeline clicking
- const projectFps = activeProject?.fps || 30;
- const time = snapTimeToFrame(rawTime, projectFps);
- seek(time);
- },
- [
- duration,
- zoomLevel,
- seek,
- rulerScrollRef,
- tracksScrollRef,
- clearSelectedElements,
- isSelecting,
- justFinishedSelecting,
- ]
- );
-
- // Update timeline duration when tracks change
- useEffect(() => {
- const totalDuration = getTotalDuration();
- setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline
- }, [tracks, setDuration, getTotalDuration]);
-
- // Old marquee system removed - using new SelectionBox component instead
-
- const handleDragEnter = (e: React.DragEvent) => {
- // When something is dragged over the timeline, show overlay
- e.preventDefault();
- // Don't show overlay for timeline elements - they're handled by tracks
- if (e.dataTransfer.types.includes("application/x-timeline-element")) {
- return;
- }
- dragCounterRef.current += 1;
- if (!isDragOver) {
- setIsDragOver(true);
- }
- };
-
- const handleDragOver = (e: React.DragEvent) => {
- e.preventDefault();
- };
-
- const handleDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Don't update state for timeline elements - they're handled by tracks
- if (e.dataTransfer.types.includes("application/x-timeline-element")) {
- return;
- }
-
- dragCounterRef.current -= 1;
- if (dragCounterRef.current === 0) {
- setIsDragOver(false);
- }
- };
-
- const handleDrop = async (e: React.DragEvent) => {
- // When media is dropped, add it as a new track/element
- e.preventDefault();
- setIsDragOver(false);
- dragCounterRef.current = 0;
-
- // Ignore timeline element drags - they're handled by track-specific handlers
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- if (hasTimelineElement) {
- return;
- }
-
- const itemData = e.dataTransfer.getData("application/x-media-item");
- if (itemData) {
- try {
- const dragData: DragData = JSON.parse(itemData);
-
- if (dragData.type === "text") {
- // Always create new text track to avoid overlaps
- addElementToNewTrack(dragData);
- } else {
- // Handle media items
- const mediaItem = mediaFiles.find(
- (item: any) => item.id === dragData.id
- );
- if (!mediaItem) {
- toast.error("Media item not found");
- return;
- }
-
- addElementToNewTrack(mediaItem);
- }
- } catch (error) {
- console.error("Error parsing dropped item data:", error);
- toast.error("Failed to add item to timeline");
- }
- } else if (e.dataTransfer.files?.length > 0) {
- // Handle file drops by creating new tracks
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- try {
- const processedItems = await processMediaFiles(e.dataTransfer.files);
- for (const processedItem of processedItems) {
- await addMediaFile(activeProject.id, processedItem);
- const currentMediaFiles = mediaFiles;
- const addedItem = currentMediaFiles.find(
- (item) =>
- item.name === processedItem.name && item.url === processedItem.url
- );
- if (addedItem) {
- const trackType: TrackType =
- addedItem.type === "audio" ? "audio" : "media";
- const targetTrackId = useTimelineStore
- .getState()
- .insertTrackAt(trackType, 0);
-
- useTimelineStore.getState().addElementToTrack(targetTrackId, {
- type: "media",
- mediaId: addedItem.id,
- name: addedItem.name,
- duration: addedItem.duration || 5,
- startTime: currentTime,
- trimStart: 0,
- trimEnd: 0,
- });
- }
- }
- } catch (error) {
- // Show error if file processing fails
- console.error("Error processing external files:", error);
- toast.error("Failed to process dropped files");
- }
- }
- };
-
- const dragProps = {
- onDragEnter: handleDragEnter,
- onDragOver: handleDragOver,
- onDragLeave: handleDragLeave,
- onDrop: handleDrop,
- };
-
- // --- Scroll synchronization effect ---
- useEffect(() => {
- const rulerViewport = rulerScrollRef.current;
- const tracksViewport = tracksScrollRef.current;
- const trackLabelsViewport = trackLabelsScrollRef.current;
-
- 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;
- lastRulerSync.current = now;
- isUpdatingRef.current = true;
- tracksViewport.scrollLeft = rulerViewport.scrollLeft;
- isUpdatingRef.current = false;
- };
- const handleTracksScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
- lastTracksSync.current = now;
- isUpdatingRef.current = true;
- 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);
- };
- }, []);
-
- return (
- setIsInTimeline(true)}
- onMouseLeave={() => setIsInTimeline(false)}
- >
-
-
- {/* Timeline Container */}
-
-
-
- {/* Timeline Header with Ruler */}
-
- {/* Track Labels Header */}
-
- {/* Empty space */}
-
- .
-
-
-
- {/* Timeline Ruler */}
-
{
- // Check if this is horizontal scrolling - if so, don't handle it here
- if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
- return; // Let ScrollArea handle horizontal scrolling
- }
- handleWheel(e);
- }}
- onMouseDown={handleSelectionMouseDown}
- onClick={handleTimelineContentClick}
- data-ruler-area
- >
-
{
- if (isUpdatingRef.current) return;
- isUpdatingRef.current = true;
- const tracksViewport = tracksScrollRef.current;
- if (tracksViewport) {
- tracksViewport.scrollLeft = (
- e.currentTarget as HTMLDivElement
- ).scrollLeft;
- }
- isUpdatingRef.current = false;
- }}
- >
-
-
- {/* Time markers */}
- {(() => {
- // Calculate appropriate time interval based on zoom level
- const getTimeInterval = (zoom: number) => {
- const pixelsPerSecond =
- TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom;
- if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in
- if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in
- if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom
- if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out
- if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out
- if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out
- return 30; // Every 30s when extremely zoomed out
- };
-
- const interval = getTimeInterval(zoomLevel);
- const markerCount = Math.ceil(duration / interval) + 1;
-
- return Array.from({ length: markerCount }, (_, i) => {
- const time = i * interval;
- if (time > duration) return null;
-
- const isMainMarker =
- time % (interval >= 1 ? Math.max(1, interval) : 1) === 0;
-
- return (
-
- );
- }).filter(Boolean);
- })()}
-
- {/* Bookmark markers */}
- {(() => {
- const { activeProject } = useProjectStore.getState();
- if (!activeProject?.bookmarks?.length) return null;
-
- return activeProject.bookmarks.map((bookmarkTime, i) => (
-
{
- e.stopPropagation();
- usePlaybackStore.getState().seek(bookmarkTime);
- }}
- >
-
-
-
-
- ));
- })()}
-
-
-
-
-
- {/* Tracks Area */}
-
- {/* Track Labels */}
- {tracks.length > 0 && (
-
-
-
- {tracks.map((track) => (
-
-
- {track.muted ? (
- toggleTrackMute(track.id)}
- />
- ) : (
- toggleTrackMute(track.id)}
- />
- )}
-
-
-
-
- ))}
-
-
-
- )}
-
- {/* Timeline Tracks Content */}
-
{
- // Check if this is horizontal scrolling - if so, don't handle it here
- if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
- return; // Let ScrollArea handle horizontal scrolling
- }
- handleWheel(e);
- }}
- onMouseDown={(e) => {
- handleTimelineMouseDown(e);
- handleSelectionMouseDown(e);
- }}
- onClick={handleTimelineContentClick}
- ref={tracksContainerRef}
- >
-
-
{
- if (isUpdatingRef.current) return;
- isUpdatingRef.current = true;
- const rulerViewport = rulerScrollRef.current;
- if (rulerViewport) {
- rulerViewport.scrollLeft = (
- e.currentTarget as HTMLDivElement
- ).scrollLeft;
- }
- isUpdatingRef.current = false;
- }}
- >
-
- {tracks.length === 0 ? (
-
- ) : (
- <>
- {tracks.map((track, index) => (
-
-
- {
- // If clicking empty area (not on a element), deselect all elements
- if (
- !(e.target as HTMLElement).closest(
- ".timeline-element"
- )
- ) {
- clearSelectedElements();
- }
- }}
- >
-
-
-
-
- {
- e.stopPropagation();
- toggleTrackMute(track.id);
- }}
- >
- {track.muted ? "Unmute Track" : "Mute Track"}
-
- e.stopPropagation()}>
- Track settings (soon)
-
-
-
- ))}
- >
- )}
-
-
-
-
-
-
- );
+ const tracksContainerHeight = { min: 0, max: 800 };
+ const { snappingEnabled } = useTimelineStore();
+ const { clearElementSelection, setElementSelection } = useElementSelection();
+ const editor = useEditor();
+ const timeline = editor.timeline;
+ const tracks = timeline.getTracks();
+ const seek = (time: number) => editor.playback.seek({ time });
+
+ // refs
+ const timelineRef = useRef(null);
+ const timelineHeaderRef = useRef(null);
+ const rulerRef = useRef(null);
+ const tracksContainerRef = useRef(null);
+ const tracksScrollRef = useRef(null);
+ const trackLabelsRef = useRef(null);
+ const playheadRef = useRef(null);
+ const trackLabelsScrollRef = useRef(null);
+
+ // state
+ const [isResizing, setIsResizing] = useState(false);
+ const [currentSnapPoint, setCurrentSnapPoint] = useState(
+ null,
+ );
+
+ const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
+ setCurrentSnapPoint(snapPoint);
+ }, []);
+ const handleResizeStateChange = useCallback(
+ ({ isResizing: nextIsResizing }: { isResizing: boolean }) => {
+ setIsResizing(nextIsResizing);
+ if (!nextIsResizing) {
+ setCurrentSnapPoint(null);
+ }
+ },
+ [],
+ );
+
+ const timelineDuration = timeline.getTotalDuration() || 0;
+ const minZoomLevel = getTimelineZoomMin({
+ duration: timelineDuration,
+ containerWidth: tracksContainerRef.current?.clientWidth,
+ });
+
+ const savedViewState = editor.project.getTimelineViewState();
+
+ const { zoomLevel, setZoomLevel, handleWheel, saveScrollPosition } =
+ useTimelineZoom({
+ containerRef: timelineRef,
+ minZoom: minZoomLevel,
+ initialZoom: savedViewState?.zoomLevel,
+ initialScrollLeft: savedViewState?.scrollLeft,
+ initialPlayheadTime: savedViewState?.playheadTime,
+ tracksScrollRef,
+ rulerScrollRef: tracksScrollRef,
+ });
+
+ const {
+ dragState,
+ dragDropTarget,
+ handleElementMouseDown,
+ handleElementClick,
+ lastMouseXRef,
+ } = useElementInteraction({
+ zoomLevel,
+ timelineRef,
+ tracksContainerRef,
+ tracksScrollRef,
+ headerRef: timelineHeaderRef,
+ snappingEnabled,
+ onSnapPointChange: handleSnapPointChange,
+ });
+
+ const { handleRulerMouseDown: handlePlayheadRulerMouseDown } =
+ useTimelinePlayhead({
+ zoomLevel,
+ rulerRef,
+ rulerScrollRef: tracksScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
+
+ const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
+ containerRef: tracksContainerRef,
+ headerRef: timelineHeaderRef,
+ zoomLevel,
+ });
+
+ const {
+ selectionBox,
+ handleMouseDown: handleSelectionMouseDown,
+ isSelecting,
+ shouldIgnoreClick,
+ } = useSelectionBox({
+ containerRef: tracksContainerRef,
+ onSelectionComplete: (elements) => {
+ setElementSelection({ elements });
+ },
+ tracksScrollRef,
+ zoomLevel,
+ });
+
+ const containerWidth = tracksContainerRef.current?.clientWidth || 1000;
+ const contentWidth =
+ timelineDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const paddingPx = getTimelinePaddingPx({
+ containerWidth,
+ zoomLevel,
+ minZoom: minZoomLevel,
+ });
+ const dynamicTimelineWidth = Math.max(
+ contentWidth + paddingPx,
+ containerWidth,
+ );
+
+ const showSnapIndicator =
+ snappingEnabled &&
+ currentSnapPoint !== null &&
+ (dragState.isDragging || isResizing);
+
+ const {
+ handleTracksMouseDown,
+ handleTracksClick,
+ handleRulerMouseDown,
+ handleRulerClick,
+ } = useTimelineSeek({
+ playheadRef,
+ trackLabelsRef,
+ rulerScrollRef: tracksScrollRef,
+ tracksScrollRef,
+ zoomLevel,
+ duration: timeline.getTotalDuration(),
+ isSelecting,
+ clearSelectedElements: clearElementSelection,
+ seek,
+ });
+
+ useScrollSync({
+ tracksScrollRef,
+ trackLabelsScrollRef,
+ });
+
+ const timelineHeaderHeight =
+ timelineHeaderRef.current?.getBoundingClientRect().height ?? 0;
+
+ return (
+
+ setZoomLevel(zoom)}
+ />
+
+
+
+
+
+
+ .
+
+
+ .
+
+ {tracks.length > 0 && (
+
+
+
+ {tracks.map((track) => (
+
+
+ {process.env.NODE_ENV === "development" &&
+ isMainTrack(track) && (
+
+ )}
+ {canTracktHaveAudio(track) && (
+
+ editor.timeline.toggleTrackMute({
+ trackId: track.id,
+ })
+ }
+ />
+ )}
+ {canTrackBeHidden(track) && (
+
+ editor.timeline.toggleTrackVisibility({
+ trackId: track.id,
+ })
+ }
+ />
+ )}
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+
+
{
+ const isDirectTarget = event.target === event.currentTarget;
+ if (!isDirectTarget) return;
+ event.stopPropagation();
+ handleTracksMouseDown(event);
+ handleSelectionMouseDown(event);
+ }}
+ onClick={(event) => {
+ const isDirectTarget = event.target === event.currentTarget;
+ if (!isDirectTarget) return;
+ event.stopPropagation();
+ handleTracksClick(event);
+ }}
+ onWheel={(event) => {
+ if (
+ event.shiftKey ||
+ Math.abs(event.deltaX) > Math.abs(event.deltaY)
+ ) {
+ return;
+ }
+ handleWheel(event);
+ }}
+ onScroll={() => {
+ saveScrollPosition();
+ }}
+ >
+
+
+
+
+
+
+
+ {tracks.length === 0 ? (
+
+ ) : (
+ tracks.map((track, index) => (
+
+
+
+
+
+
+
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ invokeAction("paste-copied");
+ }}
+ >
+ Paste elements
+
+ {
+ e.stopPropagation();
+ timeline.toggleTrackMute({
+ trackId: track.id,
+ });
+ }}
+ >
+
+
+ {canTracktHaveAudio(track) && track.muted
+ ? "Unmute track"
+ : "Mute track"}
+
+
+ {
+ e.stopPropagation();
+ timeline.toggleTrackVisibility({
+ trackId: track.id,
+ });
+ }}
+ >
+
+
+ {canTrackBeHidden(track) && track.hidden
+ ? "Show track"
+ : "Hide track"}
+
+
+ {
+ e.stopPropagation();
+ timeline.removeTrack({
+ trackId: track.id,
+ });
+ }}
+ variant="destructive"
+ >
+
+ Delete track
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+ );
}
function TrackIcon({ track }: { track: TimelineTrack }) {
- return (
- <>
- {track.type === "media" && (
-
- )}
- {track.type === "text" && (
-
- )}
- {track.type === "audio" && (
-
- )}
- >
- );
+ return <>{TRACK_ICONS[track.type]}>;
+}
+
+function TrackToggleIcon({
+ isOff,
+ icons,
+ onClick,
+}: {
+ isOff: boolean;
+ icons: {
+ on: IconSvgElement;
+ off: IconSvgElement;
+ };
+ onClick: () => void;
+}) {
+ return (
+ <>
+ {isOff ? (
+
+ ) : (
+
+ )}
+ >
+ );
}
diff --git a/apps/web/src/components/editor/timeline/snap-indicator.tsx b/apps/web/src/components/editor/timeline/snap-indicator.tsx
new file mode 100644
index 00000000..6bfdf086
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/snap-indicator.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
+import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+import type { TimelineTrack } from "@/types/timeline";
+
+interface SnapIndicatorProps {
+ snapPoint: SnapPoint | null;
+ zoomLevel: number;
+ isVisible: boolean;
+ tracks: TimelineTrack[];
+ timelineRef: React.RefObject;
+ trackLabelsRef?: React.RefObject;
+ tracksScrollRef: React.RefObject;
+}
+
+export function SnapIndicator({
+ snapPoint,
+ zoomLevel,
+ isVisible,
+ tracks,
+ timelineRef,
+ trackLabelsRef,
+ tracksScrollRef,
+}: SnapIndicatorProps) {
+ const { leftPosition, topPosition, height } = useSnapIndicatorPosition({
+ snapPoint,
+ zoomLevel,
+ tracks,
+ timelineRef,
+ trackLabelsRef,
+ tracksScrollRef,
+ });
+
+ if (!isVisible || !snapPoint) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx
deleted file mode 100644
index 7def2985..00000000
--- a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-"use client";
-
-import { cn } from "@/lib/utils";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { TimelineTrack } from "@/types/timeline";
-import { MediaFile } from "@/types/media";
-import { TProject } from "@/types/project";
-import { useSceneStore } from "@/stores/scene-store";
-
-interface CacheSegment {
- startTime: number;
- endTime: number;
- cached: boolean;
-}
-
-interface TimelineCacheIndicatorProps {
- duration: number;
- zoomLevel: number;
- tracks: TimelineTrack[];
- mediaFiles: MediaFile[];
- activeProject: TProject | null;
- getRenderStatus: (
- time: number,
- tracks: TimelineTrack[],
- mediaFiles: MediaFile[],
- activeProject: TProject | null,
- sceneId?: string
- ) => "cached" | "not-cached";
-}
-
-export function TimelineCacheIndicator({
- duration,
- zoomLevel,
- tracks,
- mediaFiles,
- activeProject,
- getRenderStatus,
-}: TimelineCacheIndicatorProps) {
- const { currentScene } = useSceneStore();
-
- // Calculate cache segments by sampling the timeline
- const calculateCacheSegments = (): CacheSegment[] => {
- const segments: CacheSegment[] = [];
- const sampleRate = 10; // Sample every 0.1 seconds
- const totalSamples = Math.ceil(duration * sampleRate);
-
- if (totalSamples === 0) {
- return [{ startTime: 0, endTime: duration, cached: false }];
- }
-
- let currentSegment: CacheSegment | null = null;
-
- for (let i = 0; i <= totalSamples; i++) {
- const time = i / sampleRate;
- const cached =
- getRenderStatus(
- time,
- tracks,
- mediaFiles,
- activeProject,
- currentScene?.id
- ) === "cached";
-
- if (!currentSegment) {
- // Start first segment
- currentSegment = {
- startTime: time,
- endTime: time,
- cached,
- };
- } else if (currentSegment.cached === cached) {
- // Extend current segment
- currentSegment.endTime = time;
- } else {
- // Finish current segment and start new one
- segments.push(currentSegment);
- currentSegment = {
- startTime: time,
- endTime: time,
- cached,
- };
- }
- }
-
- // Add the last segment
- if (currentSegment) {
- currentSegment.endTime = duration;
- segments.push(currentSegment);
- }
-
- return segments;
- };
-
- const cacheSegments = calculateCacheSegments();
-
- return (
-
- {cacheSegments.map((segment, index) => {
- const startX =
- segment.startTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const endX =
- segment.endTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const width = endX - startX;
-
- return (
-
- );
- })}
-
- );
-}
diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx
index ca47d3ca..fd292ad8 100644
--- a/apps/web/src/components/editor/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-element.tsx
@@ -1,387 +1,554 @@
"use client";
+import { useEditor } from "@/hooks/use-editor";
+import { useAssetsPanelStore } from "@/stores/assets-panel-store";
+import AudioWaveform from "./audio-waveform";
+import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
+import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import {
- Scissors,
- Trash2,
- Copy,
- Search,
- RefreshCw,
- EyeOff,
- Eye,
- Volume2,
- VolumeX,
-} from "lucide-react";
-import { useMediaStore } from "@/stores/media-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import AudioWaveform from "../audio-waveform";
-import { toast } from "sonner";
-import { TimelineElementProps, MediaElement } from "@/types/timeline";
-import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
+ getTrackClasses,
+ getTrackHeight,
+ canElementHaveAudio,
+ canElementBeHidden,
+ hasMediaId,
+} from "@/lib/timeline";
import {
- getTrackElementClasses,
- TIMELINE_CONSTANTS,
- getTrackHeight,
-} from "@/constants/timeline-constants";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuSeparator,
- ContextMenuTrigger,
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
} from "../../ui/context-menu";
-import { useMediaPanelStore } from "../media-panel/store";
+import type {
+ TimelineElement as TimelineElementType,
+ TimelineTrack,
+ ElementDragState,
+} from "@/types/timeline";
+import type { MediaAsset } from "@/types/assets";
+import { mediaSupportsAudio } from "@/lib/media/media-utils";
+import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions";
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import Image from "next/image";
+import {
+ ScissorIcon,
+ Delete02Icon,
+ Copy01Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ VolumeHighIcon,
+ VolumeOffIcon,
+ VolumeMute02Icon,
+ Search01Icon,
+ Exchange01Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { uppercase } from "@/utils/string";
+import type { ComponentProps } from "react";
+
+function getDisplayShortcut(action: TAction) {
+ const { defaultShortcuts } = getActionDefinition(action);
+ if (!defaultShortcuts?.length) {
+ return "";
+ }
+
+ return uppercase({
+ string: defaultShortcuts[0].replace("+", " "),
+ });
+}
+
+interface TimelineElementProps {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ zoomLevel: number;
+ isSelected: boolean;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+ onResizeStateChange?: (params: { isResizing: boolean }) => void;
+ onElementMouseDown: (
+ e: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
+ dragState: ElementDragState;
+}
export function TimelineElement({
- element,
- track,
- zoomLevel,
- isSelected,
- onElementMouseDown,
- onElementClick,
+ element,
+ track,
+ zoomLevel,
+ isSelected,
+ onSnapPointChange,
+ onResizeStateChange,
+ onElementMouseDown,
+ onElementClick,
+ dragState,
}: TimelineElementProps) {
- const { mediaFiles } = useMediaStore();
- const {
- dragState,
- copySelected,
- selectedElements,
- deleteSelected,
- splitSelected,
- toggleSelectedHidden,
- toggleSelectedMuted,
- duplicateElement,
- revealElementInMedia,
- replaceElementWithFile,
- getContextMenuState,
- } = useTimelineStore();
- const { currentTime } = usePlaybackStore();
-
- const mediaItem =
- element.type === "media"
- ? mediaFiles.find((file) => file.id === element.mediaId)
- : null;
- const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video";
-
- const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } =
- useTimelineElementResize({
- element,
- track,
- zoomLevel,
- });
-
- const {
- isMultipleSelected,
- isCurrentElementSelected,
- hasAudioElements,
- canSplitSelected,
- } = getContextMenuState(track.id, element.id);
-
- const effectiveDuration =
- element.duration - element.trimStart - element.trimEnd;
- const elementWidth = Math.max(
- TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
- effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
- );
-
- const isBeingDragged = dragState.elementId === element.id;
- const elementStartTime =
- isBeingDragged && dragState.isDragging
- ? dragState.currentTime
- : element.startTime;
-
- const elementLeft = elementStartTime * 50 * zoomLevel;
-
- const handleElementSplitContext = (e: React.MouseEvent) => {
- e.stopPropagation();
- splitSelected(
- currentTime,
- isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
- isMultipleSelected && isCurrentElementSelected ? undefined : element.id
- );
- };
-
- const handleElementDuplicateContext = (e: React.MouseEvent) => {
- e.stopPropagation();
- duplicateElement(track.id, element.id);
- };
-
- const handleElementCopyContext = (e: React.MouseEvent) => {
- e.stopPropagation();
- copySelected();
- };
-
- const handleElementDeleteContext = (e: React.MouseEvent) => {
- e.stopPropagation();
- deleteSelected(
- isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
- isMultipleSelected && isCurrentElementSelected ? undefined : element.id
- );
- };
-
- const handleToggleElementContext = (e: React.MouseEvent) => {
- e.stopPropagation();
-
- if (hasAudio && element.type === "media") {
- toggleSelectedMuted(
- isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
- isMultipleSelected && isCurrentElementSelected ? undefined : element.id
- );
- } else {
- toggleSelectedHidden(
- isMultipleSelected && isCurrentElementSelected ? undefined : track.id,
- isMultipleSelected && isCurrentElementSelected ? undefined : element.id
- );
- }
- };
-
- const handleReplaceClip = (e: React.MouseEvent) => {
- e.stopPropagation();
-
- const input = document.createElement("input");
- input.type = "file";
- input.accept = "video/*,audio/*,image/*";
- input.onchange = async (e) => {
- const file = (e.target as HTMLInputElement).files?.[0];
- if (file) {
- await replaceElementWithFile(track.id, element.id, file);
- }
- };
- input.click();
- };
-
- const handleRevealInMedia = (e: React.MouseEvent) => {
- e.stopPropagation();
- revealElementInMedia(element.id);
- };
-
- const renderElementContent = () => {
- if (element.type === "text") {
- return (
-
- {element.content}
-
- );
- }
-
- const mediaItem = mediaFiles.find((file) => file.id === element.mediaId);
- if (!mediaItem) {
- return (
-
- {element.name}
-
- );
- }
-
- if (
- mediaItem.type === "image" ||
- (mediaItem.type === "video" && mediaItem.thumbnailUrl)
- ) {
- const trackHeight = getTrackHeight(track.type);
- const tileWidth = trackHeight * (16 / 9);
-
- const imageUrl =
- mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl;
-
- return (
-
- );
- }
-
- if (mediaItem.type === "audio") {
- return (
-
- );
- }
-
- return (
-
- {element.name}
-
- );
- };
-
- const handleElementMouseDown = (e: React.MouseEvent) => {
- if (onElementMouseDown) {
- onElementMouseDown(e, element);
- }
- };
-
- const isMuted = element.type === "media" && element.muted;
-
- return (
-
-
-
-
onElementClick && onElementClick(e, element)}
- onMouseDown={handleElementMouseDown}
- onContextMenu={(e) =>
- onElementMouseDown && onElementMouseDown(e, element)
- }
- >
-
- {renderElementContent()}
-
-
- {(hasAudio ? isMuted : element.hidden) && (
-
- {hasAudio ? (
-
- ) : (
-
- )}
-
- )}
-
- {isSelected && (
- <>
-
handleResizeStart(e, element.id, "left")}
- >
-
-
-
handleResizeStart(e, element.id, "right")}
- >
-
-
- >
- )}
-
-
-
-
- {(!isMultipleSelected ||
- (isMultipleSelected &&
- isCurrentElementSelected &&
- canSplitSelected)) && (
-
-
- {isMultipleSelected && isCurrentElementSelected
- ? `Split ${selectedElements.length} elements at playhead`
- : "Split at playhead"}
-
- )}
-
-
-
- {isMultipleSelected && isCurrentElementSelected
- ? `Copy ${selectedElements.length} elements`
- : "Copy element"}
-
-
-
- {isMultipleSelected && isCurrentElementSelected ? (
- hasAudioElements ? (
-
- ) : (
-
- )
- ) : hasAudio ? (
- isMuted ? (
-
- ) : (
-
- )
- ) : element.hidden ? (
-
- ) : (
-
- )}
-
- {isMultipleSelected && isCurrentElementSelected
- ? hasAudioElements
- ? `Toggle mute ${selectedElements.length} elements`
- : `Toggle visibility ${selectedElements.length} elements`
- : hasAudio
- ? isMuted
- ? "Unmute"
- : "Mute"
- : element.hidden
- ? "Show"
- : "Hide"}{" "}
- {!isMultipleSelected && (element.type === "text" ? "text" : "clip")}
-
-
-
- {!isMultipleSelected && (
-
-
- Duplicate {element.type === "text" ? "text" : "clip"}
-
- )}
-
- {!isMultipleSelected && element.type === "media" && (
- <>
-
-
- Reveal in media
-
-
-
- Replace clip
-
- >
- )}
-
-
-
-
-
- {isMultipleSelected && isCurrentElementSelected
- ? `Delete ${selectedElements.length} elements`
- : `Delete ${element.type === "text" ? "text" : "clip"}`}
-
-
-
- );
+ const editor = useEditor();
+ const { selectedElements } = useElementSelection();
+ const { requestRevealMedia } = useAssetsPanelStore();
+
+ const mediaAssets = editor.media.getAssets();
+ let mediaAsset: MediaAsset | null = null;
+
+ if (hasMediaId(element)) {
+ mediaAsset =
+ mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
+ }
+
+ const hasAudio = mediaSupportsAudio({ media: mediaAsset });
+
+ const { handleResizeStart, isResizing, currentStartTime, currentDuration } =
+ useTimelineElementResize({
+ element,
+ track,
+ zoomLevel,
+ onSnapPointChange,
+ onResizeStateChange,
+ });
+
+ const isCurrentElementSelected = selectedElements.some(
+ (selected) =>
+ selected.elementId === element.id && selected.trackId === track.id,
+ );
+
+ const isBeingDragged = dragState.elementId === element.id;
+ const dragOffsetY =
+ isBeingDragged && dragState.isDragging
+ ? dragState.currentMouseY - dragState.startMouseY
+ : 0;
+ const elementStartTime =
+ isBeingDragged && dragState.isDragging
+ ? dragState.currentTime
+ : element.startTime;
+ const displayedStartTime = isResizing ? currentStartTime : elementStartTime;
+ const displayedDuration = isResizing ? currentDuration : element.duration;
+ const elementWidth =
+ displayedDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const elementLeft = displayedStartTime * 50 * zoomLevel;
+
+ const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
+ event.stopPropagation();
+ if (hasMediaId(element)) {
+ requestRevealMedia(element.mediaId);
+ }
+ };
+
+ const isMuted = canElementHaveAudio(element) && element.muted === true;
+
+ return (
+
+
+
+
+
+
+
+ }>
+ Split
+
+
+ {canElementHaveAudio(element) && hasAudio && (
+ 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ isMuted={isMuted}
+ />
+ )}
+ {canElementBeHidden(element) && (
+ 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ />
+ )}
+ {selectedElements.length === 1 && (
+ }>
+ Duplicate
+
+ )}
+ {selectedElements.length === 1 && hasMediaId(element) && (
+ <>
+ }
+ onClick={(event) => handleRevealInMedia({ event })}
+ >
+ Reveal media
+
+ }
+ disabled
+ >
+ Replace media
+
+ >
+ )}
+
+ 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ elementType={element.type}
+ selectedCount={selectedElements.length}
+ />
+
+
+ );
+}
+
+function ElementInner({
+ element,
+ track,
+ isSelected,
+ isBeingDragged,
+ hasAudio,
+ isMuted,
+ mediaAssets,
+ onElementClick,
+ onElementMouseDown,
+ handleResizeStart,
+}: {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ isSelected: boolean;
+ isBeingDragged: boolean;
+ hasAudio: boolean;
+ isMuted: boolean;
+ mediaAssets: MediaAsset[];
+ onElementClick: (e: React.MouseEvent, element: TimelineElementType) => void;
+ onElementMouseDown: (
+ e: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ handleResizeStart: (params: {
+ e: React.MouseEvent;
+ elementId: string;
+ side: "left" | "right";
+ }) => void;
+}) {
+ return (
+
+
onElementClick(e, element)}
+ onMouseDown={(e) => onElementMouseDown(e, element)}
+ >
+
+
+
+
+ {(hasAudio
+ ? isMuted
+ : canElementBeHidden(element) && element.hidden) && (
+
+ {hasAudio ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ {isSelected && (
+ <>
+
+
+ >
+ )}
+
+ );
+}
+
+function ResizeHandle({
+ side,
+ elementId,
+ handleResizeStart,
+}: {
+ side: "left" | "right";
+ elementId: string;
+ handleResizeStart: (params: {
+ e: React.MouseEvent;
+ elementId: string;
+ side: "left" | "right";
+ }) => void;
+}) {
+ const isLeft = side === "left";
+ return (
+ handleResizeStart({ e, elementId, side })}
+ aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
+ >
+
+
+ );
+}
+
+function ElementContent({
+ element,
+ track,
+ isSelected,
+ mediaAssets,
+}: {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ isSelected: boolean;
+ mediaAssets: MediaAsset[];
+}) {
+ if (element.type === "text") {
+ return (
+
+ {element.content}
+
+ );
+ }
+
+ if (element.type === "sticker") {
+ return (
+
+
+ {element.name}
+
+ );
+ }
+
+ if (element.type === "audio") {
+ const audioBuffer =
+ element.sourceType === "library" ? element.buffer : undefined;
+
+ const audioUrl =
+ element.sourceType === "library"
+ ? element.sourceUrl
+ : mediaAssets.find((asset) => asset.id === element.mediaId)?.url;
+
+ if (audioBuffer || audioUrl) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {element.name}
+
+ );
+ }
+
+ const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
+ if (!mediaAsset) {
+ return (
+
+ {element.name}
+
+ );
+ }
+
+ if (
+ mediaAsset.type === "image" ||
+ (mediaAsset.type === "video" && mediaAsset.thumbnailUrl)
+ ) {
+ const trackHeight = getTrackHeight({ type: track.type });
+ const tileWidth = trackHeight * (16 / 9);
+ const imageUrl =
+ mediaAsset.type === "image" ? mediaAsset.url : mediaAsset.thumbnailUrl;
+
+ return (
+
+ );
+ }
+
+ return (
+ {element.name}
+ );
+}
+
+function CopyMenuItem() {
+ return (
+ }>
+ Copy
+
+ );
+}
+
+function MuteMenuItem({
+ isMultipleSelected,
+ isCurrentElementSelected,
+ isMuted,
+}: {
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+ isMuted: boolean;
+}) {
+ const getIcon = () => {
+ if (isMultipleSelected && isCurrentElementSelected) {
+ return ;
+ }
+ return isMuted ? (
+
+ ) : (
+
+ );
+ };
+
+ return (
+
+ {isMuted ? "Unmute" : "Mute"}
+
+ );
+}
+
+function VisibilityMenuItem({
+ element,
+ isMultipleSelected,
+ isCurrentElementSelected,
+}: {
+ element: TimelineElementType;
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+}) {
+ const isHidden = canElementBeHidden(element) && element.hidden;
+
+ const getIcon = () => {
+ if (isMultipleSelected && isCurrentElementSelected) {
+ return ;
+ }
+ return isHidden ? (
+
+ ) : (
+
+ );
+ };
+
+ return (
+
+ {isHidden ? "Show" : "Hide"}
+
+ );
+}
+
+function DeleteMenuItem({
+ isMultipleSelected,
+ isCurrentElementSelected,
+ elementType,
+ selectedCount,
+}: {
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+ elementType: TimelineElementType["type"];
+ selectedCount: number;
+}) {
+ return (
+ }
+ >
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Delete ${selectedCount} elements`
+ : `Delete ${elementType === "text" ? "text" : "clip"}`}
+
+ );
+}
+
+function ActionMenuItem({
+ action,
+ children,
+ ...props
+}: Omit, "onClick" | "textRight"> & {
+ action: TAction;
+}) {
+ return (
+ {
+ event.stopPropagation();
+ invokeAction(action);
+ }}
+ textRight={getDisplayShortcut(action)}
+ {...props}
+ >
+ {children}
+
+ );
}
diff --git a/apps/web/src/components/editor/timeline/timeline-marker.tsx b/apps/web/src/components/editor/timeline/timeline-marker.tsx
deleted file mode 100644
index 1351a457..00000000
--- a/apps/web/src/components/editor/timeline/timeline-marker.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-"use client";
-
-import { cn } from "@/lib/utils";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-
-interface TimelineMarkerProps {
- time: number;
- zoomLevel: number;
- interval: number;
- isMainMarker: boolean;
-}
-
-export function TimelineMarker({
- time,
- zoomLevel,
- interval,
- isMainMarker,
-}: TimelineMarkerProps) {
- return (
-
-
- {(() => {
- const formatTime = (seconds: number) => {
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
- const secs = seconds % 60;
-
- if (hours > 0) {
- return `${hours}:${minutes
- .toString()
- .padStart(2, "0")}:${Math.floor(secs)
- .toString()
- .padStart(2, "0")}`;
- }
- if (minutes > 0) {
- return `${minutes}:${Math.floor(secs)
- .toString()
- .padStart(2, "0")}`;
- }
- if (interval >= 1) {
- return `${Math.floor(secs)}s`;
- }
- return `${secs.toFixed(1)}s`;
- };
- return formatTime(time);
- })()}
-
-
- );
-}
diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx
index a1be5b26..58d0adb0 100644
--- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx
@@ -1,172 +1,92 @@
"use client";
-import { useRef, useState, useEffect } from "react";
-import { TimelineTrack } from "@/types/timeline";
+import { useRef } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
+import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
+import { useEditor } from "@/hooks/use-editor";
interface TimelinePlayheadProps {
- currentTime: number;
- duration: number;
- zoomLevel: number;
- tracks: TimelineTrack[];
- seek: (time: number) => void;
- rulerRef: React.RefObject;
- rulerScrollRef: React.RefObject;
- tracksScrollRef: React.RefObject;
- trackLabelsRef?: React.RefObject;
- timelineRef: React.RefObject;
- playheadRef?: React.RefObject;
- isSnappingToPlayhead?: boolean;
+ zoomLevel: number;
+ rulerRef: React.RefObject;
+ rulerScrollRef: React.RefObject;
+ tracksScrollRef: React.RefObject;
+ timelineRef: React.RefObject;
+ playheadRef?: React.RefObject;
+ isSnappingToPlayhead?: boolean;
}
export function TimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- tracks,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- trackLabelsRef,
- timelineRef,
- playheadRef: externalPlayheadRef,
- isSnappingToPlayhead = false,
+ zoomLevel,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ timelineRef,
+ playheadRef: externalPlayheadRef,
+ isSnappingToPlayhead = false,
}: TimelinePlayheadProps) {
- const internalPlayheadRef = useRef(null);
- const playheadRef = externalPlayheadRef || internalPlayheadRef;
- const [scrollLeft, setScrollLeft] = useState(0);
-
- const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
- });
-
- // Track scroll position to lock playhead to frame
- useEffect(() => {
- const tracksViewport = tracksScrollRef.current;
-
- if (!tracksViewport) return;
-
- const handleScroll = () => {
- setScrollLeft(tracksViewport.scrollLeft);
- };
-
- // Set initial scroll position
- setScrollLeft(tracksViewport.scrollLeft);
-
- tracksViewport.addEventListener("scroll", handleScroll);
- return () => tracksViewport.removeEventListener("scroll", handleScroll);
- }, [tracksScrollRef]);
-
- // Use timeline container height minus a few pixels for breathing room
- const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
- const totalHeight = timelineContainerHeight - 4;
-
- // 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;
-
- // Calculate position locked to timeline content (accounting for scroll)
- const timelinePosition =
- playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const rawLeftPosition = trackLabelsWidth + timelinePosition - scrollLeft;
-
- // Get the timeline content width and viewport width for right boundary
- const timelineContentWidth =
- duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const tracksViewport = tracksScrollRef.current;
- const viewportWidth = tracksViewport?.clientWidth || 1000;
-
- // Constrain playhead to never appear outside the timeline area
- const leftBoundary = trackLabelsWidth;
- const rightBoundary = Math.min(
- trackLabelsWidth + timelineContentWidth - scrollLeft, // Don't go beyond timeline content
- trackLabelsWidth + viewportWidth // Don't go beyond viewport
- );
-
- const leftPosition = Math.max(
- leftBoundary,
- Math.min(rightBoundary, rawLeftPosition)
- );
-
- // Debug logging when playhead might go outside
- if (rawLeftPosition < leftBoundary || rawLeftPosition > rightBoundary) {
- console.log(
- "PLAYHEAD VISUAL DEBUG:",
- JSON.stringify({
- playheadPosition,
- timelinePosition,
- trackLabelsWidth,
- scrollLeft,
- rawLeftPosition,
- constrainedLeftPosition: leftPosition,
- leftBoundary,
- rightBoundary,
- timelineContentWidth,
- viewportWidth,
- zoomLevel,
- })
- );
- }
-
- return (
-
- {/* The playhead line spanning full height */}
-
-
- {/* Playhead dot indicator at the top (in ruler area) */}
-
-
- );
+ const editor = useEditor();
+ const duration = editor.timeline.getTotalDuration();
+ const internalPlayheadRef = useRef(null);
+ const playheadRef = externalPlayheadRef || internalPlayheadRef;
+
+ const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
+ zoomLevel,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
+
+ const timelineContainerHeight =
+ tracksScrollRef.current?.clientHeight ??
+ timelineRef.current?.clientHeight ??
+ 400;
+ const totalHeight = Math.max(0, timelineContainerHeight - 4);
+
+ const timelinePosition =
+ playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const leftPosition = timelinePosition;
+
+ const handlePlayheadKeyDown = (
+ event: React.KeyboardEvent,
+ ) => {
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
+
+ event.preventDefault();
+ const step = 1 / Math.max(1, editor.project.getActive().settings.fps);
+ const direction = event.key === "ArrowRight" ? 1 : -1;
+ const nextTime = Math.max(
+ 0,
+ Math.min(duration, playheadPosition + direction * step),
+ );
+
+ editor.playback.seek({ time: nextTime });
+ };
+
+ return (
+
+ );
}
-
-// Also export a hook for getting ruler handlers
-export function useTimelinePlayheadRuler({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
-}: Omit) {
- const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
- });
-
- return { handleRulerMouseDown, isDraggingRuler };
-}
-
-export { TimelinePlayhead as default };
diff --git a/apps/web/src/components/editor/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/timeline/timeline-ruler.tsx
new file mode 100644
index 00000000..87702a4f
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/timeline-ruler.tsx
@@ -0,0 +1,83 @@
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { DEFAULT_FPS } from "@/constants/project-constants";
+import { useEditor } from "@/hooks/use-editor";
+import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils";
+import { TimelineTick } from "./timeline-tick";
+
+interface TimelineRulerProps {
+ zoomLevel: number;
+ dynamicTimelineWidth: number;
+ rulerRef: React.RefObject;
+ handleWheel: (e: React.WheelEvent) => void;
+ handleTimelineContentClick: (e: React.MouseEvent) => void;
+ handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
+ handleRulerMouseDown: (e: React.MouseEvent) => void;
+}
+
+export function TimelineRuler({
+ zoomLevel,
+ dynamicTimelineWidth,
+ rulerRef,
+ handleWheel,
+ handleTimelineContentClick,
+ handleRulerTrackingMouseDown,
+ handleRulerMouseDown,
+}: TimelineRulerProps) {
+ const editor = useEditor();
+ const duration = editor.timeline.getTotalDuration();
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const visibleDuration = dynamicTimelineWidth / pixelsPerSecond;
+ const effectiveDuration = Math.max(duration, visibleDuration);
+ const project = editor.project.getActive();
+ const fps = project?.settings.fps ?? DEFAULT_FPS;
+ const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({
+ zoomLevel,
+ fps,
+ });
+ const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1;
+
+ const timelineTicks: Array = [];
+ for (let tickIndex = 0; tickIndex < tickCount; tickIndex += 1) {
+ const time = tickIndex * tickIntervalSeconds;
+ if (time > effectiveDuration) break;
+
+ const showLabel = shouldShowLabel({ time, labelIntervalSeconds });
+ timelineTicks.push(
+ ,
+ );
+ }
+
+ return (
+ {}}
+ >
+
+ {timelineTicks}
+
+
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-tick.tsx b/apps/web/src/components/editor/timeline/timeline-tick.tsx
new file mode 100644
index 00000000..9402d25e
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/timeline-tick.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
+
+interface TimelineTickProps {
+ time: number;
+ zoomLevel: number;
+ fps: number;
+ showLabel: boolean;
+}
+
+export function TimelineTick({
+ time,
+ zoomLevel,
+ fps,
+ showLabel,
+}: TimelineTickProps) {
+ const leftPosition = time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+
+ if (showLabel) {
+ const label = formatRulerLabel({ timeInSeconds: time, fps });
+ return (
+
+ {label}
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
index 80c6ce08..822bb438 100644
--- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
@@ -1,397 +1,355 @@
-import { usePlaybackStore } from "@/stores/playback-store";
-import { useProjectStore } from "@/stores/project-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useSceneStore } from "@/stores/scene-store";
-import { toast } from "sonner";
+import { useEditor } from "@/hooks/use-editor";
import {
- TooltipProvider,
- Tooltip,
- TooltipTrigger,
- TooltipContent,
+ TooltipProvider,
+ Tooltip,
+ TooltipTrigger,
+ TooltipContent,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
+import { SkipBack, SplitSquareHorizontal } from "lucide-react";
import {
- Pause,
- Play,
- SkipBack,
- Bookmark,
- Magnet,
- Link,
- ZoomOut,
- ZoomIn,
- Copy,
- Trash2,
- Snowflake,
- ArrowLeftToLine,
- ArrowRightToLine,
- SplitSquareHorizontal,
- Scissors,
- LayersIcon,
-} from "lucide-react";
-import {
- SplitButton,
- SplitButtonLeft,
- SplitButtonRight,
- SplitButtonSeparator,
+ SplitButton,
+ SplitButtonLeft,
+ SplitButtonRight,
+ SplitButtonSeparator,
} from "@/components/ui/split-button";
import { Slider } from "@/components/ui/slider";
-import { DEFAULT_FPS } from "@/stores/project-store";
import { formatTimeCode } from "@/lib/time";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { EditableTimecode } from "@/components/ui/editable-timecode";
+import { EditableTimecode } from "@/components/editable-timecode";
import { ScenesView } from "../scenes-view";
+import { type TAction, invokeAction } from "@/lib/actions";
+import { cn } from "@/utils/ui";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Bookmark02Icon,
+ Delete02Icon,
+ SnowIcon,
+ ScissorIcon,
+ MagnetIcon,
+ Link04Icon,
+ SearchAddIcon,
+ SearchMinusIcon,
+ PauseIcon,
+ PlayIcon,
+ Copy01Icon,
+ AlignLeftIcon,
+ AlignRightIcon,
+ Layers01Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
export function TimelineToolbar({
- zoomLevel,
- setZoomLevel,
+ zoomLevel,
+ minZoom,
+ setZoomLevel,
}: {
- zoomLevel: number;
- setZoomLevel: (zoom: number) => void;
+ zoomLevel: number;
+ minZoom: number;
+ setZoomLevel: ({ zoom }: { zoom: number }) => void;
}) {
- const {
- tracks,
- addTrack,
- addElementToTrack,
- selectedElements,
- clearSelectedElements,
- deleteSelected,
- splitSelected,
- splitAndKeepLeft,
- splitAndKeepRight,
- separateAudio,
- snappingEnabled,
- toggleSnapping,
- rippleEditingEnabled,
- toggleRippleEditing,
- } = useTimelineStore();
- const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
- const { toggleBookmark, isBookmarked, activeProject } = useProjectStore();
- const { scenes, currentScene } = useSceneStore();
+ const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
+ const newZoomLevel =
+ direction === "in"
+ ? Math.min(
+ TIMELINE_CONSTANTS.ZOOM_MAX,
+ zoomLevel + TIMELINE_CONSTANTS.ZOOM_STEP,
+ )
+ : Math.max(minZoom, zoomLevel - TIMELINE_CONSTANTS.ZOOM_STEP);
+ setZoomLevel({ zoom: newZoomLevel });
+ };
+
+ return (
+
+
+
+
+
+
+ setZoomLevel({ zoom })}
+ onZoom={handleZoom}
+ />
+
+
+ );
+}
+
+function ToolbarLeftSection() {
+ const editor = useEditor();
+ const currentTime = editor.playback.getCurrentTime();
+ const isPlaying = editor.playback.getIsPlaying();
+ const currentBookmarked = editor.scenes.isBookmarked({ time: currentTime });
+
+ const handleAction = ({
+ action,
+ event,
+ }: {
+ action: TAction;
+ event: React.MouseEvent;
+ }) => {
+ event.stopPropagation();
+ invokeAction(action);
+ };
+
+ return (
+
+
+
+ ) : (
+
+ )
+ }
+ tooltip={isPlaying ? "Pause" : "Play"}
+ onClick={({ event }) =>
+ handleAction({ action: "toggle-play", event })
+ }
+ />
+
+ }
+ tooltip="Go to start"
+ onClick={({ event }) => handleAction({ action: "goto-start", event })}
+ />
+
+
+
+
- const handleSplitSelected = () => {
- splitSelected(currentTime);
- };
+
- const handleDuplicateSelected = () => {
- if (selectedElements.length === 0) return;
- const canDuplicate = selectedElements.length === 1;
- if (!canDuplicate) return;
+ }
+ tooltip="Split element"
+ onClick={({ event }) =>
+ handleAction({ action: "split", event })
+ }
+ />
- selectedElements.forEach(({ trackId, elementId }) => {
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((el) => 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,
- });
- }
- });
- clearSelectedElements();
- };
+ }
+ tooltip="Split left"
+ onClick={({ event }) =>
+ handleAction({ action: "split-left", event })
+ }
+ />
- const handleFreezeSelected = () => {
- toast.info("Freeze frame functionality coming soon!");
- };
+ }
+ tooltip="Split right"
+ onClick={({ event }) =>
+ handleAction({ action: "split-right", event })
+ }
+ />
- const handleSplitAndKeepLeft = () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element");
- return;
- }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return;
- 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 selected element");
- return;
- }
- splitAndKeepLeft(trackId, elementId, currentTime);
- };
+ }
+ tooltip="Coming soon" /* separate audio */
+ disabled={true}
+ onClick={({ event: _event }) => {}}
+ />
- const handleSplitAndKeepRight = () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one element");
- return;
- }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- const element = track?.elements.find((c) => c.id === elementId);
- if (!element) return;
- 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 selected element");
- return;
- }
- splitAndKeepRight(trackId, elementId, currentTime);
- };
+ }
+ tooltip="Duplicate element"
+ onClick={({ event }) =>
+ handleAction({ action: "duplicate-selected", event })
+ }
+ />
- const handleSeparateAudio = () => {
- if (selectedElements.length !== 1) {
- toast.error("Select exactly one media element to separate audio");
- return;
- }
- const { trackId, elementId } = selectedElements[0];
- const track = tracks.find((t) => t.id === trackId);
- if (!track || track.type !== "media") {
- toast.error("Select a media element to separate audio");
- return;
- }
- separateAudio(trackId, elementId);
- };
+ }
+ tooltip="Coming soon" /* freeze frame */
+ disabled={true}
+ onClick={({ event: _event }) => {}}
+ />
- const handleDeleteSelected = () => {
- deleteSelected();
- };
+ }
+ tooltip="Delete element"
+ onClick={({ event }) =>
+ handleAction({ action: "delete-selected", event })
+ }
+ />
- const handleZoomIn = () => {
- setZoomLevel(Math.min(4, zoomLevel + 0.25));
- };
+
- const handleZoomOut = () => {
- setZoomLevel(Math.max(0.25, zoomLevel - 0.25));
- };
+
+
+ }
+ tooltip={currentBookmarked ? "Remove bookmark" : "Add bookmark"}
+ onClick={({ event }) =>
+ handleAction({ action: "toggle-bookmark", event })
+ }
+ />
+
+
+
+ );
+}
+
+function TimeDisplay() {
+ const editor = useEditor();
+ const currentTime = editor.playback.getCurrentTime();
+ const totalDuration = editor.timeline.getTotalDuration();
+ const fps = editor.project.getActive().settings.fps;
+
+ return (
+
+
editor.playback.seek({ time })}
+ className="text-center"
+ />
+ /
+
+ {formatTimeCode({
+ timeInSeconds: totalDuration,
+ format: "HH:MM:SS:FF",
+ fps,
+ })}
+
+
+ );
+}
+
+function SceneSelector() {
+ const editor = useEditor();
+ const currentScene = editor.scenes.getActiveScene();
+
+ return (
+
+
+ {currentScene?.name || "No Scene"}
+
+
+ {}} type="button">
+
+
+
+
+
+ );
+}
- const handleZoomSliderChange = (values: number[]) => {
- setZoomLevel(values[0]);
- };
+function ToolbarRightSection({
+ zoomLevel,
+ minZoom,
+ onZoomChange,
+ onZoom,
+}: {
+ zoomLevel: number;
+ minZoom: number;
+ onZoomChange: (zoom: number) => void;
+ onZoom: (options: { direction: "in" | "out" }) => void;
+}) {
+ const {
+ snappingEnabled,
+ rippleEditingEnabled,
+ toggleSnapping,
+ toggleRippleEditing,
+ } = useTimelineStore();
- const handleToggleBookmark = async () => {
- await toggleBookmark(currentTime);
- };
+ return (
+
+
+
+ }
+ tooltip="Auto snapping"
+ onClick={() => toggleSnapping()}
+ />
- const currentBookmarked = isBookmarked(currentTime);
- return (
-
-
-
-
-
-
- {isPlaying ? (
-
- ) : (
-
- )}
-
-
-
- {isPlaying ? "Pause (Space)" : "Play (Space)"}
-
-
-
-
- seek(0)}>
-
-
-
- Return to Start (Home / Enter)
-
-
- {/* Time Display */}
-
-
-
- /
-
-
- {formatTimeCode(duration, "HH:MM:SS:FF")}
-
-
- {tracks.length === 0 && (
- <>
-
-
-
- {
- 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
-
-
- Add a test clip to try playback
-
- >
- )}
-
-
-
-
-
-
-
- Split element (Ctrl+S)
-
-
-
-
-
-
-
- Split and keep left (Ctrl+Q)
-
-
-
-
-
-
-
- Split and keep right (Ctrl+W)
-
-
-
-
-
-
-
- Separate audio (Ctrl+D)
-
-
-
-
-
-
-
- Duplicate element (Ctrl+D)
-
-
-
-
-
-
-
- Freeze frame (F)
-
-
-
-
-
-
-
- Delete element (Delete)
-
-
-
-
-
-
-
-
-
- {currentBookmarked ? "Remove bookmark" : "Add bookmark"}
-
-
-
-
-
-
- {currentScene?.name || "No Scene"}
-
-
- {}}>
-
-
-
-
-
-
-
-
-
-
- {snappingEnabled ? (
-
- ) : (
-
- )}
-
-
- Auto snapping
-
-
-
-
-
-
-
-
- {rippleEditingEnabled
- ? "Disable Ripple Editing"
- : "Enable Ripple Editing"}
-
-
-
+
+ }
+ tooltip="Ripple editing"
+ onClick={() => toggleRippleEditing()}
+ />
+
-
-
-
-
-
-
-
-
-
-
-
-
- );
+
+
+
+ onZoom({ direction: "out" })}
+ >
+
+
+ onZoomChange(values[0])}
+ min={minZoom}
+ max={TIMELINE_CONSTANTS.ZOOM_MAX}
+ step={TIMELINE_CONSTANTS.ZOOM_STEP}
+ />
+ onZoom({ direction: "in" })}
+ >
+
+
+
+
+ );
+}
+
+function ToolbarButton({
+ icon,
+ tooltip,
+ onClick,
+ disabled,
+}: {
+ icon: React.ReactNode;
+ tooltip: string;
+ onClick: ({ event }: { event: React.MouseEvent }) => void;
+ disabled?: boolean;
+}) {
+ return (
+
+
+ onClick({ event })}
+ className={disabled ? "cursor-not-allowed opacity-50" : ""}
+ >
+ {icon}
+
+
+ {tooltip}
+
+ );
}
diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx
index 7cfde931..29d58315 100644
--- a/apps/web/src/components/editor/timeline/timeline-track.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-track.tsx
@@ -1,1199 +1,113 @@
"use client";
-import { useRef, useState, useEffect } from "react";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { toast } from "sonner";
-import { processMediaFiles } from "@/lib/media-processing";
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { TimelineElement } from "./timeline-element";
-import {
- TimelineTrack,
- getMainTrack,
- canElementGoOnTrack,
-} from "@/types/timeline";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
-import type {
- TimelineElement as TimelineElementType,
- DragData,
- TrackType,
-} from "@/types/timeline";
-import {
- snapTimeToFrame,
- TIMELINE_CONSTANTS,
-} from "@/constants/timeline-constants";
-import { DEFAULT_FPS, useProjectStore } from "@/stores/project-store";
-import { useTimelineSnapping, SnapPoint } from "@/hooks/use-timeline-snapping";
-import { useEdgeAutoScroll } from "@/hooks/use-edge-auto-scroll";
+import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineElement as TimelineElementType } from "@/types/timeline";
+import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
+import type { ElementDragState } from "@/types/timeline";
+import { useEditor } from "@/hooks/use-editor";
+import { cn } from "@/utils/ui";
+
+interface TimelineTrackContentProps {
+ track: TimelineTrack;
+ zoomLevel: number;
+ dragState: ElementDragState;
+ rulerScrollRef: React.RefObject;
+ tracksScrollRef: React.RefObject;
+ lastMouseXRef: React.RefObject;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+ onResizeStateChange?: (params: { isResizing: boolean }) => void;
+ onElementMouseDown: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ }) => void;
+ onElementClick: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ }) => void;
+ onTrackMouseDown?: (event: React.MouseEvent) => void;
+ shouldIgnoreClick?: () => boolean;
+}
export function TimelineTrackContent({
- track,
- zoomLevel,
- onSnapPointChange,
- rulerScrollRef,
- tracksScrollRef,
-}: {
- track: TimelineTrack;
- zoomLevel: number;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
- rulerScrollRef: React.RefObject;
- tracksScrollRef: React.RefObject;
-}) {
- const { mediaFiles } = useMediaStore();
- const {
- tracks,
- addTrack,
- moveElementToTrack,
- updateElementStartTime,
- updateElementStartTimeWithRipple,
- addElementToTrack,
- selectedElements,
- selectElement,
- dragState,
- startDrag: startDragAction,
- updateDragTime,
- endDrag: endDragAction,
- clearSelectedElements,
- insertTrackAt,
- snappingEnabled,
- rippleEditingEnabled,
- } = useTimelineStore();
-
- const { currentTime, duration } = 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
- ) => {
- // Always apply frame snapping first
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
- let finalTime = snapTimeToFrame(dropTime, projectFps);
-
- // Additionally apply element snapping if enabled
- if (snappingEnabled) {
- // 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;
- }
-
- // Only use element snapping if it found a snap point, otherwise keep frame-snapped time
- if (bestSnapResult.snapPoint) {
- finalTime = bestSnapResult.snappedTime;
- }
- }
-
- return finalTime;
- };
-
- const timelineRef = useRef(null);
- const [isDropping, setIsDropping] = useState(false);
- const [dropPosition, setDropPosition] = useState(null);
- const [wouldOverlap, setWouldOverlap] = useState(false);
- const dragCounterRef = useRef(0);
- const [mouseDownLocation, setMouseDownLocation] = useState<{
- x: number;
- y: number;
- } | null>(null);
-
- const lastMouseXRef = useRef(0);
-
- // Set up mouse event listeners for drag
- useEffect(() => {
- if (!dragState.isDragging) return;
-
- const handleMouseMove = (e: MouseEvent) => {
- if (!timelineRef.current) return;
- lastMouseXRef.current = e.clientX;
-
- // On first mouse move during drag, ensure the element is selected
- if (dragState.elementId && dragState.trackId) {
- const isSelected = selectedElements.some(
- (c) =>
- c.trackId === dragState.trackId &&
- c.elementId === dragState.elementId
- );
-
- if (!isSelected) {
- // Select this element (replacing other selections) since we're dragging it
- selectElement(dragState.trackId, dragState.elementId, false);
- }
- }
-
- const timelineRect = timelineRef.current.getBoundingClientRect();
- const mouseX = e.clientX - timelineRect.left;
- const mouseTime = Math.max(
- 0,
- mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
- );
- const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
-
- // Always apply frame snapping first
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
- let finalTime = snapTimeToFrame(adjustedTime, projectFps);
- let snapPoint = null;
-
- // Additionally apply element snapping if enabled
- 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;
- }
-
- // Only use element snapping if it found a snap point, otherwise keep frame-snapped time
- if (bestSnapResult.snapPoint) {
- finalTime = bestSnapResult.snappedTime;
- snapPoint = bestSnapResult.snapPoint;
- }
-
- // Notify parent component about snap point change
- onSnapPointChange?.(snapPoint);
- } else {
- // Clear snap point when element snapping is disabled
- onSnapPointChange?.(null);
- }
-
- updateDragTime(finalTime);
- };
-
- const handleMouseUp = (e: MouseEvent) => {
- if (!dragState.elementId || !dragState.trackId) return;
-
- // If this track initiated the drag, we should handle the mouse up regardless of where it occurs
- const isTrackThatStartedDrag = dragState.trackId === track.id;
-
- const timelineRect = timelineRef.current?.getBoundingClientRect();
- if (!timelineRect) {
- if (isTrackThatStartedDrag) {
- 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;
- }
-
- const isMouseOverThisTrack =
- e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom;
-
- if (!isMouseOverThisTrack && !isTrackThatStartedDrag) return;
-
- const finalTime = dragState.currentTime;
-
- if (isMouseOverThisTrack) {
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const movingElement = sourceTrack?.elements.find(
- (c) => c.id === dragState.elementId
- );
-
- if (movingElement) {
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const movingElementEnd = finalTime + movingElementDuration;
-
- const targetTrack = tracks.find((t) => t.id === track.id);
- const hasOverlap = targetTrack?.elements.some((existingElement) => {
- if (
- dragState.trackId === track.id &&
- existingElement.id === dragState.elementId
- ) {
- return false;
- }
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return finalTime < existingEnd && movingElementEnd > existingStart;
- });
-
- if (!hasOverlap) {
- if (dragState.trackId === track.id) {
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- dragState.elementId,
- finalTime
- );
- } else {
- updateElementStartTime(
- track.id,
- dragState.elementId,
- finalTime
- );
- }
- } else {
- moveElementToTrack(
- dragState.trackId,
- track.id,
- dragState.elementId
- );
- requestAnimationFrame(() => {
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- dragState.elementId!,
- finalTime
- );
- } else {
- updateElementStartTime(
- track.id,
- dragState.elementId!,
- finalTime
- );
- }
- });
- }
- }
- }
- } else if (isTrackThatStartedDrag) {
- // Mouse is not over this track, but this track started the drag
- // This means user released over ruler/outside - update position within same track
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const movingElement = sourceTrack?.elements.find(
- (c) => c.id === dragState.elementId
- );
-
- if (movingElement) {
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const movingElementEnd = finalTime + movingElementDuration;
-
- const hasOverlap = track.elements.some((existingElement) => {
- if (existingElement.id === dragState.elementId) {
- return false;
- }
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return finalTime < existingEnd && movingElementEnd > existingStart;
- });
-
- if (!hasOverlap) {
- 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);
- }
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
-
- return () => {
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- };
- }, [
- dragState.isDragging,
- dragState.clickOffsetTime,
- dragState.elementId,
- dragState.trackId,
- dragState.currentTime,
- zoomLevel,
- tracks,
- track.id,
- updateDragTime,
- updateElementStartTime,
- moveElementToTrack,
- endDragAction,
- selectedElements,
- selectElement,
- onSnapPointChange,
- ]);
-
- useEdgeAutoScroll({
- isActive: dragState.isDragging,
- getMouseClientX: () => lastMouseXRef.current,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
- });
-
- const handleElementMouseDown = (
- e: React.MouseEvent,
- element: TimelineElementType
- ) => {
- setMouseDownLocation({ x: e.clientX, y: e.clientY });
-
- // Detect right-click (button 2) and handle selection without starting drag
- const isRightClick = e.button === 2;
- const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey;
-
- if (isRightClick) {
- // Handle right-click selection
- const isSelected = selectedElements.some(
- (c) => c.trackId === track.id && c.elementId === element.id
- );
-
- // If element is not selected, select it (keep other selections if multi-select)
- if (!isSelected) {
- selectElement(track.id, element.id, isMultiSelect);
- }
- // If element is already selected, keep it selected
-
- // Don't start drag action for right-clicks
- return;
- }
-
- // Handle multi-selection for left-click with modifiers
- if (isMultiSelect) {
- selectElement(track.id, element.id, true);
- }
-
- // Calculate the offset from the left edge of the element to where the user clicked
- const elementElement = e.currentTarget as HTMLElement;
- const elementRect = elementElement.getBoundingClientRect();
- const clickOffsetX = e.clientX - elementRect.left;
- const clickOffsetTime =
- clickOffsetX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
-
- startDragAction(
- element.id,
- track.id,
- e.clientX,
- element.startTime,
- clickOffsetTime
- );
- };
-
- const handleElementClick = (
- e: React.MouseEvent,
- element: TimelineElementType
- ) => {
- e.stopPropagation();
-
- // Check if mouse moved significantly
- if (mouseDownLocation) {
- const deltaX = Math.abs(e.clientX - mouseDownLocation.x);
- const deltaY = Math.abs(e.clientY - mouseDownLocation.y);
- // If it moved more than a few pixels, consider it a drag and not a click.
- if (deltaX > 5 || deltaY > 5) {
- setMouseDownLocation(null); // Reset for next interaction
- return;
- }
- }
-
- // Skip selection logic for multi-selection (handled in mousedown)
- if (e.metaKey || e.ctrlKey || e.shiftKey) {
- return;
- }
-
- // Handle single selection
- const isSelected = selectedElements.some(
- (c) => c.trackId === track.id && c.elementId === element.id
- );
-
- if (!isSelected) {
- // If element is not selected, select it (replacing other selections)
- selectElement(track.id, element.id, false);
- }
- // If element is already selected, keep it selected (do nothing)
- };
-
- const handleTrackDragOver = (e: React.DragEvent) => {
- e.preventDefault();
-
- // Handle both timeline elements and media items
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineElement && !hasMediaItem) return;
-
- // Calculate drop position for overlap checking
- const trackContainer = e.currentTarget.querySelector(
- ".track-elements-container"
- ) as HTMLElement;
- let dropTime = 0;
- if (trackContainer) {
- const rect = trackContainer.getBoundingClientRect();
- const mouseX = Math.max(0, e.clientX - rect.left);
- dropTime = mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
- }
-
- // Check for potential overlaps and show appropriate feedback
- let wouldOverlap = false;
-
- if (hasMediaItem) {
- try {
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (mediaItemData) {
- const dragData: DragData = JSON.parse(mediaItemData);
-
- if (dragData.type === "text") {
- // Text elements have default duration of 5 seconds
- const newElementDuration = 5;
- const snappedTime = getDropSnappedTime(
- dropTime,
- newElementDuration
- );
- const newElementEnd = snappedTime + newElementDuration;
-
- wouldOverlap = track.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return snappedTime < existingEnd && newElementEnd > existingStart;
- });
- } else {
- // Media elements
- const mediaItem = mediaFiles.find(
- (item) => item.id === dragData.id
- );
- if (mediaItem) {
- const newElementDuration = mediaItem.duration || 5;
- const snappedTime = getDropSnappedTime(
- dropTime,
- newElementDuration
- );
- const newElementEnd = snappedTime + newElementDuration;
-
- wouldOverlap = track.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return (
- snappedTime < existingEnd && newElementEnd > existingStart
- );
- });
- }
- }
- }
- } catch (error) {
- // Continue with default behavior
- }
- } else if (hasTimelineElement) {
- try {
- const timelineElementData = e.dataTransfer.getData(
- "application/x-timeline-element"
- );
- if (timelineElementData) {
- const { elementId, trackId: fromTrackId } =
- JSON.parse(timelineElementData);
- const sourceTrack = tracks.find(
- (t: TimelineTrack) => t.id === fromTrackId
- );
- const movingElement = sourceTrack?.elements.find(
- (c: any) => c.id === elementId
- );
-
- if (movingElement) {
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const snappedTime = getDropSnappedTime(
- dropTime,
- movingElementDuration,
- elementId
- );
- const movingElementEnd = snappedTime + movingElementDuration;
-
- wouldOverlap = track.elements.some((existingElement) => {
- if (fromTrackId === track.id && existingElement.id === elementId)
- return false;
-
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
- return (
- snappedTime < existingEnd && movingElementEnd > existingStart
- );
- });
- }
- }
- } catch (error) {
- // Continue with default behavior
- }
- }
-
- if (wouldOverlap) {
- e.dataTransfer.dropEffect = "none";
- setWouldOverlap(true);
- // Use default duration for position indicator
- setDropPosition(getDropSnappedTime(dropTime, 5));
- return;
- }
-
- e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy";
- setWouldOverlap(false);
- // Use default duration for position indicator
- setDropPosition(getDropSnappedTime(dropTime, 5));
- };
-
- const handleTrackDragEnter = (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineElement && !hasMediaItem) return;
-
- dragCounterRef.current++;
- setIsDropping(true);
- };
-
- const handleTrackDragLeave = (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
-
- if (!hasTimelineElement && !hasMediaItem) return;
-
- dragCounterRef.current--;
-
- if (dragCounterRef.current === 0) {
- setIsDropping(false);
- setWouldOverlap(false);
- setDropPosition(null);
- }
- };
-
- const handleTrackDrop = (e: React.DragEvent) => {
- e.preventDefault();
- e.stopPropagation();
-
- // 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);
- setWouldOverlap(false);
-
- const hasTimelineElement = e.dataTransfer.types.includes(
- "application/x-timeline-element"
- );
- const hasMediaItem = e.dataTransfer.types.includes(
- "application/x-media-item"
- );
- const hasFiles = e.dataTransfer.files?.length > 0;
-
- if (!hasTimelineElement && !hasMediaItem && !hasFiles) return;
-
- const trackContainer = e.currentTarget.querySelector(
- ".track-elements-container"
- ) as HTMLElement;
- if (!trackContainer) return;
-
- const rect = trackContainer.getBoundingClientRect();
- const mouseX = Math.max(0, e.clientX - rect.left);
- const mouseY = e.clientY - rect.top; // Get Y position relative to this track
- const newStartTime =
- mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || DEFAULT_FPS;
- const snappedTime = snapTimeToFrame(newStartTime, projectFps);
-
- // Calculate drop position relative to tracks
- const currentTrackIndex = tracks.findIndex((t) => t.id === track.id);
-
- // Determine drop zone within the track (top 20px, middle 20px, bottom 20px)
- let dropPosition: "above" | "on" | "below";
- if (mouseY < 20) {
- dropPosition = "above";
- } else if (mouseY > 40) {
- dropPosition = "below";
- } else {
- dropPosition = "on";
- }
-
- try {
- if (hasTimelineElement) {
- // Handle timeline element movement
- const timelineElementData = e.dataTransfer.getData(
- "application/x-timeline-element"
- );
- if (!timelineElementData) return;
-
- const {
- elementId,
- trackId: fromTrackId,
- clickOffsetTime = 0,
- } = JSON.parse(timelineElementData);
-
- // Find the element being moved
- const sourceTrack = tracks.find(
- (t: TimelineTrack) => t.id === fromTrackId
- );
- const movingElement = sourceTrack?.elements.find(
- (c: TimelineElementType) => c.id === elementId
- );
-
- if (!movingElement) {
- toast.error("Element not found");
- return;
- }
-
- // 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) => {
- // Skip the element being moved if it's on the same track
- if (fromTrackId === track.id && existingElement.id === elementId)
- return false;
-
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
-
- // Check if elements overlap
- return (
- finalStartTime < existingEnd && movingElementEnd > existingStart
- );
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot move element here - it would overlap with existing elements"
- );
- return;
- }
-
- if (fromTrackId === track.id) {
- // Moving within same track
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- elementId,
- finalStartTime
- );
- } else {
- updateElementStartTime(track.id, elementId, finalStartTime);
- }
- } else {
- // Moving to different track
- moveElementToTrack(fromTrackId, track.id, elementId);
- requestAnimationFrame(() => {
- if (rippleEditingEnabled) {
- updateElementStartTimeWithRipple(
- track.id,
- elementId,
- finalStartTime
- );
- } else {
- updateElementStartTime(track.id, elementId, finalStartTime);
- }
- });
- }
- } else if (hasMediaItem) {
- // Handle media item drop
- const mediaItemData = e.dataTransfer.getData(
- "application/x-media-item"
- );
- if (!mediaItemData) return;
-
- const dragData: DragData = JSON.parse(mediaItemData);
-
- if (dragData.type === "text") {
- let targetTrackId = track.id;
- let targetTrack = track;
-
- // Handle position-aware track creation for text
- if (track.type !== "text" || dropPosition !== "on") {
- // Text tracks should go above the main track
- const mainTrack = getMainTrack(tracks);
- let insertIndex: number;
-
- if (dropPosition === "above") {
- insertIndex = currentTrackIndex;
- } else if (dropPosition === "below") {
- insertIndex = currentTrackIndex + 1;
- } else {
- // dropPosition === "on" but track is not text type
- // Insert above main track if main track exists, otherwise at top
- if (mainTrack) {
- const mainTrackIndex = tracks.findIndex(
- (t) => t.id === mainTrack.id
- );
- insertIndex = mainTrackIndex;
- } else {
- insertIndex = 0; // Top of timeline
- }
- }
-
- targetTrackId = insertTrackAt("text", insertIndex);
- // Get the updated tracks array after creating the new track
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- }
-
- // Check for overlaps with existing elements in target track
- const newElementDuration = 5; // Default text duration
- const textSnappedTime = getDropSnappedTime(
- newStartTime,
- newElementDuration
- );
- const newElementEnd = textSnappedTime + newElementDuration;
-
- const hasOverlap = targetTrack.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
-
- // Check if elements overlap
- return (
- textSnappedTime < existingEnd && newElementEnd > existingStart
- );
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot place element here - it would overlap with existing elements"
- );
- return;
- }
-
- addElementToTrack(targetTrackId, {
- ...DEFAULT_TEXT_ELEMENT,
- name: dragData.name || DEFAULT_TEXT_ELEMENT.name,
- content: dragData.content || DEFAULT_TEXT_ELEMENT.content,
- startTime: textSnappedTime,
- });
- } else {
- // Handle media items
- const mediaItem = mediaFiles.find((item) => item.id === dragData.id);
-
- if (!mediaItem) {
- toast.error("Media item not found");
- return;
- }
-
- let targetTrackId = track.id;
-
- // Check if track type is compatible
- const isVideoOrImage =
- dragData.type === "video" || dragData.type === "image";
- const isAudio = dragData.type === "audio";
- const isCompatible = isVideoOrImage
- ? canElementGoOnTrack("media", track.type)
- : isAudio
- ? canElementGoOnTrack("media", track.type)
- : false;
-
- let targetTrack = tracks.find((t) => t.id === targetTrackId);
-
- // Handle position-aware track creation for media elements
- if (!isCompatible || dropPosition !== "on") {
- 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
- 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") {
- insertIndex = currentTrackIndex;
- } else if (dropPosition === "below") {
- insertIndex = currentTrackIndex + 1;
- } else {
- // Insert above main track
- const mainTrackIndex = tracks.findIndex(
- (t) => t.id === mainTrack.id
- );
- insertIndex = mainTrackIndex;
- }
-
- targetTrackId = insertTrackAt("media", insertIndex);
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- }
- } else if (isAudio) {
- // Audio tracks go at the bottom
- const mainTrack = getMainTrack(tracks);
- let insertIndex: number;
-
- if (dropPosition === "above") {
- insertIndex = currentTrackIndex;
- } else if (dropPosition === "below") {
- insertIndex = currentTrackIndex + 1;
- } else {
- // Insert after main track (bottom area)
- if (mainTrack) {
- const mainTrackIndex = tracks.findIndex(
- (t) => t.id === mainTrack.id
- );
- insertIndex = mainTrackIndex + 1;
- } else {
- insertIndex = tracks.length; // Bottom of timeline
- }
- }
-
- targetTrackId = insertTrackAt("audio", insertIndex);
- const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(
- (t) => t.id === targetTrackId
- );
- if (!newTargetTrack) return;
- targetTrack = newTargetTrack;
- }
- }
-
- if (!targetTrack) return;
-
- // Check for overlaps with existing elements in target track
- const newElementDuration = mediaItem.duration || 5;
- const mediaSnappedTime = getDropSnappedTime(
- newStartTime,
- newElementDuration
- );
- const newElementEnd = mediaSnappedTime + newElementDuration;
-
- const hasOverlap = targetTrack.elements.some((existingElement) => {
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
-
- // Check if elements overlap
- return (
- mediaSnappedTime < existingEnd && newElementEnd > existingStart
- );
- });
-
- if (hasOverlap) {
- toast.error(
- "Cannot place element here - it would overlap with existing elements"
- );
- return;
- }
-
- addElementToTrack(targetTrackId, {
- type: "media",
- mediaId: mediaItem.id,
- name: mediaItem.name,
- duration: mediaItem.duration || 5,
- startTime: mediaSnappedTime,
- trimStart: 0,
- trimEnd: 0,
- });
- }
- } else if (hasFiles) {
- // External file drops
- const { activeProject } = useProjectStore.getState();
- const { addMediaFile } = useMediaStore.getState();
- const { addElementToTrack } = useTimelineStore.getState();
-
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- // Process and add files to new timeline tracks at playhead position
- processMediaFiles(e.dataTransfer.files)
- .then(async (processedItems) => {
- for (const processedItem of processedItems) {
- await addMediaFile(activeProject.id, processedItem);
- const currentMediaFiles = mediaFiles;
- const addedItem = currentMediaFiles.find(
- (item) =>
- item.name === processedItem.name &&
- item.url === processedItem.url
- );
-
- if (addedItem) {
- const trackType: TrackType =
- addedItem.type === "audio" ? "audio" : "media";
- const targetTrackId = insertTrackAt(trackType, 0);
-
- addElementToTrack(targetTrackId, {
- type: "media",
- mediaId: addedItem.id,
- name: addedItem.name,
- duration: addedItem.duration || 5,
- startTime: currentTime,
- trimStart: 0,
- trimEnd: 0,
- });
- }
- }
- })
- .catch((error) => {
- console.error("Error processing external files:", error);
- toast.error("Failed to process dropped files");
- });
- }
- } catch (error) {
- console.error("Error handling drop:", error);
- toast.error("Failed to add media to track");
- }
- };
-
- return (
- {
- // If clicking empty area (not on an element), deselect all elements
- if (!(e.target as HTMLElement).closest(".timeline-element")) {
- clearSelectedElements();
- }
- }}
- onDragOver={handleTrackDragOver}
- onDragEnter={handleTrackDragEnter}
- onDragLeave={handleTrackDragLeave}
- onDrop={handleTrackDrop}
- >
-
- {track.elements.length === 0 ? (
-
- {isDropping
- ? wouldOverlap
- ? "Cannot drop - would overlap"
- : "Drop element here"
- : ""}
-
- ) : (
- <>
- {track.elements.map((element) => {
- const isSelected = selectedElements.some(
- (c) => c.trackId === track.id && c.elementId === element.id
- );
-
- const handleElementSplit = () => {
- const { currentTime } = usePlaybackStore();
- const { splitSelected } = useTimelineStore();
- const splitTime = currentTime;
- const effectiveStart = element.startTime;
- const effectiveEnd =
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd);
-
- if (splitTime > effectiveStart && splitTime < effectiveEnd) {
- splitSelected(splitTime, track.id, element.id);
- } else {
- toast.error("Playhead must be within element to split");
- }
- };
-
- const handleElementDuplicate = () => {
- const { addElementToTrack } = useTimelineStore.getState();
- const { id, ...elementWithoutId } = element;
- addElementToTrack(track.id, {
- ...elementWithoutId,
- name: element.name + " (copy)",
- startTime:
- element.startTime +
- (element.duration - element.trimStart - element.trimEnd) +
- 0.1,
- });
- };
-
- const handleElementDelete = () => {
- const { deleteSelected } = useTimelineStore.getState();
- deleteSelected(track.id, element.id);
- };
-
- return (
-
- );
- })}
- >
- )}
-
-
- );
+ track,
+ zoomLevel,
+ dragState,
+ rulerScrollRef,
+ tracksScrollRef,
+ lastMouseXRef,
+ onSnapPointChange,
+ onResizeStateChange,
+ onElementMouseDown,
+ onElementClick,
+ onTrackMouseDown,
+ shouldIgnoreClick,
+}: TimelineTrackContentProps) {
+ const editor = useEditor();
+ const { isElementSelected, clearElementSelection } = useElementSelection();
+
+ const duration = editor.timeline.getTotalDuration();
+
+ useEdgeAutoScroll({
+ isActive: dragState.isDragging,
+ getMouseClientX: () => lastMouseXRef.current ?? 0,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
+ });
+
+ const hasSelectedElements = track.elements.some((element) =>
+ isElementSelected({ trackId: track.id, elementId: element.id }),
+ );
+
+ return (
+ {
+ if (shouldIgnoreClick?.()) return;
+ clearElementSelection();
+ }}
+ onMouseDown={(event) => {
+ onTrackMouseDown?.(event);
+ }}
+ type="button"
+ >
+
+ {track.elements.length === 0 ? (
+
+ ) : (
+ track.elements.map((element) => {
+ const isSelected = isElementSelected({
+ trackId: track.id,
+ elementId: element.id,
+ });
+
+ return (
+
+ onElementMouseDown({ event, element, track })
+ }
+ onElementClick={(event, element) =>
+ onElementClick({ event, element, track })
+ }
+ dragState={dragState}
+ />
+ );
+ })
+ )}
+
+
+ );
}
diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx
index 176452cd..7c78fc57 100644
--- a/apps/web/src/components/footer.tsx
+++ b/apps/web/src/components/footer.tsx
@@ -1,130 +1,122 @@
-"use client";
-
-import { motion } from "motion/react";
import Link from "next/link";
import { RiDiscordFill, RiTwitterXLine } from "react-icons/ri";
import { FaGithub } from "react-icons/fa6";
import Image from "next/image";
+import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
+import { capitalizeFirstLetter } from "@/utils/string";
-export function Footer() {
- return (
-
-
-
- {/* Brand Section */}
-
-
-
- OpenCut
-
-
- The open source video editor that gets the job done. Simple,
- powerful, and works on any platform.
-
-
-
-
-
-
-
-
-
-
-
-
-
+type Category = "resources" | "company";
+
+interface FooterLink {
+ label: string;
+ href: string;
+}
-
-
-
Resources
-
-
-
- Roadmap
-
-
-
-
- Privacy policy
-
-
-
-
- Terms of use
-
-
-
-
+type CategoryLinks = Record
;
+
+const links: CategoryLinks = {
+ resources: [
+ { label: "Roadmap", href: "/roadmap" },
+ { label: "Blog", href: "/blog" },
+ { label: "Privacy", href: "/privacy" },
+ { label: "Terms of use", href: "/terms" },
+ ],
+ company: [
+ { label: "Contributors", href: "/contributors" },
+ { label: "Sponsors", href: "/sponsors" },
+ { label: "Branding", href: "/branding" },
+ { label: "About", href: `${SOCIAL_LINKS.github}/blob/main/README.md` },
+ ],
+};
+
+export function Footer() {
+ return (
+
- {/* Bottom Section */}
-
-
- © 2025 OpenCut, All Rights Reserved
-
-
-
-
- );
+ {/* Bottom Section */}
+
+
+
+ © {new Date().getFullYear()} OpenCut, All Rights Reserved
+
+
+
+
+
+ );
}
diff --git a/apps/web/src/components/gitHub-contribute-section.tsx b/apps/web/src/components/gitHub-contribute-section.tsx
new file mode 100644
index 00000000..f96def4a
--- /dev/null
+++ b/apps/web/src/components/gitHub-contribute-section.tsx
@@ -0,0 +1,47 @@
+import { Button } from "./ui/button";
+import Link from "next/link";
+import { SOCIAL_LINKS } from "@/constants/site-constants";
+import {
+ GithubIcon,
+ Link04Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+export function GitHubContributeSection({
+ title,
+ description,
+}: {
+ title: string;
+ description: string;
+}) {
+ return (
+
+
+
{title}
+
{description}
+
+
+
+
+
+ Start contributing
+
+
+
+
+
+ Report issues
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/header-base.tsx b/apps/web/src/components/header-base.tsx
deleted file mode 100644
index 98f1c431..00000000
--- a/apps/web/src/components/header-base.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-"use client";
-
-import { cn } from "@/lib/utils";
-import { ReactNode } from "react";
-
-interface HeaderBaseProps {
- leftContent?: ReactNode;
- centerContent?: ReactNode;
- rightContent?: ReactNode;
- className?: string;
- children?: ReactNode;
-}
-
-export function HeaderBase({
- leftContent,
- centerContent,
- rightContent,
- className,
- children,
-}: HeaderBaseProps) {
- // If children is provided, render it directly without the grid layout
- if (children) {
- return (
-
- );
- }
-
- return (
-
- {leftContent && {leftContent}
}
- {centerContent && (
- {centerContent}
- )}
- {rightContent && {rightContent}
}
-
- );
-}
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index f8877330..95f08304 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -1,57 +1,147 @@
"use client";
+import { useState } from "react";
import Link from "next/link";
+import { motion } from "motion/react";
import { Button } from "./ui/button";
import { ArrowRight } from "lucide-react";
-import { HeaderBase } from "./header-base";
import Image from "next/image";
import { ThemeToggle } from "./theme-toggle";
+import { GithubIcon, Menu02Icon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { cn } from "@/utils/ui";
+import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
export function Header() {
- const leftContent = (
-
-
- OpenCut
-
- );
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const closeMenu = () => setIsMenuOpen(false);
- const rightContent = (
-
-
-
-
- Blog
-
-
-
-
- Contributors
-
-
-
-
-
- Projects
-
-
-
-
-
- );
+ const links = [
+ {
+ label: "Contributors",
+ href: "/contributors",
+ },
+ {
+ label: "Sponsors",
+ href: "/sponsors",
+ },
+ {
+ label: "Blog",
+ href: "/blog",
+ },
+ ];
- return (
-
-
-
- );
+ return (
+
+ );
}
diff --git a/apps/web/src/components/icons.tsx b/apps/web/src/components/icons.tsx
deleted file mode 100644
index b24687a6..00000000
--- a/apps/web/src/components/icons.tsx
+++ /dev/null
@@ -1,233 +0,0 @@
-export function GoogleIcon({ className }: { className?: string }) {
- return (
-
-
-
-
-
-
- );
-}
-
-export function GithubIcon({ className }: { className?: string }) {
- return (
-
-
-
-
-
-
-
- );
-}
-
-export function VercelIcon({ className }: { className?: string }) {
- return (
-
-
-
- );
-}
-
-export function BackgroundIcon({ className }: { className?: string }) {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-export function MarbleIcon({
- className = "",
- size = 32,
-}: {
- className?: string;
- size?: number;
-}) {
- return (
-
-
-
-
- );
-}
-
-export function DataBuddyIcon({
- className = "",
- size = 32,
-}: {
- className?: string;
- size?: number;
-}) {
- return (
-
-
-
-
- );
-}
-
-export function SocialsIcon({
- className = "",
- size = 32,
-}: {
- className?: string;
- size?: number;
-}) {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-export function TransitionUpIcon({
- className = "",
- size = 16,
-}: {
- className?: string;
- size?: number;
-}) {
- return (
-
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx
deleted file mode 100644
index 689c3ee3..00000000
--- a/apps/web/src/components/keyboard-shortcuts-help.tsx
+++ /dev/null
@@ -1,245 +0,0 @@
-"use client";
-
-import { Keyboard } from "lucide-react";
-import { useEffect, useState } from "react";
-import { toast } from "sonner";
-import {
- type KeyboardShortcut,
- useKeyboardShortcutsHelp,
-} from "@/hooks/use-keyboard-shortcuts-help";
-import { useKeybindingsStore } from "@/stores/keybindings-store";
-import { Button } from "./ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "./ui/dialog";
-
-export function KeyboardShortcutsHelp() {
- const [open, setOpen] = useState(false);
- const [recordingShortcut, setRecordingShortcut] =
- useState(null);
-
- const {
- updateKeybinding,
- removeKeybinding,
- getKeybindingString,
- validateKeybinding,
- getKeybindingsForAction,
- setIsRecording,
- resetToDefaults,
- isRecording,
- } = useKeybindingsStore();
-
- // Get shortcuts from centralized hook
- const { shortcuts } = useKeyboardShortcutsHelp();
-
- const categories = Array.from(new Set(shortcuts.map((s) => s.category)));
-
- useEffect(() => {
- if (!isRecording || !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}"`
- );
- 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);
-
- setIsRecording(false);
- setRecordingShortcut(null);
- }
- };
-
- const handleClickOutside = () => {
- setRecordingShortcut(null);
- setIsRecording(false);
- };
-
- document.addEventListener("keydown", handleKeyDown);
- document.addEventListener("click", handleClickOutside);
-
- return () => {
- document.removeEventListener("keydown", handleKeyDown);
- document.removeEventListener("click", handleClickOutside);
- };
- }, [
- recordingShortcut,
- getKeybindingString,
- updateKeybinding,
- removeKeybinding,
- validateKeybinding,
- getKeybindingsForAction,
- setIsRecording,
- isRecording,
- ]);
-
- const handleStartRecording = (shortcut: KeyboardShortcut) => {
- setRecordingShortcut(shortcut);
- setIsRecording(true);
- };
-
- return (
-
-
-
-
- Shortcuts
-
-
-
-
-
-
- Keyboard Shortcuts
-
-
- Speed up your video editing workflow with these keyboard shortcuts.
- Click any shortcut key to edit it.
-
-
-
-
-
- {categories.map((category) => (
-
-
- {category}
-
-
- {shortcuts
- .filter((shortcut) => shortcut.category === category)
- .map((shortcut) => (
-
- ))}
-
-
- ))}
-
-
-
-
- Reset to Default
-
-
-
-
- );
-}
-
-function ShortcutItem({
- shortcut,
- isRecording,
- onStartRecording,
-}: {
- shortcut: KeyboardShortcut;
- isRecording: boolean;
- onStartRecording: (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 (
-
-
- {shortcut.icon && (
-
{shortcut.icon}
- )}
-
{shortcut.description}
-
-
- {displayKeys.map((key: string, index: number) => (
-
-
- {key.split("+").map((keyPart: string, partIndex: number) => {
- const keyId = `${shortcut.id}-${index}-${partIndex}`;
- return (
- onStartRecording(shortcut)}
- >
- {keyPart}
-
- );
- })}
-
- {index < displayKeys.length - 1 && (
-
or
- )}
-
- ))}
-
-
- );
-}
-
-function EditableShortcutKey({
- children,
- isRecording,
- onStartRecording,
-}: {
- children: React.ReactNode;
- isRecording: boolean;
- onStartRecording: () => void;
-}) {
- const handleClick = (e: React.MouseEvent) => {
- e.preventDefault();
- e.stopPropagation();
- onStartRecording();
- };
-
- return (
-
- {children}
-
- );
-}
\ No newline at end of file
diff --git a/apps/web/src/components/landing/handlebars.tsx b/apps/web/src/components/landing/handlebars.tsx
index 758717e5..da0d895d 100644
--- a/apps/web/src/components/landing/handlebars.tsx
+++ b/apps/web/src/components/landing/handlebars.tsx
@@ -1,105 +1,173 @@
"use client";
-import {
- motion,
- useMotionTemplate,
- useMotionValue,
- useMotionValueEvent,
- useTransform,
-} from "motion/react";
import { type PropsWithChildren, useEffect, useRef, useState } from "react";
type HandlebarsProps = PropsWithChildren;
export function Handlebars({ children }: HandlebarsProps) {
- const containerRef = useRef(null);
-
- const [width, setWidth] = useState(0);
-
- const [leftHandle, setLeftHandle] = useState(0);
- const [rightHandle, setRightHandle] = useState(width);
-
- const leftHandleX = useMotionValue(0);
- const rightHandleX = useMotionValue(width);
-
- useEffect(() => {
- const el = containerRef.current;
- if (!el) return;
-
- const observer = new ResizeObserver(() => {
- setWidth(el.offsetWidth);
- setRightHandle(el.offsetWidth);
- rightHandleX.set(el.offsetWidth);
- });
-
- observer.observe(el);
- return () => observer.disconnect();
- }, [rightHandleX.set]);
-
- useMotionValueEvent(leftHandleX, "change", () => {
- setLeftHandle(leftHandleX.get());
- });
- useMotionValueEvent(rightHandleX, "change", () => {
- setRightHandle(rightHandleX.get());
- });
- const leftGradient = useTransform(leftHandleX, [0, width - 10], [0, 100]);
- const rightGradient = useTransform(rightHandleX, [0, width + 10], [0, 100]);
-
- return (
-
-
-
-
-
(null);
+ const leftHandleRef = useRef(null);
+ const rightHandleRef = useRef(null);
+
+ const [width, setWidth] = useState(0);
+ const [leftHandle, setLeftHandle] = useState(0);
+ const [rightHandle, setRightHandle] = useState(0);
+
+ const widthRef = useRef(0);
+ const leftHandlePositionRef = useRef(0);
+ const rightHandlePositionRef = useRef(0);
+
+ const dragRef = useRef<{
+ isDragging: boolean;
+ side: "left" | "right" | null;
+ pointerId: number | null;
+ startX: number;
+ initialPosition: number;
+ }>({
+ isDragging: false,
+ side: null,
+ pointerId: null,
+ startX: 0,
+ initialPosition: 0,
+ });
+
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+
+ const updateWidth = () => {
+ const newWidth = el.offsetWidth;
+ setWidth(newWidth);
+ setRightHandle(newWidth);
+ };
+
+ const observer = new ResizeObserver(updateWidth);
+ observer.observe(el);
+ updateWidth();
+
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ widthRef.current = width;
+ leftHandlePositionRef.current = leftHandle;
+ rightHandlePositionRef.current = rightHandle;
+ }, [leftHandle, rightHandle, width]);
+
+ useEffect(() => {
+ const handlePointerMove = (event: PointerEvent) => {
+ const { isDragging, side, pointerId, startX, initialPosition } =
+ dragRef.current;
+
+ if (!isDragging) return;
+ if (pointerId !== null && event.pointerId !== pointerId) return;
+ if (!side) return;
+
+ const deltaX = event.clientX - startX;
+
+ if (side === "left") {
+ const maxLeft = Math.max(0, rightHandlePositionRef.current - 60);
+ const nextLeftHandle = Math.max(
+ 0,
+ Math.min(maxLeft, initialPosition + deltaX),
+ );
+ setLeftHandle(nextLeftHandle);
+ return;
+ }
+
+ const minRight = Math.min(
+ widthRef.current,
+ leftHandlePositionRef.current + 60,
+ );
+ const nextRightHandle = Math.max(
+ minRight,
+ Math.min(widthRef.current, initialPosition + deltaX),
+ );
+ setRightHandle(nextRightHandle);
+ };
+
+ const handlePointerEnd = (event: PointerEvent) => {
+ const { pointerId } = dragRef.current;
+ if (pointerId !== null && event.pointerId !== pointerId) return;
+
+ dragRef.current.isDragging = false;
+ dragRef.current.side = null;
+ dragRef.current.pointerId = null;
+ };
+
+ window.addEventListener("pointermove", handlePointerMove);
+ window.addEventListener("pointerup", handlePointerEnd);
+ window.addEventListener("pointercancel", handlePointerEnd);
+
+ return () => {
+ window.removeEventListener("pointermove", handlePointerMove);
+ window.removeEventListener("pointerup", handlePointerEnd);
+ window.removeEventListener("pointercancel", handlePointerEnd);
+ };
+ }, []);
+
+ const leftGradientPercent = width > 0 ? (leftHandle / (width - 10)) * 100 : 0;
+ const rightGradientPercent =
+ width > 0 ? (rightHandle / (width + 10)) * 100 : 0;
+
+ return (
+
+
+
+
{
+ event.preventDefault();
+ leftHandleRef.current?.setPointerCapture(event.pointerId);
+ dragRef.current.isDragging = true;
+ dragRef.current.side = "left";
+ dragRef.current.pointerId = event.pointerId;
+ dragRef.current.startX = event.clientX;
+ dragRef.current.initialPosition = leftHandlePositionRef.current;
+ }}
+ >
+
+
+
+
{
+ event.preventDefault();
+ rightHandleRef.current?.setPointerCapture(event.pointerId);
+ dragRef.current.isDragging = true;
+ dragRef.current.side = "right";
+ dragRef.current.pointerId = event.pointerId;
+ dragRef.current.startX = event.clientX;
+ dragRef.current.initialPosition = rightHandlePositionRef.current;
+ }}
+ >
+
+
+
+
+
- {children}
-
-
-
- );
+ }}
+ >
+ {children}
+
+
+
+ );
}
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx
index 1b557412..6f11ad7c 100644
--- a/apps/web/src/components/landing/hero.tsx
+++ b/apps/web/src/components/landing/hero.tsx
@@ -1,81 +1,46 @@
"use client";
-import { motion } from "motion/react";
import { Button } from "../ui/button";
-import { SponsorButton } from "../ui/sponsor-button";
-import { VercelIcon } from "../icons";
import { ArrowRight } from "lucide-react";
-
import Image from "next/image";
import { Handlebars } from "./handlebars";
import Link from "next/link";
export function Hero() {
- return (
-
-
-
-
-
-
-
- The Open Source
- Video Editor
-
+ return (
+
+
+
+
+
The open source
+ Video editor
+
-
- A simple but powerful video editor that gets the job done. Works on
- any platform.
-
+
+ A simple but powerful video editor that gets the job done. Works on
+ any platform.
+
-
-
-
- Try early beta
-
-
-
-
-
-
- );
+
+
+
+ Try early beta
+
+
+
+
+
+
+ );
}
diff --git a/apps/web/src/components/language-select.tsx b/apps/web/src/components/language-select.tsx
deleted file mode 100644
index 43ed632d..00000000
--- a/apps/web/src/components/language-select.tsx
+++ /dev/null
@@ -1,204 +0,0 @@
-import { useState, useRef, useEffect } from "react";
-import { ChevronDown, Globe } from "lucide-react";
-import { cn } from "@/lib/utils";
-import { motion } from "framer-motion";
-import ReactCountryFlag from "react-country-flag";
-
-export interface Language {
- code: string;
- name: string;
- flag?: string;
-}
-
-interface LanguageSelectProps {
- selectedCountry: string;
- onSelect: (country: string) => void;
- containerRef: React.RefObject;
- languages: Language[];
-}
-
-function FlagPreloader({ languages }: { languages: Language[] }) {
- return (
-
- {languages.map((language) => (
-
- ))}
-
- );
-}
-
-export function LanguageSelect({
- selectedCountry,
- onSelect,
- containerRef,
- languages,
-}: LanguageSelectProps) {
- const [expanded, setExpanded] = useState(false);
- const [isTapping, setIsTapping] = useState(false);
- const [isClosing, setIsClosing] = useState(false);
- const collapsedHeight = "2.5rem";
- const expandHeight = "12rem";
- const buttonRef = useRef(null);
-
- const expand = () => {
- setIsTapping(true);
- setTimeout(() => setIsTapping(false), 600);
- setExpanded(true);
- buttonRef.current?.focus();
- };
-
- useEffect(() => {
- if (!expanded) return;
-
- const handleClickOutside = (event: MouseEvent) => {
- if (
- buttonRef.current &&
- !buttonRef.current.contains(event.target as Node)
- ) {
- setIsClosing(true);
- setTimeout(() => setIsClosing(false), 600);
- setExpanded(false);
- buttonRef.current?.blur();
- }
- };
-
- document.addEventListener("mousedown", handleClickOutside);
- return () => {
- document.removeEventListener("mousedown", handleClickOutside);
- };
- }, [expanded]);
-
- const selectedLanguage = languages.find(
- (lang) => lang.code === selectedCountry
- );
-
- const handleSelect = ({
- code,
- e,
- }: {
- code: string;
- e: React.MouseEvent;
- }) => {
- e.stopPropagation();
- e.preventDefault();
- onSelect(code);
- setExpanded(false);
- };
-
- return (
-
-
-
- {!expanded ? (
-
-
- {selectedCountry === "auto" ? (
-
- ) : (
-
- )}
-
- {selectedCountry === "auto" ? "Auto" : selectedLanguage?.name}
-
-
-
- ) : (
-
-
- {languages.map((language) => (
-
- ))}
-
- )}
-
-
-
-
-
-
- );
-}
-
-function LanguageButton({
- language,
- onSelect,
- selectedCountry,
-}: {
- language: Language;
- onSelect: ({
- code,
- e,
- }: {
- code: string;
- e: React.MouseEvent;
- }) => void;
- selectedCountry: string;
-}) {
- return (
- onSelect({ code: language.code, e })}
- >
- {language.code === "auto" ? (
-
- ) : (
-
- )}
- {language.name}
-
- );
-}
diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx
index b8e9ae1d..b1b5ab3a 100644
--- a/apps/web/src/components/providers/editor-provider.tsx
+++ b/apps/web/src/components/providers/editor-provider.tsx
@@ -1,53 +1,136 @@
"use client";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
-import { useEditorStore } from "@/stores/editor-store";
+import { useEditor } from "@/hooks/use-editor";
import {
- useKeybindingsListener,
- useKeybindingDisabler,
+ useKeybindingsListener,
+ useKeybindingDisabler,
} from "@/hooks/use-keybindings";
-import { useEditorActions } from "@/hooks/use-editor-actions";
+import { useEditorActions } from "@/hooks/actions/use-editor-actions";
interface EditorProviderProps {
- children: React.ReactNode;
+ projectId: string;
+ children: React.ReactNode;
}
-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();
- }, [initializeApp]);
-
- // Show loading screen while initializing
- if (isInitializing || !isPanelsReady) {
- return (
-
- );
- }
-
- // App is ready, render children
- return <>{children}>;
+export function EditorProvider({ projectId, children }: EditorProviderProps) {
+ const editor = useEditor();
+ const router = useRouter();
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const { disableKeybindings, enableKeybindings } = useKeybindingDisabler();
+ const activeProject = editor.project.getActiveOrNull();
+
+ useEffect(() => {
+ if (isLoading) {
+ disableKeybindings();
+ } else {
+ enableKeybindings();
+ }
+ }, [isLoading, disableKeybindings, enableKeybindings]);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const loadProject = async () => {
+ try {
+ setIsLoading(true);
+ await editor.project.loadProject({ id: projectId });
+
+ if (cancelled) return;
+
+ setIsLoading(false);
+ } catch (err) {
+ if (cancelled) return;
+
+ const isNotFound =
+ err instanceof Error &&
+ (err.message.includes("not found") ||
+ err.message.includes("does not exist"));
+
+ if (isNotFound) {
+ try {
+ const newProjectId = await editor.project.createNewProject({
+ name: "Untitled Project",
+ });
+ router.replace(`/editor/${newProjectId}`);
+ } catch (_createErr) {
+ setError("Failed to create project");
+ setIsLoading(false);
+ }
+ } else {
+ setError(
+ err instanceof Error ? err.message : "Failed to load project",
+ );
+ setIsLoading(false);
+ }
+ }
+ };
+
+ loadProject();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [projectId, editor, router]);
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!activeProject) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+
+ {children}
+ >
+ );
+}
+
+function EditorRuntimeBindings() {
+ const editor = useEditor();
+
+ useEffect(() => {
+ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
+ if (!editor.save.getIsDirty()) return;
+ event.preventDefault();
+ (event as unknown as { returnValue: string }).returnValue = "";
+ };
+
+ window.addEventListener("beforeunload", handleBeforeUnload);
+ return () => window.removeEventListener("beforeunload", handleBeforeUnload);
+ }, [editor]);
+
+ useEditorActions();
+ useKeybindingsListener();
+ return null;
}
diff --git a/apps/web/src/components/providers/migrators/scenes-migrator.tsx b/apps/web/src/components/providers/migrators/scenes-migrator.tsx
deleted file mode 100644
index b984a7f4..00000000
--- a/apps/web/src/components/providers/migrators/scenes-migrator.tsx
+++ /dev/null
@@ -1,177 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import { usePathname } from "next/navigation";
-import { storageService } from "@/lib/storage/storage-service";
-import { TProject, Scene } from "@/types/project";
-import { generateUUID } from "@/lib/utils";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Progress } from "@/components/ui/progress";
-import { createMainScene } from "@/stores/project-store";
-
-interface MigrationProgress {
- current: number;
- total: number;
- currentProjectName: string;
-}
-
-export function ScenesMigrator({ children }: { children: React.ReactNode }) {
- const pathname = usePathname();
- const [isMigrating, setIsMigrating] = useState(false);
- const [progress, setProgress] = useState({
- current: 0,
- total: 0,
- currentProjectName: "",
- });
-
- const shouldCheckMigration =
- pathname.startsWith("/editor") || pathname.startsWith("/projects");
-
- useEffect(() => {
- if (!shouldCheckMigration) return;
-
- checkAndMigrateProjects();
- }, [shouldCheckMigration]);
-
- const checkAndMigrateProjects = async () => {
- try {
- const projects = await storageService.loadAllProjects();
- const legacyProjects = projects.filter(
- (project) => !project.scenes || project.scenes.length === 0
- );
-
- if (legacyProjects.length === 0) {
- // No migration needed
- return;
- }
-
- setIsMigrating(true);
- setProgress({
- current: 0,
- total: legacyProjects.length,
- currentProjectName: "",
- });
-
- // Migrate each legacy project
- for (let i = 0; i < legacyProjects.length; i++) {
- const project = legacyProjects[i];
-
- setProgress({
- current: i,
- total: legacyProjects.length,
- currentProjectName: project.name,
- });
-
- await migrateLegacyProject(project);
- }
-
- setProgress({
- current: legacyProjects.length,
- total: legacyProjects.length,
- currentProjectName: "Complete!",
- });
-
- setTimeout(() => {
- setIsMigrating(false);
- }, 1000);
- } catch (error) {
- console.error("Migration failed:", error);
- setIsMigrating(false);
- }
- };
-
- const migrateLegacyProject = async (project: TProject) => {
- try {
- const mainScene: Scene = {
- id: generateUUID(),
- name: "Main Scene",
- isMain: true,
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- const migratedProject: TProject = {
- ...project,
- scenes: [mainScene],
- currentSceneId: mainScene.id,
- updatedAt: new Date(),
- };
-
- // Load existing timeline data (legacy format)
- const legacyTimeline = await storageService.loadTimeline({
- projectId: project.id,
- });
-
- await storageService.saveProject({ project: migratedProject });
-
- // If timeline data, migrate it to the main scene
- if (legacyTimeline && legacyTimeline.length > 0) {
- await storageService.saveTimeline({
- projectId: project.id,
- tracks: legacyTimeline,
- sceneId: mainScene.id,
- });
- }
-
- // Clean up legacy timeline storage
- await storageService.deleteProjectTimeline({ projectId: project.id });
- } catch (error) {
- console.error(`Failed to migrate project ${project.name}:`, error);
- throw error;
- }
- };
-
- if (!shouldCheckMigration) {
- return children;
- }
-
- if (isMigrating) {
- const progressPercent =
- progress.total > 0 ? (progress.current / progress.total) * 100 : 0;
-
- return (
-
- e.preventDefault()}
- >
-
- Updating Projects
-
- We're adding scene support to your projects. This will only take a
- moment.
-
-
-
-
-
-
- Progress
-
- {progress.current} of {progress.total}
-
-
-
-
-
- {progress.currentProjectName && (
-
- {progress.current < progress.total
- ? `Updating: ${progress.currentProjectName}`
- : progress.currentProjectName}
-
- )}
-
-
-
- );
- }
-
- return children;
-}
diff --git a/apps/web/src/components/rename-project-dialog.tsx b/apps/web/src/components/rename-project-dialog.tsx
deleted file mode 100644
index dc576b00..00000000
--- a/apps/web/src/components/rename-project-dialog.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Input } from "@/components/ui/input";
-import { useState } from "react";
-
-export function RenameProjectDialog({
- isOpen,
- onOpenChange,
- onConfirm,
- projectName,
-}: {
- isOpen: boolean;
- onOpenChange: (open: boolean) => void;
- onConfirm: (name: string) => void;
- projectName: string;
-}) {
- const [name, setName] = useState(projectName);
-
- // Reset the name when dialog opens - this is better UX than syncing with every prop change
- const handleOpenChange = (open: boolean) => {
- if (open) {
- setName(projectName);
- }
- onOpenChange(open);
- };
-
- return (
-
-
-
- Rename Project
-
- Enter a new name for your project.
-
-
-
- setName(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- onConfirm(name);
- }
- }}
- placeholder="Enter a new name"
- className="mt-0 bg-background border-2 border-border"
- />
-
-
- {
- e.preventDefault();
- e.stopPropagation();
- onOpenChange(false);
- }}
- >
- Cancel
-
- onConfirm(name)}>Rename
-
-
-
- );
-}
diff --git a/apps/web/src/components/storage-provider.tsx b/apps/web/src/components/storage-provider.tsx
index 69105d0f..144790dc 100644
--- a/apps/web/src/components/storage-provider.tsx
+++ b/apps/web/src/components/storage-provider.tsx
@@ -1,80 +1,81 @@
"use client";
-import { createContext, useContext, useEffect, useState } from "react";
-import { useProjectStore } from "@/stores/project-store";
-import { useMediaStore } from "@/stores/media-store";
-import { storageService } from "@/lib/storage/storage-service";
+import { createContext, useContext, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
+import { useEditor } from "@/hooks/use-editor";
+import { storageService } from "@/services/storage/storage-service";
interface StorageContextType {
- isInitialized: boolean;
- isLoading: boolean;
- hasSupport: boolean;
- error: string | null;
+ isInitialized: boolean;
+ isLoading: boolean;
+ hasSupport: boolean;
+ error: string | null;
}
const StorageContext = createContext(null);
export function useStorage() {
- const context = useContext(StorageContext);
- if (!context) {
- throw new Error("useStorage must be used within StorageProvider");
- }
- return context;
+ const context = useContext(StorageContext);
+ if (!context) {
+ throw new Error("useStorage must be used within StorageProvider");
+ }
+ return context;
}
interface StorageProviderProps {
- children: React.ReactNode;
+ children: React.ReactNode;
}
export function StorageProvider({ children }: StorageProviderProps) {
- const [status, setStatus] = useState({
- isInitialized: false,
- isLoading: true,
- hasSupport: false,
- error: null,
- });
+ const [status, setStatus] = useState({
+ isInitialized: false,
+ isLoading: true,
+ hasSupport: false,
+ error: null,
+ });
- const loadAllProjects = useProjectStore((state) => state.loadAllProjects);
+ const editor = useEditor();
+ const hasInitialized = useRef(false);
- useEffect(() => {
- const initializeStorage = async () => {
- setStatus((prev) => ({ ...prev, isLoading: true }));
+ useEffect(() => {
+ if (hasInitialized.current) return;
+ hasInitialized.current = true;
- try {
- // Check browser support
- const hasSupport = storageService.isFullySupported();
+ const initializeStorage = async () => {
+ setStatus((prev) => ({ ...prev, isLoading: true }));
- if (!hasSupport) {
- toast.warning(
- "Storage not fully supported. Some features may not work."
- );
- }
+ try {
+ const hasSupport = storageService.isFullySupported();
- // Load saved projects (media will be loaded when a project is loaded)
- await loadAllProjects();
+ if (!hasSupport) {
+ toast.warning(
+ "Storage not fully supported. Some features may not work.",
+ );
+ }
- setStatus({
- isInitialized: true,
- isLoading: false,
- hasSupport,
- error: null,
- });
- } catch (error) {
- console.error("Failed to initialize storage:", error);
- setStatus({
- isInitialized: false,
- isLoading: false,
- hasSupport: storageService.isFullySupported(),
- error: error instanceof Error ? error.message : "Unknown error",
- });
- }
- };
+ await editor.project.loadAllProjects();
- initializeStorage();
- }, [loadAllProjects]);
+ setStatus({
+ isInitialized: true,
+ isLoading: false,
+ hasSupport,
+ error: null,
+ });
+ } catch (error) {
+ console.error("Failed to initialize storage:", error);
+ setStatus({
+ isInitialized: false,
+ isLoading: false,
+ hasSupport: storageService.isFullySupported(),
+ error: error instanceof Error ? error.message : "Unknown error",
+ });
+ }
+ };
- return (
- {children}
- );
+ initializeStorage();
+ }, [editor.project.loadAllProjects]);
+
+ return (
+ {children}
+ );
}
diff --git a/apps/web/src/components/theme-toggle.tsx b/apps/web/src/components/theme-toggle.tsx
index 8302a19c..394d75b2 100644
--- a/apps/web/src/components/theme-toggle.tsx
+++ b/apps/web/src/components/theme-toggle.tsx
@@ -1,25 +1,36 @@
"use client";
import { Button } from "./ui/button";
-import { Sun, Moon } from "lucide-react";
import { useTheme } from "next-themes";
+import { cn } from "@/utils/ui";
+import { Sun03Icon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
interface ThemeToggleProps {
- className?: string;
+ className?: string;
+ iconClassName?: string;
+ onToggle?: (e: React.MouseEvent) => void;
}
-export function ThemeToggle({ className }: ThemeToggleProps) {
- const { theme, setTheme } = useTheme();
+export function ThemeToggle({
+ className,
+ iconClassName,
+ onToggle,
+}: ThemeToggleProps) {
+ const { theme, setTheme } = useTheme();
- return (
- setTheme(theme === "dark" ? "light" : "dark")}
- >
-
- {theme === "dark" ? "Light" : "Dark"}
-
- );
+ return (
+ {
+ setTheme(theme === "dark" ? "light" : "dark");
+ onToggle?.(e);
+ }}
+ >
+
+ {theme === "dark" ? "Light" : "Dark"}
+
+ );
}
diff --git a/apps/web/src/components/ui/accordion.tsx b/apps/web/src/components/ui/accordion.tsx
index 68edd869..95a1c2df 100644
--- a/apps/web/src/components/ui/accordion.tsx
+++ b/apps/web/src/components/ui/accordion.tsx
@@ -4,53 +4,53 @@ import * as React from "react";
import { Accordion as AccordionPrimitive } from "radix-ui";
import { ChevronDown } from "lucide-react";
-import { cn } from "../../lib/utils";
+import { cn } from "@/utils/ui";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
-
+
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
>(({ className, children, ...props }, ref) => (
-
- svg]:rotate-180",
- className
- )}
- {...props}
- >
- {children}
-
-
-
+
+ svg]:rotate-180",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
>(({ className, children, ...props }, ref) => (
-
- {children}
-
+
+ {children}
+
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx
index 2cce235c..e7295429 100644
--- a/apps/web/src/components/ui/alert-dialog.tsx
+++ b/apps/web/src/components/ui/alert-dialog.tsx
@@ -3,7 +3,7 @@
import * as React from "react";
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
-import { cn } from "../../lib/utils";
+import { cn } from "@/utils/ui";
import { buttonVariants } from "./button";
const AlertDialog = AlertDialogPrimitive.Root;
@@ -13,129 +13,129 @@ const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
-
+
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
-
-
-
-
+
+
+
+
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
- className,
- ...props
+ className,
+ ...props
}: React.HTMLAttributes) => (
-
+
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({
- className,
- ...props
+ className,
+ ...props
}: React.HTMLAttributes) => (
-
+
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
-
+
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
-
+
));
AlertDialogDescription.displayName =
- AlertDialogPrimitive.Description.displayName;
+ AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
-
+
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
-
+
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
- AlertDialog,
- AlertDialogPortal,
- AlertDialogOverlay,
- AlertDialogTrigger,
- AlertDialogContent,
- AlertDialogHeader,
- AlertDialogFooter,
- AlertDialogTitle,
- AlertDialogDescription,
- AlertDialogAction,
- AlertDialogCancel,
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
};
diff --git a/apps/web/src/components/ui/alert.tsx b/apps/web/src/components/ui/alert.tsx
index cef19199..e941ca43 100644
--- a/apps/web/src/components/ui/alert.tsx
+++ b/apps/web/src/components/ui/alert.tsx
@@ -1,58 +1,65 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
-
-import { cn } from "../../lib/utils";
+import { AlertTriangle } from "lucide-react";
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Alert02Icon } from "@hugeicons/core-free-icons";
const alertVariants = cva(
- "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
- {
- variants: {
- variant: {
- default: "bg-background text-foreground",
- destructive:
- "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- }
+ "relative w-full rounded-lg border p-5 py-4.5 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-destructive [&>svg~*]:pl-7",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "bg-destructive/5 border-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
);
const Alert = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes & VariantProps
->(({ className, variant, ...props }, ref) => (
-
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, children, ...props }, ref) => (
+
+ {variant === "destructive" && (
+
+ )}
+ {children}
+
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
- HTMLParagraphElement,
- React.HTMLAttributes
+ HTMLParagraphElement,
+ React.HTMLAttributes
>(({ className, ...props }, ref) => (
-
+
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
- HTMLParagraphElement,
- React.HTMLAttributes
+ HTMLParagraphElement,
+ React.HTMLAttributes
>(({ className, ...props }, ref) => (
-
+
));
AlertDescription.displayName = "AlertDescription";
diff --git a/apps/web/src/components/ui/audio-player.tsx b/apps/web/src/components/ui/audio-player.tsx
deleted file mode 100644
index 37bf8ca9..00000000
--- a/apps/web/src/components/ui/audio-player.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-"use client";
-
-import { useRef, useEffect } from "react";
-import { usePlaybackStore } from "@/stores/playback-store";
-
-interface AudioPlayerProps {
- src: string;
- className?: string;
- clipStartTime: number;
- trimStart: number;
- trimEnd: number;
- clipDuration: number;
- trackMuted?: boolean;
-}
-
-export function AudioPlayer({
- src,
- className = "",
- clipStartTime,
- trimStart,
- trimEnd,
- clipDuration,
- trackMuted = false,
-}: AudioPlayerProps) {
- const audioRef = useRef(null);
- const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
-
- // Calculate if we're within this clip's timeline range
- const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
- const isInClipRange =
- currentTime >= clipStartTime && currentTime < clipEndTime;
-
- // Sync playback events
- useEffect(() => {
- const audio = audioRef.current;
- if (!audio || !isInClipRange) return;
-
- const handleSeekEvent = (e: CustomEvent) => {
- // Always update audio time, even if outside clip range
- const timelineTime = e.detail.time;
- const audioTime = Math.max(
- trimStart,
- Math.min(
- clipDuration - trimEnd,
- timelineTime - clipStartTime + trimStart
- )
- );
- audio.currentTime = audioTime;
- };
-
- const handleUpdateEvent = (e: CustomEvent) => {
- // Always update audio time, even if outside clip range
- const timelineTime = e.detail.time;
- const targetTime = Math.max(
- trimStart,
- Math.min(
- clipDuration - trimEnd,
- timelineTime - clipStartTime + trimStart
- )
- );
-
- if (Math.abs(audio.currentTime - targetTime) > 0.5) {
- audio.currentTime = targetTime;
- }
- };
-
- const handleSpeed = (e: CustomEvent) => {
- audio.playbackRate = e.detail.speed;
- };
-
- window.addEventListener("playback-seek", handleSeekEvent as EventListener);
- window.addEventListener(
- "playback-update",
- handleUpdateEvent as EventListener
- );
- window.addEventListener("playback-speed", handleSpeed as EventListener);
-
- return () => {
- window.removeEventListener(
- "playback-seek",
- handleSeekEvent as EventListener
- );
- window.removeEventListener(
- "playback-update",
- handleUpdateEvent as EventListener
- );
- window.removeEventListener(
- "playback-speed",
- handleSpeed as EventListener
- );
- };
- }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
-
- // Sync playback state
- useEffect(() => {
- const audio = audioRef.current;
- if (!audio) return;
-
- if (isPlaying && isInClipRange && !trackMuted) {
- audio.play().catch(() => {});
- } else {
- audio.pause();
- }
- }, [isPlaying, isInClipRange, trackMuted]);
-
- // Sync volume and speed
- useEffect(() => {
- const audio = audioRef.current;
- if (!audio) return;
-
- audio.volume = volume;
- audio.muted = muted || trackMuted;
- audio.playbackRate = speed;
- }, [volume, speed, muted, trackMuted]);
-
- return (
- e.preventDefault()}
- />
- );
-}
diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/web/src/components/ui/avatar.tsx
index 7979c415..0c2fb4c4 100644
--- a/apps/web/src/components/ui/avatar.tsx
+++ b/apps/web/src/components/ui/avatar.tsx
@@ -3,47 +3,47 @@
import * as React from "react";
import { Avatar as AvatarPrimitive } from "radix-ui";
-import { cn } from "../../lib/utils";
+import { cn } from "@/utils/ui";
const Avatar = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
-
+
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
-
+
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
-
+
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx
index 02b8532b..21240ba0 100644
--- a/apps/web/src/components/ui/badge.tsx
+++ b/apps/web/src/components/ui/badge.tsx
@@ -1,36 +1,36 @@
-import * as React from "react";
+import type * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
-import { cn } from "../../lib/utils";
+import { cn } from "@/utils/ui";
const badgeVariants = cva(
- "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
- {
- variants: {
- variant: {
- default:
- "border-transparent bg-primary text-primary-foreground shadow-sm hover:bg-primary/80",
- secondary:
- "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
- destructive:
- "border-transparent bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/80",
- outline: "text-foreground",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- }
+ "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground shadow-sm hover:bg-primary/80",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ destructive:
+ "border-transparent bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/80",
+ outline: "text-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
);
export interface BadgeProps
- extends React.HTMLAttributes,
- VariantProps {}
+ extends React.HTMLAttributes,
+ VariantProps {}
function Badge({ className, variant, ...props }: BadgeProps) {
- return (
-
- );
+ return (
+
+ );
}
export { Badge, badgeVariants };
diff --git a/apps/web/src/components/ui/breadcrumb.tsx b/apps/web/src/components/ui/breadcrumb.tsx
index d9df9bf5..e95f2965 100644
--- a/apps/web/src/components/ui/breadcrumb.tsx
+++ b/apps/web/src/components/ui/breadcrumb.tsx
@@ -2,114 +2,114 @@ import * as React from "react";
import { Slot as SlotPrimitive } from "radix-ui";
import { ChevronRight, MoreHorizontal } from "lucide-react";
-import { cn } from "../../lib/utils";
+import { cn } from "@/utils/ui";
const Breadcrumb = React.forwardRef<
- HTMLElement,
- React.ComponentPropsWithoutRef<"nav"> & {
- separator?: React.ReactNode;
- }
+ HTMLElement,
+ React.ComponentPropsWithoutRef<"nav"> & {
+ separator?: React.ReactNode;
+ }
>(({ ...props }, ref) => );
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<
- HTMLOListElement,
- React.ComponentPropsWithoutRef<"ol">
+ HTMLOListElement,
+ React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
-
+
));
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<
- HTMLLIElement,
- React.ComponentPropsWithoutRef<"li">
+ HTMLLIElement,
+ React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
-
+
));
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
- HTMLAnchorElement,
- React.ComponentPropsWithoutRef<"a"> & {
- asChild?: boolean;
- }
+ HTMLAnchorElement,
+ React.ComponentPropsWithoutRef<"a"> & {
+ asChild?: boolean;
+ }
>(({ asChild, className, ...props }, ref) => {
- const Comp = asChild ? SlotPrimitive.Slot : "a";
+ const Comp = asChild ? SlotPrimitive.Slot : "a";
- return (
-
- );
+ return (
+
+ );
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<
- HTMLSpanElement,
- React.ComponentPropsWithoutRef<"span">
+ HTMLSpanElement,
+ React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
-
+
));
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({
- children,
- className,
- ...props
+ children,
+ className,
+ ...props
}: React.ComponentProps<"li">) => (
- svg]:w-3.5 [&>svg]:h-3.5", className)}
- {...props}
- >
- {children ?? }
-
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({
- className,
- ...props
+ className,
+ ...props
}: React.ComponentProps<"span">) => (
-
-
- More
-
+
+
+ More
+
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
- Breadcrumb,
- BreadcrumbList,
- BreadcrumbItem,
- BreadcrumbLink,
- BreadcrumbPage,
- BreadcrumbSeparator,
- BreadcrumbEllipsis,
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
};
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
index 56a10f44..6c063eff 100644
--- a/apps/web/src/components/ui/button.tsx
+++ b/apps/web/src/components/ui/button.tsx
@@ -2,59 +2,63 @@ import * as React from "react";
import { Slot as SlotPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
-import { cn } from "../../lib/utils";
+import { cn } from "@/utils/ui";
const buttonVariants = cva(
- "inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
- {
- variants: {
- variant: {
- default:
- "bg-foreground text-background shadow-sm hover:bg-foreground/90",
- primary:
- "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
- "primary-gradient":
- "bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity",
- destructive:
- "bg-destructive/0 border border-destructive/25 text-destructive shadow-xs hover:bg-destructive hover:text-destructive-foreground",
- outline:
- "border border-input bg-transparent shadow-xs hover:opacity-75 transition-opacity hover:text-accent-foreground",
- secondary:
- "bg-secondary text-secondary-foreground shadow-xs hover:bg-foreground/15 border border-input",
- text: "bg-transparent p-0 rounded-none opacity-100 hover:opacity-50 transition-opacity", // Instead of ghost (matches app better)
- link: "text-primary underline-offset-4 hover:underline",
- },
- size: {
- default: "h-9 px-4 py-2",
- sm: "h-8 rounded-sm px-3 text-xs",
- lg: "h-10 rounded-md px-8",
- icon: "h-7 w-7",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- }
+ "inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground hover:bg-primary-hover",
+ background:
+ "bg-background text-foreground hover:bg-background/90",
+ foreground:
+ "bg-foreground text-background hover:bg-foreground/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ "destructive-foreground":
+ "border bg-background hover:bg-destructive/15 text-destructive",
+ outline:
+ "border border-border bg-transparent hover:bg-accent/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-foreground/15 border border-input",
+ text: "bg-transparent rounded-none opacity-100 hover:opacity-75",
+ link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto",
+ },
+ size: {
+ default: "h-9.5 px-4 py-2",
+ sm: "h-8 px-3 text-xs",
+ lg: "h-10 p-5 px-6",
+ icon: "size-7",
+ text: "p-0",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
);
export interface ButtonProps
- extends React.ButtonHTMLAttributes,
- VariantProps {
- asChild?: boolean;
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
}
const Button = React.forwardRef(
- ({ className, variant, size, asChild = false, ...props }, ref) => {
- const Comp = asChild ? SlotPrimitive.Slot : "button";
- return (
-
- );
- }
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? SlotPrimitive.Slot : "button";
+ const effectiveSize = size ?? (variant === "text" ? "text" : "default");
+ return (
+
+ );
+ },
);
Button.displayName = "Button";
diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx
index 192643cb..a0664cc3 100644
--- a/apps/web/src/components/ui/calendar.tsx
+++ b/apps/web/src/components/ui/calendar.tsx
@@ -1,71 +1,71 @@
"use client";
-import * as React from "react";
+import type * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
-import { cn } from "../../lib/utils";
+import { cn } from "@/utils/ui";
import { buttonVariants } from "./button";
export type CalendarProps = React.ComponentProps;
function Calendar({
- className,
- classNames,
- showOutsideDays = true,
- ...props
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
}: CalendarProps) {
- return (
- .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
- : "[&:has([aria-selected])]:rounded-md"
- ),
- day: cn(
- buttonVariants({ variant: "text" }),
- "h-8 w-8 p-0 font-normal aria-selected:opacity-100"
- ),
- day_range_start: "day-range-start",
- day_range_end: "day-range-end",
- day_selected:
- "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
- day_today: "bg-accent text-accent-foreground",
- day_outside:
- "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
- day_disabled: "text-muted-foreground opacity-50",
- day_range_middle:
- "aria-selected:bg-accent aria-selected:text-accent-foreground",
- day_hidden: "invisible",
- ...classNames,
- }}
- components={{
- IconLeft: ({ ...props }) => ,
- IconRight: ({ ...props }) => ,
- }}
- {...props}
- />
- );
+ return (
+ .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
+ : "[&:has([aria-selected])]:rounded-md",
+ ),
+ day: cn(
+ buttonVariants({ variant: "text" }),
+ "size-8 p-0 font-normal aria-selected:opacity-100",
+ ),
+ day_range_start: "day-range-start",
+ day_range_end: "day-range-end",
+ day_selected:
+ "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
+ day_today: "bg-accent text-accent-foreground",
+ day_outside:
+ "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
+ day_disabled: "text-muted-foreground opacity-50",
+ day_range_middle:
+ "aria-selected:bg-accent aria-selected:text-accent-foreground",
+ day_hidden: "invisible",
+ ...classNames,
+ }}
+ components={{
+ IconLeft: ({ ...props }) => ,
+ IconRight: ({ ...props }) => ,
+ }}
+ {...props}
+ />
+ );
}
Calendar.displayName = "Calendar";
diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx
index 6d0b37cc..c3fc45f0 100644
--- a/apps/web/src/components/ui/card.tsx
+++ b/apps/web/src/components/ui/card.tsx
@@ -1,80 +1,80 @@
import * as React from "react";
-import { cn } from "../../lib/utils";
+import { cn } from "@/utils/ui";
const Card = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes
+ HTMLDivElement,
+ React.HTMLAttributes
>(({ className, ...props }, ref) => (
-
+
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes
+ HTMLDivElement,
+ React.HTMLAttributes
>(({ className, ...props }, ref) => (
-
+
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes
+ HTMLDivElement,
+ React.HTMLAttributes
>(({ className, ...props }, ref) => (
-
+
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes
+ HTMLDivElement,
+ React.HTMLAttributes
>(({ className, ...props }, ref) => (
-
+
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes
+ HTMLDivElement,
+ React.HTMLAttributes
>(({ className, ...props }, ref) => (
-
+
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes
+ HTMLDivElement,
+ React.HTMLAttributes
>(({ className, ...props }, ref) => (
-
+
));
CardFooter.displayName = "CardFooter";
export {
- Card,
- CardHeader,
- CardFooter,
- CardTitle,
- CardDescription,
- CardContent,
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardDescription,
+ CardContent,
};
diff --git a/apps/web/src/components/ui/carousel.tsx b/apps/web/src/components/ui/carousel.tsx
index a9c98b67..9018b9ea 100644
--- a/apps/web/src/components/ui/carousel.tsx
+++ b/apps/web/src/components/ui/carousel.tsx
@@ -1,12 +1,12 @@
"use client";
-import * as React from "react";
import useEmblaCarousel, {
- type UseEmblaCarouselType,
+ type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
+import * as React from "react";
-import { cn } from "../../lib/utils";
+import { cn } from "@/utils/ui";
import { Button } from "./button";
type CarouselApi = UseEmblaCarouselType[1];
@@ -15,248 +15,248 @@ type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
- opts?: CarouselOptions;
- plugins?: CarouselPlugin;
- orientation?: "horizontal" | "vertical";
- setApi?: (api: CarouselApi) => void;
+ opts?: CarouselOptions;
+ plugins?: CarouselPlugin;
+ orientation?: "horizontal" | "vertical";
+ setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
- carouselRef: ReturnType[0];
- api: ReturnType