From 4d127a038bf768eded68aae3cfa5c1b12241bdac Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Mar 2026 00:55:15 +0100 Subject: chore: normalize line endings --- apps/web/src/app/api/auth/[...all]/route.ts | 8 +- apps/web/src/app/api/health/route.ts | 6 +- apps/web/src/app/api/sounds/search/route.ts | 560 +-- apps/web/src/app/base-page.tsx | 112 +- apps/web/src/app/blog/[slug]/page.tsx | 270 +- apps/web/src/app/blog/page.tsx | 104 +- apps/web/src/app/brand/page.tsx | 536 +-- apps/web/src/app/changelog/[version]/page.tsx | 210 +- .../changelog/components/copy-markdown-button.tsx | 136 +- apps/web/src/app/changelog/components/release.tsx | 212 +- apps/web/src/app/changelog/utils.ts | 86 +- apps/web/src/app/contributors/page.tsx | 484 +-- apps/web/src/app/editor/[project_id]/page.tsx | 226 +- apps/web/src/app/globals.css | 568 +-- apps/web/src/app/metadata.ts | 172 +- apps/web/src/app/page.tsx | 42 +- apps/web/src/app/projects/page.tsx | 2030 +++++------ apps/web/src/app/projects/store.ts | 270 +- apps/web/src/app/roadmap/page.tsx | 292 +- apps/web/src/app/robots.ts | 26 +- apps/web/src/app/rss.xml/route.ts | 92 +- apps/web/src/app/sitemap.ts | 122 +- apps/web/src/app/sponsors/page.tsx | 172 +- apps/web/src/components/editable-timecode.tsx | 300 +- .../editor/dialogs/project-info-dialog.tsx | 166 +- .../components/editor/dialogs/shortcuts-dialog.tsx | 458 +-- .../editor/dialogs/storage-persistence-dialog.tsx | 82 +- apps/web/src/components/editor/editor-header.tsx | 490 +-- apps/web/src/components/editor/export-button.tsx | 672 ++-- apps/web/src/components/editor/mobile-gate.tsx | 144 +- .../editor/panels/assets/drag-overlay.tsx | 122 +- .../editor/panels/assets/draggable-item.tsx | 478 +-- .../src/components/editor/panels/assets/tabbar.tsx | 222 +- .../editor/panels/assets/views/base-panel.tsx | 114 +- .../editor/panels/assets/views/captions.tsx | 358 +- .../panels/assets/views/settings/background.tsx | 454 +-- .../editor/panels/assets/views/settings/index.tsx | 738 ++-- .../editor/panels/assets/views/sounds.tsx | 1148 +++--- .../editor/panels/assets/views/stickers.tsx | 940 ++--- .../components/editor/panels/assets/views/text.tsx | 92 +- .../panels/preview/bookmark-note-overlay.tsx | 118 +- .../editor/panels/preview/context-menu.tsx | 146 +- .../editor/panels/preview/guide-overlay.tsx | 64 +- .../editor/panels/preview/guide-popover.tsx | 200 +- .../editor/panels/preview/handle-primitives.tsx | 664 ++-- .../src/components/editor/panels/preview/index.tsx | 8 +- .../panels/preview/preview-interaction-overlay.tsx | 234 +- .../editor/panels/preview/preview-viewport.tsx | 1152 +++---- .../editor/panels/preview/snap-guides.tsx | 94 +- .../editor/panels/preview/text-edit-overlay.tsx | 424 +-- .../components/editor/panels/preview/toolbar.tsx | 302 +- .../editor/panels/preview/transform-handles.tsx | 280 +- .../properties/components/keyframe-toggle.tsx | 64 +- .../editor/panels/properties/empty-view.tsx | 40 +- .../properties/hooks/use-element-playhead.ts | 48 +- .../hooks/use-keyframed-color-property.ts | 190 +- .../hooks/use-keyframed-number-property.ts | 322 +- .../hooks/use-keyframed-param-property.ts | 296 +- .../hooks/use-keyframed-vector-property.ts | 380 +- .../panels/properties/hooks/use-property-draft.ts | 144 +- .../editor/panels/properties/registry.tsx | 606 ++-- .../panels/properties/stores/properties-store.ts | 36 +- .../editor/panels/properties/tabs/audio-tab.tsx | 218 +- .../editor/panels/properties/tabs/blending-tab.tsx | 460 +-- .../editor/panels/properties/tabs/effects-tab.tsx | 618 ++-- .../editor/panels/properties/tabs/graphic-tab.tsx | 476 +-- .../editor/panels/properties/tabs/speed-tab.tsx | 274 +- .../editor/panels/properties/tabs/text-tab.tsx | 1524 ++++---- .../panels/properties/tabs/transform-tab.tsx | 848 ++--- .../editor/panels/timeline/drag-line.tsx | 58 +- .../editor/panels/timeline/snap-indicator.tsx | 100 +- .../editor/panels/timeline/timeline-element.tsx | 1868 +++++----- .../editor/panels/timeline/timeline-tick.tsx | 78 +- .../editor/panels/timeline/timeline-track.tsx | 256 +- apps/web/src/components/editor/scenes-view.tsx | 438 +-- apps/web/src/components/footer.tsx | 246 +- .../src/components/gitHub-contribute-section.tsx | 88 +- apps/web/src/components/header.tsx | 394 +-- apps/web/src/components/landing/hero.tsx | 82 +- .../src/components/providers/editor-provider.tsx | 266 +- apps/web/src/components/section.tsx | 514 +-- apps/web/src/components/storage-provider.tsx | 162 +- apps/web/src/components/theme-toggle.tsx | 78 +- apps/web/src/components/ui/accordion.tsx | 114 +- apps/web/src/components/ui/alert.tsx | 142 +- apps/web/src/components/ui/avatar.tsx | 100 +- apps/web/src/components/ui/breadcrumb.tsx | 212 +- apps/web/src/components/ui/button.tsx | 126 +- apps/web/src/components/ui/calendar.tsx | 144 +- apps/web/src/components/ui/checkbox.tsx | 60 +- apps/web/src/components/ui/color-picker.tsx | 938 ++--- apps/web/src/components/ui/context-menu.tsx | 606 ++-- apps/web/src/components/ui/dialog.tsx | 290 +- apps/web/src/components/ui/dropdown-menu.tsx | 586 ++-- apps/web/src/components/ui/font-picker.tsx | 516 +-- apps/web/src/components/ui/input.tsx | 286 +- apps/web/src/components/ui/number-field.tsx | 636 ++-- apps/web/src/components/ui/popover.tsx | 104 +- apps/web/src/components/ui/prose.tsx | 222 +- apps/web/src/components/ui/select.tsx | 442 +-- apps/web/src/components/ui/sheet.tsx | 314 +- apps/web/src/components/ui/slider.tsx | 60 +- apps/web/src/components/ui/sonner.tsx | 70 +- apps/web/src/components/ui/spinner.tsx | 34 +- apps/web/src/components/ui/split-button.tsx | 180 +- apps/web/src/components/ui/tabs.tsx | 170 +- apps/web/src/components/ui/textarea.tsx | 48 +- apps/web/src/components/ui/tooltip.tsx | 152 +- .../src/components/ui/use-overlay-open-change.ts | 156 +- apps/web/src/constants/animation-constants.ts | 4 +- apps/web/src/constants/export-constants.ts | 24 +- apps/web/src/constants/font-constants.ts | 22 +- apps/web/src/constants/guide-constants.ts | 8 +- apps/web/src/constants/mask-constants.ts | 8 +- apps/web/src/constants/retime-constants.ts | 60 +- apps/web/src/constants/site-constants.ts | 138 +- apps/web/src/constants/sticker-constants.ts | 16 +- apps/web/src/constants/text-constants.ts | 22 +- apps/web/src/constants/timeline-constants.ts | 170 +- apps/web/src/constants/transcription-constants.ts | 120 +- apps/web/src/core/index.ts | 106 +- apps/web/src/core/managers/audio-manager.ts | 1416 ++++---- apps/web/src/core/managers/commands.ts | 98 +- apps/web/src/core/managers/media-manager.ts | 3 + apps/web/src/core/managers/playback-manager.ts | 24 +- apps/web/src/core/managers/project-manager.ts | 19 + apps/web/src/core/managers/renderer-manager.ts | 6 +- apps/web/src/core/managers/save-manager.ts | 214 +- apps/web/src/core/managers/scenes-manager.ts | 726 ++-- apps/web/src/core/managers/selection-manager.ts | 146 +- apps/web/src/core/managers/timeline-manager.ts | 8 + .../timeline/element/use-element-selection.ts | 274 +- .../timeline/element/use-keyframe-selection.ts | 458 +-- apps/web/src/hooks/timeline/use-bookmark-drag.ts | 562 +-- .../web/src/hooks/timeline/use-edge-auto-scroll.ts | 194 +- .../hooks/timeline/use-initial-scroll-bottom.ts | 84 +- apps/web/src/hooks/timeline/use-scroll-sync.ts | 346 +- .../hooks/timeline/use-snap-indicator-position.ts | 108 +- .../src/hooks/timeline/use-timeline-drag-drop.ts | 1218 +++---- apps/web/src/hooks/timeline/use-timeline-seek.ts | 394 +-- apps/web/src/hooks/use-editor.ts | 116 +- apps/web/src/hooks/use-element-preview.ts | 76 +- apps/web/src/hooks/use-focus-lock.ts | 158 +- apps/web/src/hooks/use-font-atlas.ts | 106 +- apps/web/src/hooks/use-infinite-scroll.ts | 70 +- apps/web/src/hooks/use-mask-handles.ts | 516 +-- apps/web/src/hooks/use-menu-preview.ts | 52 +- apps/web/src/hooks/use-mobile.ts | 38 +- apps/web/src/hooks/use-paste-media.ts | 228 +- apps/web/src/hooks/use-resize-observer.ts | 52 +- apps/web/src/hooks/use-shift-key.ts | 70 +- apps/web/src/hooks/use-sound-search.ts | 308 +- apps/web/src/hooks/use-storage-persistence.ts | 90 +- apps/web/src/hooks/use-transform-handles.ts | 1238 +++---- apps/web/src/lib/actions/definitions.ts | 410 +-- apps/web/src/lib/actions/keybinding.ts | 158 +- apps/web/src/lib/actions/types.ts | 88 +- apps/web/src/lib/animation/color-channel.ts | 38 +- .../web/src/lib/animation/graphic-param-channel.ts | 154 +- apps/web/src/lib/animation/index.ts | 142 +- apps/web/src/lib/animation/keyframe-query.ts | 144 +- apps/web/src/lib/animation/number-channel.ts | 40 +- apps/web/src/lib/animation/property-groups.ts | 78 +- apps/web/src/lib/animation/property-registry.ts | 738 ++-- apps/web/src/lib/animation/resolve.ts | 270 +- apps/web/src/lib/animation/target-resolver.ts | 554 +-- apps/web/src/lib/animation/types.ts | 238 +- apps/web/src/lib/animation/vector-channel.ts | 144 +- apps/web/src/lib/auth/client.ts | 12 +- apps/web/src/lib/auth/server.ts | 86 +- apps/web/src/lib/blog/query.ts | 256 +- apps/web/src/lib/blog/types.ts | 184 +- apps/web/src/lib/cancel-interaction.ts | 48 +- apps/web/src/lib/commands/media/add-media-asset.ts | 34 + .../src/lib/commands/media/remove-media-asset.ts | 198 +- apps/web/src/lib/commands/preview-tracker.ts | 46 +- .../commands/project/update-project-settings.ts | 92 +- apps/web/src/lib/commands/scene/create-scene.ts | 80 +- apps/web/src/lib/commands/scene/delete-scene.ts | 118 +- apps/web/src/lib/commands/scene/index.ts | 14 +- apps/web/src/lib/commands/scene/move-bookmark.ts | 118 +- apps/web/src/lib/commands/scene/remove-bookmark.ts | 118 +- apps/web/src/lib/commands/scene/rename-scene.ts | 92 +- apps/web/src/lib/commands/scene/toggle-bookmark.ts | 104 +- apps/web/src/lib/commands/scene/update-bookmark.ts | 110 +- .../src/lib/commands/timeline/clipboard/paste.ts | 586 ++-- .../commands/timeline/element/delete-elements.ts | 158 +- .../timeline/element/duplicate-elements.ts | 256 +- .../timeline/element/effects/add-effect.ts | 148 +- .../lib/commands/timeline/element/effects/index.ts | 10 +- .../timeline/element/effects/remove-effect.ts | 130 +- .../timeline/element/effects/reorder-effect.ts | 146 +- .../timeline/element/effects/toggle-effect.ts | 134 +- .../web/src/lib/commands/timeline/element/index.ts | 30 +- .../commands/timeline/element/insert-element.ts | 716 ++-- .../commands/timeline/element/keyframes/index.ts | 10 +- .../keyframes/remove-effect-param-keyframe.ts | 136 +- .../timeline/element/keyframes/remove-keyframe.ts | 276 +- .../timeline/element/keyframes/retime-keyframe.ts | 148 +- .../keyframes/upsert-effect-param-keyframe.ts | 168 +- .../timeline/element/keyframes/upsert-keyframe.ts | 190 +- .../lib/commands/timeline/element/masks/index.ts | 4 +- .../commands/timeline/element/masks/remove-mask.ts | 128 +- .../timeline/element/masks/toggle-mask-inverted.ts | 150 +- .../lib/commands/timeline/element/move-elements.ts | 322 +- .../lib/commands/timeline/element/retime/index.ts | 2 +- .../element/retime/update-element-retime.ts | 228 +- .../timeline/element/toggle-elements-muted.ts | 114 +- .../timeline/element/toggle-elements-visibility.ts | 94 +- .../timeline/element/update-element-duration.ts | 114 +- .../timeline/element/update-element-start-time.ts | 138 +- .../commands/timeline/element/update-element.ts | 94 +- apps/web/src/lib/commands/timeline/index.ts | 10 +- .../src/lib/commands/timeline/track/add-track.ts | 106 +- .../lib/commands/timeline/track/remove-track.ts | 70 +- .../commands/timeline/track/toggle-track-mute.ts | 78 +- .../timeline/track/toggle-track-visibility.ts | 80 +- .../src/lib/commands/timeline/tracks-snapshot.ts | 40 +- apps/web/src/lib/drag-data.ts | 96 +- apps/web/src/lib/effects/effect.vert.glsl | 14 +- apps/web/src/lib/effects/registry.ts | 20 +- apps/web/src/lib/effects/types.ts | 84 +- apps/web/src/lib/fonts/types.ts | 48 +- apps/web/src/lib/gradients/parser.ts | 1304 +++---- apps/web/src/lib/graphics/definitions/ellipse.ts | 146 +- apps/web/src/lib/graphics/definitions/index.ts | 58 +- apps/web/src/lib/graphics/definitions/polygon.ts | 422 +-- apps/web/src/lib/graphics/definitions/rectangle.ts | 170 +- apps/web/src/lib/graphics/definitions/shared.ts | 32 +- apps/web/src/lib/graphics/definitions/star.ts | 218 +- apps/web/src/lib/graphics/index.ts | 250 +- apps/web/src/lib/graphics/registry.ts | 20 +- apps/web/src/lib/graphics/stroke.ts | 204 +- apps/web/src/lib/graphics/types.ts | 46 +- apps/web/src/lib/guides/definitions/custom.tsx | 48 +- apps/web/src/lib/guides/definitions/platforms.tsx | 130 +- .../src/lib/guides/definitions/tiktok-layout.tsx | 670 ++-- apps/web/src/lib/guides/index.ts | 26 +- apps/web/src/lib/guides/registry.tsx | 56 +- apps/web/src/lib/guides/types.ts | 40 +- apps/web/src/lib/masks/__tests__/snap.test.ts | 470 +-- apps/web/src/lib/masks/definitions/box-like.ts | 540 +-- apps/web/src/lib/masks/definitions/ellipse.ts | 144 +- apps/web/src/lib/masks/definitions/index.ts | 78 +- apps/web/src/lib/masks/definitions/rectangle.ts | 188 +- apps/web/src/lib/masks/definitions/split.ts | 694 ++-- apps/web/src/lib/masks/geometry.ts | 224 +- apps/web/src/lib/masks/handle-positions.ts | 764 ++-- apps/web/src/lib/masks/index.ts | 36 +- apps/web/src/lib/masks/param-update.ts | 118 +- .../src/lib/masks/shaders/jfa-distance.frag.glsl | 72 +- apps/web/src/lib/masks/snap.ts | 670 ++-- apps/web/src/lib/masks/types.ts | 230 +- apps/web/src/lib/masks/utils.ts | 128 +- apps/web/src/lib/media/audio-mastering.ts | 206 +- apps/web/src/lib/media/media-utils.ts | 64 +- apps/web/src/lib/media/mediabunny.ts | 316 +- apps/web/src/lib/media/types.ts | 18 +- apps/web/src/lib/media/upload-toast.ts | 102 +- apps/web/src/lib/params.ts | 100 +- apps/web/src/lib/preview/element-bounds.ts | 654 ++-- apps/web/src/lib/preview/preview-snap.ts | 1078 +++--- apps/web/src/lib/project/__tests__/fps.test.ts | 65 + apps/web/src/lib/project/fps.ts | 39 + apps/web/src/lib/project/types.ts | 106 +- apps/web/src/lib/rate-limit.ts | 42 +- apps/web/src/lib/registry.ts | 80 +- apps/web/src/lib/rendering.ts | 56 +- apps/web/src/lib/retime/__tests__/resolve.test.ts | 122 +- apps/web/src/lib/retime/__tests__/split.test.ts | 62 +- apps/web/src/lib/retime/audio-stretch.ts | 364 +- apps/web/src/lib/retime/index.ts | 8 +- apps/web/src/lib/retime/presets.ts | 24 +- apps/web/src/lib/retime/resolve.ts | 96 +- apps/web/src/lib/retime/split.ts | 68 +- apps/web/src/lib/scenes.ts | 206 +- apps/web/src/lib/selection/constants.ts | 2 +- apps/web/src/lib/selection/context.tsx | 104 +- apps/web/src/lib/selection/hit-testing.ts | 100 +- apps/web/src/lib/selection/hooks/use-box-select.ts | 340 +- .../src/lib/selection/hooks/use-selection-scope.ts | 56 +- apps/web/src/lib/selection/index.ts | 8 +- apps/web/src/lib/selection/scope.ts | 58 +- apps/web/src/lib/selection/selectable-item.tsx | 214 +- apps/web/src/lib/selection/selectable-surface.tsx | 576 ++-- apps/web/src/lib/selection/selection-box.tsx | 100 +- apps/web/src/lib/selection/state.ts | 330 +- apps/web/src/lib/selection/types.ts | 78 +- apps/web/src/lib/shaders/jfa-init.frag.glsl | 50 +- apps/web/src/lib/shaders/jfa-step.frag.glsl | 120 +- apps/web/src/lib/sounds/types.ts | 78 +- .../src/lib/stickers/__tests__/sticker-id.test.ts | 68 +- apps/web/src/lib/stickers/index.ts | 634 ++-- .../src/lib/stickers/providers/countries-data.ts | 3638 ++++++++++---------- apps/web/src/lib/stickers/providers/index.ts | 48 +- apps/web/src/lib/stickers/providers/logos.ts | 62 +- apps/web/src/lib/stickers/sticker-id.ts | 68 +- apps/web/src/lib/text/measure-element.ts | 264 +- apps/web/src/lib/time.ts | 3 +- apps/web/src/lib/timeline/audio-constants.ts | 4 +- apps/web/src/lib/timeline/audio-state.ts | 200 +- apps/web/src/lib/timeline/bookmarks.ts | 298 +- apps/web/src/lib/timeline/defaults.ts | 154 +- apps/web/src/lib/timeline/drag-utils.ts | 38 +- apps/web/src/lib/timeline/drag.ts | 84 +- apps/web/src/lib/timeline/drop-utils.ts | 628 ++-- apps/web/src/lib/timeline/element-utils.ts | 870 ++--- apps/web/src/lib/timeline/index.ts | 56 +- apps/web/src/lib/timeline/pixel-utils.ts | 158 +- apps/web/src/lib/timeline/ripple-utils.ts | 34 +- apps/web/src/lib/timeline/selection-hit-testing.ts | 266 +- apps/web/src/lib/timeline/snap-utils.ts | 344 +- apps/web/src/lib/timeline/track-element-update.ts | 66 +- apps/web/src/lib/timeline/track-utils.ts | 594 ++-- apps/web/src/lib/timeline/types.ts | 608 ++-- apps/web/src/lib/transcription/caption.ts | 98 +- apps/web/src/lib/transcription/types.ts | 94 +- apps/web/src/proxy.ts | 36 +- apps/web/src/services/renderer/canvas-utils.ts | 32 +- apps/web/src/services/renderer/mask-feather.ts | 108 +- .../services/renderer/nodes/effect-layer-node.ts | 162 +- .../src/services/renderer/nodes/graphic-node.ts | 184 +- apps/web/src/services/renderer/nodes/video-node.ts | 74 +- apps/web/src/services/renderer/scene-builder.ts | 544 +-- apps/web/src/services/renderer/scene-exporter.ts | 326 +- apps/web/src/services/renderer/webgl/jfa.ts | 388 +-- .../src/services/renderer/webgl/webgl-context.ts | 98 +- .../renderer/webgl/webgl-effect-renderer.ts | 70 +- .../web/src/services/renderer/webgl/webgl-utils.ts | 596 ++-- .../storage/migrations/__tests__/fixtures/index.ts | 20 +- .../storage/migrations/__tests__/fixtures/v5.ts | 108 +- .../migrations/__tests__/v15-to-v16.test.ts | 186 +- .../migrations/__tests__/v16-to-v17.test.ts | 264 +- .../migrations/__tests__/v18-to-v19.test.ts | 126 +- .../storage/migrations/__tests__/v3-to-v4.test.ts | 280 +- .../storage/migrations/__tests__/v4-to-v5.test.ts | 274 +- .../storage/migrations/__tests__/v5-to-v6.test.ts | 184 +- .../storage/migrations/__tests__/v8-to-v9.test.ts | 350 +- apps/web/src/services/storage/migrations/base.ts | 18 +- apps/web/src/services/storage/migrations/index.ts | 92 +- apps/web/src/services/storage/migrations/runner.ts | 310 +- .../storage/migrations/transformers/v10-to-v11.ts | 226 +- .../storage/migrations/transformers/v11-to-v12.ts | 392 +-- .../storage/migrations/transformers/v12-to-v13.ts | 204 +- .../storage/migrations/transformers/v13-to-v14.ts | 212 +- .../storage/migrations/transformers/v14-to-v15.ts | 134 +- .../storage/migrations/transformers/v15-to-v16.ts | 120 +- .../storage/migrations/transformers/v16-to-v17.ts | 214 +- .../storage/migrations/transformers/v17-to-v18.ts | 210 +- .../storage/migrations/transformers/v18-to-v19.ts | 92 +- .../storage/migrations/transformers/v3-to-v4.ts | 378 +- .../storage/migrations/transformers/v4-to-v5.ts | 354 +- .../storage/migrations/transformers/v5-to-v6.ts | 176 +- .../storage/migrations/transformers/v6-to-v7.ts | 212 +- .../storage/migrations/transformers/v7-to-v8.ts | 192 +- .../storage/migrations/transformers/v8-to-v9.ts | 198 +- .../storage/migrations/transformers/v9-to-v10.ts | 42 +- .../src/services/storage/migrations/v10-to-v11.ts | 32 +- .../src/services/storage/migrations/v11-to-v12.ts | 32 +- .../src/services/storage/migrations/v12-to-v13.ts | 32 +- .../src/services/storage/migrations/v13-to-v14.ts | 32 +- .../src/services/storage/migrations/v14-to-v15.ts | 32 +- .../src/services/storage/migrations/v15-to-v16.ts | 32 +- .../src/services/storage/migrations/v16-to-v17.ts | 32 +- .../src/services/storage/migrations/v17-to-v18.ts | 32 +- .../src/services/storage/migrations/v18-to-v19.ts | 32 +- .../src/services/storage/migrations/v3-to-v4.ts | 32 +- .../src/services/storage/migrations/v4-to-v5.ts | 32 +- .../src/services/storage/migrations/v5-to-v6.ts | 32 +- .../src/services/storage/migrations/v6-to-v7.ts | 32 +- .../src/services/storage/migrations/v7-to-v8.ts | 32 +- .../src/services/storage/migrations/v8-to-v9.ts | 32 +- .../src/services/storage/migrations/v9-to-v10.ts | 32 +- apps/web/src/services/storage/quota.ts | 290 +- apps/web/src/services/transcription/service.ts | 372 +- apps/web/src/services/transcription/worker.ts | 352 +- apps/web/src/stores/editor-store.ts | 48 +- apps/web/src/stores/keybindings-store.ts | 694 ++-- .../src/stores/keybindings/migrations/v2-to-v3.ts | 52 +- .../src/stores/keybindings/migrations/v3-to-v4.ts | 50 +- .../src/stores/keybindings/migrations/v4-to-v5.ts | 34 +- .../src/stores/keybindings/migrations/v5-to-v6.ts | 34 +- apps/web/src/stores/panel-store.ts | 186 +- apps/web/src/stores/preview-store.ts | 224 +- apps/web/src/stores/sounds-store.ts | 522 +-- apps/web/src/stores/timeline-store.ts | 112 +- apps/web/src/types/eyedropper.d.ts | 26 +- apps/web/src/types/glsl.d.ts | 8 +- apps/web/src/types/soundtouchjs.d.ts | 28 +- apps/web/src/utils/color.ts | 364 +- apps/web/src/utils/id.ts | 56 +- apps/web/src/utils/math.ts | 168 +- 392 files changed, 45860 insertions(+), 45699 deletions(-) create mode 100644 apps/web/src/lib/project/__tests__/fps.test.ts create mode 100644 apps/web/src/lib/project/fps.ts (limited to 'apps/web/src') diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts index e17d0cce..544457e2 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 "@/lib/auth/server"; -import { toNextJsHandler } from "better-auth/next-js"; - -export const { POST, GET } = toNextJsHandler(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/health/route.ts b/apps/web/src/app/api/health/route.ts index edb40a0d..dbe6fdb1 100644 --- a/apps/web/src/app/api/health/route.ts +++ b/apps/web/src/app/api/health/route.ts @@ -1,3 +1,3 @@ -export async function GET() { - 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 339a54da..3070d152 100644 --- a/apps/web/src/app/api/sounds/search/route.ts +++ b/apps/web/src/app/api/sounds/search/route.ts @@ -1,280 +1,280 @@ -import { webEnv } from "@opencut/env/web"; -import { type NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -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), -}); - -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(), -}); - -const freesoundResponseSchema = z.object({ - 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(), -}); - -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(), -}); - -function buildSortParameter({ query, sort }: { query?: string; sort: string }) { - if (!query) return `${sort}_desc`; - return sort === "score" ? "score" : `${sort}_desc`; -} - -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 *]`); - - 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", - ); -} - -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 }); - } - - const { searchParams } = new URL(request.url); - - 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 (!validationResult.success) { - return NextResponse.json( - { - error: "Invalid parameters", - details: validationResult.error.flatten().fieldErrors, - }, - { status: 400 }, - ); - } - - const { - q: query, - type, - page, - page_size: pageSize, - sort, - min_rating, - commercial_only, - } = validationResult.data; - - 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 baseUrl = "https://freesound.org/apiv2/search/text/"; - - const sortParam = buildSortParameter({ query, sort }); - - 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", - }); - - const isEffectsSearch = type === "effects" || !type; - if (isEffectsSearch) { - applyEffectsFilters({ params, min_rating, commercial_only }); - } - - 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 }, - ); - } - - 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 data = freesoundValidation.data; - - 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 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 }, - ); - } -} +import { webEnv } from "@opencut/env/web"; +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +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), +}); + +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(), +}); + +const freesoundResponseSchema = z.object({ + 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(), +}); + +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(), +}); + +function buildSortParameter({ query, sort }: { query?: string; sort: string }) { + if (!query) return `${sort}_desc`; + return sort === "score" ? "score" : `${sort}_desc`; +} + +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 *]`); + + 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", + ); +} + +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 }); + } + + const { searchParams } = new URL(request.url); + + 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 (!validationResult.success) { + return NextResponse.json( + { + error: "Invalid parameters", + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 }, + ); + } + + const { + q: query, + type, + page, + page_size: pageSize, + sort, + min_rating, + commercial_only, + } = validationResult.data; + + 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 baseUrl = "https://freesound.org/apiv2/search/text/"; + + const sortParam = buildSortParameter({ query, sort }); + + 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", + }); + + const isEffectsSearch = type === "effects" || !type; + if (isEffectsSearch) { + applyEffectsFilters({ params, min_rating, commercial_only }); + } + + 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 }, + ); + } + + 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 data = freesoundValidation.data; + + 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 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 }, + ); + } +} diff --git a/apps/web/src/app/base-page.tsx b/apps/web/src/app/base-page.tsx index 1841a1ee..78768581 100644 --- a/apps/web/src/app/base-page.tsx +++ b/apps/web/src/app/base-page.tsx @@ -1,56 +1,56 @@ -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?: React.ReactNode; - action?: React.ReactNode; -} - -export function BasePage({ - children, - className = "", - mainClassName = "", - maxWidth = "3xl", - title, - description, - action, -}: BasePageProps) { - const maxWidthClass = { - "3xl": "max-w-3xl", - "6xl": "max-w-6xl", - full: "max-w-full", - }[maxWidth]; - - return ( -
-
-
- {title && description && ( -
-

- {title} -

-

- {description} -

- {action} -
- )} - {children} -
-
-
- ); -} +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?: React.ReactNode; + action?: React.ReactNode; +} + +export function BasePage({ + children, + className = "", + mainClassName = "", + maxWidth = "3xl", + title, + description, + action, +}: BasePageProps) { + const maxWidthClass = { + "3xl": "max-w-3xl", + "6xl": "max-w-6xl", + full: "max-w-full", + }[maxWidth]; + + return ( +
+
+
+ {title && description && ( +
+

+ {title} +

+

+ {description} +

+ {action} +
+ )} + {children} +
+
+
+ ); +} diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx index 90afd919..b581de95 100644 --- a/apps/web/src/app/blog/[slug]/page.tsx +++ b/apps/web/src/app/blog/[slug]/page.tsx @@ -1,135 +1,135 @@ -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 "@/lib/blog/types"; - -type PageProps = { - params: Promise<{ slug: string }>; - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; -}; - -export async function generateMetadata({ - 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: Author) => author.name), - }, - }; -} - -export async function generateStaticParams() { - 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 ( - - - - - - ); -} - -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 ( -
- {post.title} -
- ); -} - -function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) { - return ( -
- -
- ); -} - -function PostTitle({ title }: { title: string }) { - return ( -

- {title} -

- ); -} - -function PostContent({ html }: { html: string }) { - return ( -
- -
- ); -} +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 "@/lib/blog/types"; + +type PageProps = { + params: Promise<{ slug: string }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}; + +export async function generateMetadata({ + 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: Author) => author.name), + }, + }; +} + +export async function generateStaticParams() { + 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 ( + + + + + + ); +} + +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 ( +
+ {post.title} +
+ ); +} + +function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) { + return ( +
+ +
+ ); +} + +function PostTitle({ title }: { title: string }) { + return ( +

+ {title} +

+ ); +} + +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 df0b195d..9f5a8972 100644 --- a/apps/web/src/app/blog/page.tsx +++ b/apps/web/src/app/blog/page.tsx @@ -1,52 +1,52 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import { BasePage } from "@/app/base-page"; -import { Separator } from "@/components/ui/separator"; -import { getPosts } from "@/lib/blog/query"; -import type { Post } from "@/lib/blog/types"; - -export const metadata: Metadata = { - title: "Blog - OpenCut", - description: - "Read the latest news and updates about OpenCut, the free and open-source video editor.", - openGraph: { - title: "Blog - OpenCut", - description: - "Read the latest news and updates about OpenCut, the free and open-source video editor.", - type: "website", - }, -}; - -export default async function BlogPage() { - const data = await getPosts().catch(() => null); - if (!data || !data.posts) return
No posts yet
; - - return ( - -
- {data.posts.map((post) => ( -
- - -
- ))} -
-
- ); -} - -function BlogPostItem({ post }: { post: Post }) { - return ( - -
-
-

{post.title}

-

{post.description}

-
-
- - ); -} +import type { Metadata } from "next"; +import Link from "next/link"; +import { BasePage } from "@/app/base-page"; +import { Separator } from "@/components/ui/separator"; +import { getPosts } from "@/lib/blog/query"; +import type { Post } from "@/lib/blog/types"; + +export const metadata: Metadata = { + title: "Blog - OpenCut", + description: + "Read the latest news and updates about OpenCut, the free and open-source video editor.", + openGraph: { + title: "Blog - OpenCut", + description: + "Read the latest news and updates about OpenCut, the free and open-source video editor.", + type: "website", + }, +}; + +export default async function BlogPage() { + const data = await getPosts().catch(() => null); + if (!data || !data.posts) return
No posts yet
; + + return ( + +
+ {data.posts.map((post) => ( +
+ + +
+ ))} +
+
+ ); +} + +function BlogPostItem({ post }: { post: Post }) { + return ( + +
+
+

{post.title}

+

{post.description}

+
+
+ + ); +} diff --git a/apps/web/src/app/brand/page.tsx b/apps/web/src/app/brand/page.tsx index 94e3f3e1..a7085154 100644 --- a/apps/web/src/app/brand/page.tsx +++ b/apps/web/src/app/brand/page.tsx @@ -1,268 +1,268 @@ -"use client"; - -import type { CSSProperties } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import { Check, Copy, Download } from "lucide-react"; -import { useState } from "react"; -import { BasePage } from "@/app/base-page"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { cn } from "@/utils/ui"; - -function downloadAsset(src: string) { - const filename = src.split("/").pop() ?? "asset.svg"; - const a = document.createElement("a"); - a.href = src; - a.download = filename; - a.click(); -} - -async function copyAsset(src: string) { - const res = await fetch(src); - const text = await res.text(); - await navigator.clipboard.writeText(text); -} - -const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets); - -type AssetTheme = "dark" | "light" | "icon"; - -interface AssetVariant { - src: string; - theme: AssetTheme; - label: string; - width: number; - height: number; -} - -interface AssetSection { - title: string; - description: string; - cols: "1" | "2"; - assets: AssetVariant[]; -} - -const ASSET_SECTIONS: AssetSection[] = [ - { - title: "Symbol", - description: - "Use the symbol on its own when the OpenCut name is already present nearby or space is limited.", - cols: "2", - assets: [ - { - src: "/logos/opencut/symbol.svg", - theme: "dark", - label: "Symbol", - width: 400, - height: 400, - }, - { - src: "/logos/opencut/symbol-light.svg", - theme: "light", - label: "Symbol", - width: 400, - height: 400, - }, - ], - }, - { - title: "Lockup", - description: - "The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.", - cols: "2", - assets: [ - { - src: "/logos/opencut/logo.svg", - theme: "dark", - label: "Logo", - width: 1809, - height: 400, - }, - { - src: "/logos/opencut/logo-light.svg", - theme: "light", - label: "Logo", - width: 1809, - height: 400, - }, - { - src: "/logos/opencut/text.svg", - theme: "dark", - label: "Text", - width: 1760, - height: 400, - }, - { - src: "/logos/opencut/text-light.svg", - theme: "light", - label: "Text", - width: 1760, - height: 400, - }, - ], - }, -]; - -export default function BrandPage() { - return ( - - Download OpenCut brand assets for use in your projects.{" "} - - document - .getElementById("guidelines") - ?.scrollIntoView({ behavior: "smooth" }) - } - > - Read the brand guidelines. - - - } - action={ - - } - > -
- {ASSET_SECTIONS.map((section) => ( -
-
-

{section.title}

-

- {section.description} -

-
-
- {section.assets.map((variant) => ( - - ))} -
-
- ))} -
- - - -
-
-

Usage

-

- OpenCut is open source — the code is free to use under its license. - That license does not cover the name or logo. You can say you use - OpenCut, that your project integrates with OpenCut, or that it was - built on top of OpenCut. You cannot name your product OpenCut, imply - we made or endorse your product, or use the marks commercially - without asking first. For anything unclear, reach out at{" "} - - brand@opencut.app - - . -

-
- -
-

What's not allowed

-
    - {[ - "Using OpenCut in the name of your product, service, or domain.", - "Implying that OpenCut made, sponsors, or endorses your work.", - "Using the logo or name on merchandise or commercial marketing.", - "Modifying the marks.", - ].map((item) => ( -
  • - - - {item} -
  • - ))} -
-
-
-
- ); -} - -const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = { - light: { - backgroundImage: - "linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)", - backgroundSize: "18px 18px", - backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", - backgroundColor: "#000", - }, - dark: { - backgroundImage: - "linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)", - backgroundSize: "18px 18px", - backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", - backgroundColor: "#f5f5f5", - }, -}; - -function AssetCard({ variant }: { variant: AssetVariant }) { - const [copied, setCopied] = useState(false); - - async function handleCopy() { - await copyAsset(variant.src); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - - return ( - -
- {variant.label} -
- - -
- ); -} +"use client"; + +import type { CSSProperties } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { Check, Copy, Download } from "lucide-react"; +import { useState } from "react"; +import { BasePage } from "@/app/base-page"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/utils/ui"; + +function downloadAsset(src: string) { + const filename = src.split("/").pop() ?? "asset.svg"; + const a = document.createElement("a"); + a.href = src; + a.download = filename; + a.click(); +} + +async function copyAsset(src: string) { + const res = await fetch(src); + const text = await res.text(); + await navigator.clipboard.writeText(text); +} + +const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets); + +type AssetTheme = "dark" | "light" | "icon"; + +interface AssetVariant { + src: string; + theme: AssetTheme; + label: string; + width: number; + height: number; +} + +interface AssetSection { + title: string; + description: string; + cols: "1" | "2"; + assets: AssetVariant[]; +} + +const ASSET_SECTIONS: AssetSection[] = [ + { + title: "Symbol", + description: + "Use the symbol on its own when the OpenCut name is already present nearby or space is limited.", + cols: "2", + assets: [ + { + src: "/logos/opencut/symbol.svg", + theme: "dark", + label: "Symbol", + width: 400, + height: 400, + }, + { + src: "/logos/opencut/symbol-light.svg", + theme: "light", + label: "Symbol", + width: 400, + height: 400, + }, + ], + }, + { + title: "Lockup", + description: + "The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.", + cols: "2", + assets: [ + { + src: "/logos/opencut/logo.svg", + theme: "dark", + label: "Logo", + width: 1809, + height: 400, + }, + { + src: "/logos/opencut/logo-light.svg", + theme: "light", + label: "Logo", + width: 1809, + height: 400, + }, + { + src: "/logos/opencut/text.svg", + theme: "dark", + label: "Text", + width: 1760, + height: 400, + }, + { + src: "/logos/opencut/text-light.svg", + theme: "light", + label: "Text", + width: 1760, + height: 400, + }, + ], + }, +]; + +export default function BrandPage() { + return ( + + Download OpenCut brand assets for use in your projects.{" "} + + document + .getElementById("guidelines") + ?.scrollIntoView({ behavior: "smooth" }) + } + > + Read the brand guidelines. + + + } + action={ + + } + > +
+ {ASSET_SECTIONS.map((section) => ( +
+
+

{section.title}

+

+ {section.description} +

+
+
+ {section.assets.map((variant) => ( + + ))} +
+
+ ))} +
+ + + +
+
+

Usage

+

+ OpenCut is open source — the code is free to use under its license. + That license does not cover the name or logo. You can say you use + OpenCut, that your project integrates with OpenCut, or that it was + built on top of OpenCut. You cannot name your product OpenCut, imply + we made or endorse your product, or use the marks commercially + without asking first. For anything unclear, reach out at{" "} + + brand@opencut.app + + . +

+
+ +
+

What's not allowed

+
    + {[ + "Using OpenCut in the name of your product, service, or domain.", + "Implying that OpenCut made, sponsors, or endorses your work.", + "Using the logo or name on merchandise or commercial marketing.", + "Modifying the marks.", + ].map((item) => ( +
  • + - + {item} +
  • + ))} +
+
+
+
+ ); +} + +const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = { + light: { + backgroundImage: + "linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)", + backgroundSize: "18px 18px", + backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", + backgroundColor: "#000", + }, + dark: { + backgroundImage: + "linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)", + backgroundSize: "18px 18px", + backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", + backgroundColor: "#f5f5f5", + }, +}; + +function AssetCard({ variant }: { variant: AssetVariant }) { + const [copied, setCopied] = useState(false); + + async function handleCopy() { + await copyAsset(variant.src); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( + +
+ {variant.label} +
+ + +
+ ); +} diff --git a/apps/web/src/app/changelog/[version]/page.tsx b/apps/web/src/app/changelog/[version]/page.tsx index af382822..a3647fbd 100644 --- a/apps/web/src/app/changelog/[version]/page.tsx +++ b/apps/web/src/app/changelog/[version]/page.tsx @@ -1,105 +1,105 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -import { notFound } from "next/navigation"; -import { BasePage } from "@/app/base-page"; -import { allChangelogs } from "content-collections"; -import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; -import { getSortedReleases } from "../utils"; -import { - ReleaseArticle, - ReleaseMeta, - ReleaseTitle, - ReleaseDescription, - ReleaseChanges, -} from "../components/release"; -import { CopyMarkdownButton } from "../components/copy-markdown-button"; - -type Props = { params: Promise<{ version: string }> }; - -export async function generateStaticParams() { - return allChangelogs.map((r) => ({ version: r.version })); -} - -export async function generateMetadata({ params }: Props): Promise { - const { version } = await params; - const release = allChangelogs.find((r) => r.version === version); - if (!release) return {}; - return { - title: `${release.title} (${release.version}) - OpenCut Changelog`, - description: release.description, - }; -} - -export default async function ReleaseDetailPage({ params }: Props) { - const { version } = await params; - const releases = getSortedReleases(); - const index = releases.findIndex((r) => r.version === version); - - if (index === -1) notFound(); - - const release = releases[index]; - const newer = index > 0 ? releases[index - 1] : null; - const older = index < releases.length - 1 ? releases[index + 1] : null; - - return ( - -
- - - All releases - - - -
-
- - -
- {release.title} - {release.description && ( - {release.description} - )} -
- -
- - -
-
- ); -} +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { BasePage } from "@/app/base-page"; +import { allChangelogs } from "content-collections"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { getSortedReleases } from "../utils"; +import { + ReleaseArticle, + ReleaseMeta, + ReleaseTitle, + ReleaseDescription, + ReleaseChanges, +} from "../components/release"; +import { CopyMarkdownButton } from "../components/copy-markdown-button"; + +type Props = { params: Promise<{ version: string }> }; + +export async function generateStaticParams() { + return allChangelogs.map((r) => ({ version: r.version })); +} + +export async function generateMetadata({ params }: Props): Promise { + const { version } = await params; + const release = allChangelogs.find((r) => r.version === version); + if (!release) return {}; + return { + title: `${release.title} (${release.version}) - OpenCut Changelog`, + description: release.description, + }; +} + +export default async function ReleaseDetailPage({ params }: Props) { + const { version } = await params; + const releases = getSortedReleases(); + const index = releases.findIndex((r) => r.version === version); + + if (index === -1) notFound(); + + const release = releases[index]; + const newer = index > 0 ? releases[index - 1] : null; + const older = index < releases.length - 1 ? releases[index + 1] : null; + + return ( + +
+ + + All releases + + + +
+
+ + +
+ {release.title} + {release.description && ( + {release.description} + )} +
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/app/changelog/components/copy-markdown-button.tsx b/apps/web/src/app/changelog/components/copy-markdown-button.tsx index 2366550a..edb920f5 100644 --- a/apps/web/src/app/changelog/components/copy-markdown-button.tsx +++ b/apps/web/src/app/changelog/components/copy-markdown-button.tsx @@ -1,68 +1,68 @@ -"use client"; - -import { useState } from "react"; -import { CheckIcon, ClipboardIcon } from "lucide-react"; -import { getSectionTitle, groupAndOrderChanges } from "../utils"; -import type { Change } from "../utils"; -import { cn } from "@/utils/ui"; -import { Button } from "@/components/ui/button"; - -function buildMarkdown({ - description, - changes, -}: { - description?: string; - changes: Change[]; -}): string { - const lines: string[] = []; - - if (description) { - lines.push(description, ""); - } - - const { grouped, orderedTypes } = groupAndOrderChanges({ changes }); - - for (const type of orderedTypes) { - lines.push(`## ${getSectionTitle(type)}`); - for (const change of grouped[type]) { - lines.push(`- ${change.text}`); - } - lines.push(""); - } - - return lines.join("\n").trimEnd(); -} - -export function CopyMarkdownButton({ - description, - changes, -}: { - description?: string; - changes: Change[]; -}) { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - const markdown = buildMarkdown({ description, changes }); - await navigator.clipboard.writeText(markdown); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( - - ); -} +"use client"; + +import { useState } from "react"; +import { CheckIcon, ClipboardIcon } from "lucide-react"; +import { getSectionTitle, groupAndOrderChanges } from "../utils"; +import type { Change } from "../utils"; +import { cn } from "@/utils/ui"; +import { Button } from "@/components/ui/button"; + +function buildMarkdown({ + description, + changes, +}: { + description?: string; + changes: Change[]; +}): string { + const lines: string[] = []; + + if (description) { + lines.push(description, ""); + } + + const { grouped, orderedTypes } = groupAndOrderChanges({ changes }); + + for (const type of orderedTypes) { + lines.push(`## ${getSectionTitle(type)}`); + for (const change of grouped[type]) { + lines.push(`- ${change.text}`); + } + lines.push(""); + } + + return lines.join("\n").trimEnd(); +} + +export function CopyMarkdownButton({ + description, + changes, +}: { + description?: string; + changes: Change[]; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + const markdown = buildMarkdown({ description, changes }); + await navigator.clipboard.writeText(markdown); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + ); +} diff --git a/apps/web/src/app/changelog/components/release.tsx b/apps/web/src/app/changelog/components/release.tsx index eb04c1f0..2226c321 100644 --- a/apps/web/src/app/changelog/components/release.tsx +++ b/apps/web/src/app/changelog/components/release.tsx @@ -1,106 +1,106 @@ -import type { ReactNode } from "react"; -import Link from "next/link"; -import { cn } from "@/utils/ui"; -import { getSectionTitle, groupAndOrderChanges } from "../utils"; -import type { Release } from "../utils"; - -export function ReleaseArticle({ - variant, - isLatest, - children, -}: { - variant: "list" | "detail"; - isLatest?: boolean; - children: ReactNode; -}) { - if (variant === "list") { - return ( -
-
-
-
-
{children}
-
- ); - } - - return
{children}
; -} - -export function ReleaseMeta({ release }: { release: Release }) { - return ( - - {release.version} — {release.date} - - ); -} - -const titleSizes: Record<"h1" | "h2", string> = { - h1: "text-4xl", - h2: "text-2xl", -}; - -export function ReleaseTitle({ - as: As, - href, - children, -}: { - as: "h1" | "h2"; - href?: string; - children: ReactNode; -}) { - return ( - - {href ? ( - - {children} - - ) : ( - children - )} - - ); -} - -export function ReleaseDescription({ children }: { children: ReactNode }) { - return ( -

- {children} -

- ); -} - -export function ReleaseChanges({ release }: { release: Release }) { - const { grouped, orderedTypes } = groupAndOrderChanges({ - changes: release.changes, - }); - - return ( -
- {orderedTypes.map((type) => ( -
-

- {getSectionTitle(type)}: -

-
    - {grouped[type].map((change) => ( -
  • - {change.text} -
  • - ))} -
-
- ))} -
- ); -} +import type { ReactNode } from "react"; +import Link from "next/link"; +import { cn } from "@/utils/ui"; +import { getSectionTitle, groupAndOrderChanges } from "../utils"; +import type { Release } from "../utils"; + +export function ReleaseArticle({ + variant, + isLatest, + children, +}: { + variant: "list" | "detail"; + isLatest?: boolean; + children: ReactNode; +}) { + if (variant === "list") { + return ( +
+
+
+
+
{children}
+
+ ); + } + + return
{children}
; +} + +export function ReleaseMeta({ release }: { release: Release }) { + return ( + + {release.version} — {release.date} + + ); +} + +const titleSizes: Record<"h1" | "h2", string> = { + h1: "text-4xl", + h2: "text-2xl", +}; + +export function ReleaseTitle({ + as: As, + href, + children, +}: { + as: "h1" | "h2"; + href?: string; + children: ReactNode; +}) { + return ( + + {href ? ( + + {children} + + ) : ( + children + )} + + ); +} + +export function ReleaseDescription({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +export function ReleaseChanges({ release }: { release: Release }) { + const { grouped, orderedTypes } = groupAndOrderChanges({ + changes: release.changes, + }); + + return ( +
+ {orderedTypes.map((type) => ( +
+

+ {getSectionTitle(type)}: +

+
    + {grouped[type].map((change) => ( +
  • + {change.text} +
  • + ))} +
+
+ ))} +
+ ); +} diff --git a/apps/web/src/app/changelog/utils.ts b/apps/web/src/app/changelog/utils.ts index 163207c0..d3405631 100644 --- a/apps/web/src/app/changelog/utils.ts +++ b/apps/web/src/app/changelog/utils.ts @@ -1,43 +1,43 @@ -import { allChangelogs } from "content-collections"; - -export type Change = { type: string; text: string }; -export type Release = (typeof allChangelogs)[number]; - -const knownSectionOrder = ["new", "improved", "fixed", "breaking"]; - -const knownSectionTitles: Record = { - new: "Features", - improved: "Improvements", - fixed: "Fixes", - breaking: "Breaking Changes", -}; - -export function getSectionTitle(type: string): string { - return ( - knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1) - ); -} - -export function groupAndOrderChanges({ changes }: { changes: Change[] }) { - const grouped = changes.reduce>((acc, change) => { - if (!acc[change.type]) acc[change.type] = []; - acc[change.type].push(change); - return acc; - }, {}); - - const customTypes = Object.keys(grouped).filter( - (type) => !knownSectionOrder.includes(type), - ); - const orderedTypes = [ - ...knownSectionOrder.filter((type) => grouped[type]?.length > 0), - ...customTypes, - ]; - - return { grouped, orderedTypes }; -} - -export function getSortedReleases() { - return [...allChangelogs].sort((a, b) => - b.version.localeCompare(a.version, undefined, { numeric: true }), - ); -} +import { allChangelogs } from "content-collections"; + +export type Change = { type: string; text: string }; +export type Release = (typeof allChangelogs)[number]; + +const knownSectionOrder = ["new", "improved", "fixed", "breaking"]; + +const knownSectionTitles: Record = { + new: "Features", + improved: "Improvements", + fixed: "Fixes", + breaking: "Breaking Changes", +}; + +export function getSectionTitle(type: string): string { + return ( + knownSectionTitles[type] ?? type.charAt(0).toUpperCase() + type.slice(1) + ); +} + +export function groupAndOrderChanges({ changes }: { changes: Change[] }) { + const grouped = changes.reduce>((acc, change) => { + if (!acc[change.type]) acc[change.type] = []; + acc[change.type].push(change); + return acc; + }, {}); + + const customTypes = Object.keys(grouped).filter( + (type) => !knownSectionOrder.includes(type), + ); + const orderedTypes = [ + ...knownSectionOrder.filter((type) => grouped[type]?.length > 0), + ...customTypes, + ]; + + return { grouped, orderedTypes }; +} + +export function getSortedReleases() { + return [...allChangelogs].sort((a, b) => + b.version.localeCompare(a.version, undefined, { numeric: true }), + ); +} diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx index 21703852..64792632 100644 --- a/apps/web/src/app/contributors/page.tsx +++ b/apps/web/src/app/contributors/page.tsx @@ -1,242 +1,242 @@ -import type { Metadata } from "next"; -import Link from "next/link"; -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", - }, -}; - -interface Contributor { - 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 - }, - ); - - 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); - const totalContributions = contributors.reduce( - (sum, c) => sum + c.contributions, - 0, - ); - - return ( - -
- - -
- -
- {topContributors.length > 0 && ( - - )} - {otherContributors.length > 0 && ( - - )} - - -
-
- ); -} - -function StatItem({ value, label }: { value: number; label: string }) { - return ( -
-
- {value} - {label} -
- ); -} - -function TopContributorsSection({ - contributors, -}: { - contributors: Contributor[]; -}) { - return ( -
-
-

Top contributors

-

- Leading the way in contributions -

-
- -
- {contributors.map((contributor) => ( - - ))} -
-
- ); -} - -function TopContributorCard({ contributor }: { contributor: Contributor }) { - return ( - - - - - - - {contributor.login.charAt(0).toUpperCase()} - - -
-

{contributor.login}

-
- {contributor.contributions} - contributions -
-
-
-
- - ); -} - -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} -

-
-
- - ))} -
-
- ); -} - -function ExternalToolsSection() { - return ( -
-
-

External tools

-

Tools we use to build OpenCut

-
- -
- {EXTERNAL_TOOLS.map((tool, index) => ( - - - - -
-

{tool.name}

-

- {tool.description} -

-
-
-
- - ))} -
-
- ); -} +import type { Metadata } from "next"; +import Link from "next/link"; +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", + }, +}; + +interface Contributor { + 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 + }, + ); + + 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); + const totalContributions = contributors.reduce( + (sum, c) => sum + c.contributions, + 0, + ); + + return ( + +
+ + +
+ +
+ {topContributors.length > 0 && ( + + )} + {otherContributors.length > 0 && ( + + )} + + +
+
+ ); +} + +function StatItem({ value, label }: { value: number; label: string }) { + return ( +
+
+ {value} + {label} +
+ ); +} + +function TopContributorsSection({ + contributors, +}: { + contributors: Contributor[]; +}) { + return ( +
+
+

Top contributors

+

+ Leading the way in contributions +

+
+ +
+ {contributors.map((contributor) => ( + + ))} +
+
+ ); +} + +function TopContributorCard({ contributor }: { contributor: Contributor }) { + return ( + + + + + + + {contributor.login.charAt(0).toUpperCase()} + + +
+

{contributor.login}

+
+ {contributor.contributions} + contributions +
+
+
+
+ + ); +} + +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} +

+
+
+ + ))} +
+
+ ); +} + +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]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 3ec6ddb8..38a25d4c 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -1,113 +1,113 @@ -"use client"; - -import { useParams } from "next/navigation"; -import { - ResizablePanelGroup, - ResizablePanel, - ResizableHandle, -} from "@/components/ui/resizable"; -import { AssetsPanel } from "@/components/editor/panels/assets"; -import { PropertiesPanel } from "@/components/editor/panels/properties"; -import { Timeline } from "@/components/editor/panels/timeline"; -import { PreviewPanel } from "@/components/editor/panels/preview"; -import { EditorHeader } from "@/components/editor/editor-header"; -import { EditorProvider } from "@/components/providers/editor-provider"; -import { Onboarding } from "@/components/editor/onboarding"; -import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog"; -import { usePanelStore } from "@/stores/panel-store"; -import { usePasteMedia } from "@/hooks/use-paste-media"; -import { MobileGate } from "@/components/editor/mobile-gate"; - -export default function Editor() { - const params = useParams(); - const projectId = params.project_id as string; - - return ( - - -
- -
- -
- - -
-
-
- ); -} - -function EditorLayout() { - usePasteMedia(); - 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); - }} - > - - - - - - - - - - - - - - - - - - - - - - - - - ); -} +"use client"; + +import { useParams } from "next/navigation"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; +import { AssetsPanel } from "@/components/editor/panels/assets"; +import { PropertiesPanel } from "@/components/editor/panels/properties"; +import { Timeline } from "@/components/editor/panels/timeline"; +import { PreviewPanel } from "@/components/editor/panels/preview"; +import { EditorHeader } from "@/components/editor/editor-header"; +import { EditorProvider } from "@/components/providers/editor-provider"; +import { Onboarding } from "@/components/editor/onboarding"; +import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog"; +import { usePanelStore } from "@/stores/panel-store"; +import { usePasteMedia } from "@/hooks/use-paste-media"; +import { MobileGate } from "@/components/editor/mobile-gate"; + +export default function Editor() { + const params = useParams(); + const projectId = params.project_id as string; + + return ( + + +
+ +
+ +
+ + +
+
+
+ ); +} + +function EditorLayout() { + usePasteMedia(); + 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 21219427..a2df844a 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,284 +1,284 @@ -@import "tailwindcss"; - -/* Custom variant for dark mode */ -@custom-variant dark (&:where(.dark, .dark *)); - -/* Plugins */ -@plugin "@tailwindcss/typography"; -@plugin "tailwindcss-animate"; - -:root { - --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-hover: hsl(0, 0%, 96%); - --popover-foreground: hsl(0 0% 2%); - --primary: hsl(200, 90%, 52%); - --primary-foreground: hsl(0, 0%, 100%); - --secondary: hsl(204, 100%, 97%); - --secondary-border: hsl(204, 100%, 94%); - --secondary-foreground: hsl(200, 98%, 39%); - --muted: hsl(0 0% 85.1%); - --muted-foreground: hsl(0 0% 50%); - --accent: hsl(0, 0%, 96%); - --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% 91%); - --input: hsl(0, 0%, 100%); - --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%); - --sidebar: hsl(0 0% 98%); -} - -.panel { - --background: hsl(210, 20%, 98%); - --foreground: hsl(0 0% 13%); - --card: hsl(0, 0%, 98%); - --card-foreground: hsl(0 0% 13%); - --primary-foreground: hsl(0, 0%, 98%); - --secondary: hsl(204, 100%, 95%); - --secondary-border: hsl(204, 100%, 92%); - --secondary-foreground: hsl(200, 98%, 37%); - --muted: hsl(0 0% 83.1%); - --muted-foreground: hsl(0 0% 48%); - --accent: hsl(0, 0%, 93%); - --accent-foreground: hsl(0 0% 5%); - --destructive-foreground: hsl(0, 0%, 98%); - --constructive-foreground: hsl(0, 0%, 98%); - --border: hsl(0 0% 87%); - --input: hsl(0 0% 93%); - --ring: hsl(0, 0%, 53%); -} - -.dark { - --background: hsl(0, 0%, 5%); - --foreground: hsl(0 0% 87%); - --card: hsl(0, 0%, 5%); - --card-foreground: hsl(0 0% 87%); - --popover: hsl(0, 0%, 5%); - --popover-hover: hsl(0, 0%, 13%); - --popover-foreground: hsl(0 0% 95%); - --secondary: hsl(204, 100%, 12%); - --secondary-border: hsl(204, 100%, 15%); - --secondary-foreground: hsl(200, 98%, 61%); - --muted: hsl(0 0% 20%); - --accent: hsl(0, 0%, 14%); - --accent-foreground: hsl(0 0% 95%); - --border: hsl(0 0% 16%); - --input: hsl(0 0% 5%); - --ring: hsl(0, 0%, 50%); - --sidebar-background: hsl(0 0% 8%); - --sidebar-foreground: hsl(0 0% 95%); - --sidebar-primary: hsl(0 0% 95%); - --sidebar-primary-foreground: hsl(0 0% 15%); - --sidebar-accent: hsl(0 0% 20%); - --sidebar-accent-foreground: hsl(0 0% 95%); - --sidebar-border: hsl(0 0% 20%); - --sidebar-ring: hsl(0 0% 83.1%); - --sidebar: hsl(0 0% 6%); -} - -.dark .panel { - --background: hsl(0 0% 10%); - --foreground: hsl(0 0% 85%); - --card: hsl(0, 0%, 10%); - --card-foreground: hsl(0 0% 85%); - --secondary: hsl(204, 67%, 9%); - --secondary-border: hsl(204, 100%, 14%); - --secondary-foreground: hsl(200, 98%, 63%); - --muted: hsl(0 0% 22%); - --accent: hsl(0, 0%, 15%); - --accent-foreground: hsl(0 0% 93%); - --border: hsl(0 0% 18%); - --input: hsl(0 0% 22%); - --ring: hsl(0, 0%, 52%); -} - -@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. - - 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; - } - ::selection { - @apply bg-primary/35 selection:text-primary-foreground; - } -} - -@theme inline { - /* Responsive breakpoints */ - --breakpoint-xs: 30rem; - - /* Typography */ - --font-sans: var(--font-inter), sans-serif; - - /* Font sizes */ - --text-xs: 0.72rem; - --text-sm: 0.79rem; - --text-base: 0.92rem; - --text-base--line-height: calc(1.5 / 0.95); - --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-hover: var(--popover-hover); - --color-popover-foreground: var(--popover-foreground); - - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-border: var(--secondary-border); - --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); - - /* 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; - } -} - -@utility scrollbar-x-hidden { - -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; - } -} - -@utility scrollbar-thin { - &::-webkit-scrollbar { - width: 7px; - height: 7px; - } - &::-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; - } -} +@import "tailwindcss"; + +/* Custom variant for dark mode */ +@custom-variant dark (&:where(.dark, .dark *)); + +/* Plugins */ +@plugin "@tailwindcss/typography"; +@plugin "tailwindcss-animate"; + +:root { + --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-hover: hsl(0, 0%, 96%); + --popover-foreground: hsl(0 0% 2%); + --primary: hsl(200, 90%, 52%); + --primary-foreground: hsl(0, 0%, 100%); + --secondary: hsl(204, 100%, 97%); + --secondary-border: hsl(204, 100%, 94%); + --secondary-foreground: hsl(200, 98%, 39%); + --muted: hsl(0 0% 85.1%); + --muted-foreground: hsl(0 0% 50%); + --accent: hsl(0, 0%, 96%); + --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% 91%); + --input: hsl(0, 0%, 100%); + --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%); + --sidebar: hsl(0 0% 98%); +} + +.panel { + --background: hsl(210, 20%, 98%); + --foreground: hsl(0 0% 13%); + --card: hsl(0, 0%, 98%); + --card-foreground: hsl(0 0% 13%); + --primary-foreground: hsl(0, 0%, 98%); + --secondary: hsl(204, 100%, 95%); + --secondary-border: hsl(204, 100%, 92%); + --secondary-foreground: hsl(200, 98%, 37%); + --muted: hsl(0 0% 83.1%); + --muted-foreground: hsl(0 0% 48%); + --accent: hsl(0, 0%, 93%); + --accent-foreground: hsl(0 0% 5%); + --destructive-foreground: hsl(0, 0%, 98%); + --constructive-foreground: hsl(0, 0%, 98%); + --border: hsl(0 0% 87%); + --input: hsl(0 0% 93%); + --ring: hsl(0, 0%, 53%); +} + +.dark { + --background: hsl(0, 0%, 5%); + --foreground: hsl(0 0% 87%); + --card: hsl(0, 0%, 5%); + --card-foreground: hsl(0 0% 87%); + --popover: hsl(0, 0%, 5%); + --popover-hover: hsl(0, 0%, 13%); + --popover-foreground: hsl(0 0% 95%); + --secondary: hsl(204, 100%, 12%); + --secondary-border: hsl(204, 100%, 15%); + --secondary-foreground: hsl(200, 98%, 61%); + --muted: hsl(0 0% 20%); + --accent: hsl(0, 0%, 14%); + --accent-foreground: hsl(0 0% 95%); + --border: hsl(0 0% 16%); + --input: hsl(0 0% 5%); + --ring: hsl(0, 0%, 50%); + --sidebar-background: hsl(0 0% 8%); + --sidebar-foreground: hsl(0 0% 95%); + --sidebar-primary: hsl(0 0% 95%); + --sidebar-primary-foreground: hsl(0 0% 15%); + --sidebar-accent: hsl(0 0% 20%); + --sidebar-accent-foreground: hsl(0 0% 95%); + --sidebar-border: hsl(0 0% 20%); + --sidebar-ring: hsl(0 0% 83.1%); + --sidebar: hsl(0 0% 6%); +} + +.dark .panel { + --background: hsl(0 0% 10%); + --foreground: hsl(0 0% 85%); + --card: hsl(0, 0%, 10%); + --card-foreground: hsl(0 0% 85%); + --secondary: hsl(204, 67%, 9%); + --secondary-border: hsl(204, 100%, 14%); + --secondary-foreground: hsl(200, 98%, 63%); + --muted: hsl(0 0% 22%); + --accent: hsl(0, 0%, 15%); + --accent-foreground: hsl(0 0% 93%); + --border: hsl(0 0% 18%); + --input: hsl(0 0% 22%); + --ring: hsl(0, 0%, 52%); +} + +@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. + + 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; + } + ::selection { + @apply bg-primary/35 selection:text-primary-foreground; + } +} + +@theme inline { + /* Responsive breakpoints */ + --breakpoint-xs: 30rem; + + /* Typography */ + --font-sans: var(--font-inter), sans-serif; + + /* Font sizes */ + --text-xs: 0.72rem; + --text-sm: 0.79rem; + --text-base: 0.92rem; + --text-base--line-height: calc(1.5 / 0.95); + --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-hover: var(--popover-hover); + --color-popover-foreground: var(--popover-foreground); + + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-border: var(--secondary-border); + --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); + + /* 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; + } +} + +@utility scrollbar-x-hidden { + -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; + } +} + +@utility scrollbar-thin { + &::-webkit-scrollbar { + width: 7px; + height: 7px; + } + &::-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/metadata.ts b/apps/web/src/app/metadata.ts index 7c192d2b..72f0c2aa 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-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", - }, -}; +import type { Metadata } from "next"; +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", + }, +}; diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index d97c9a54..43dbc104 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,21 +1,21 @@ -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-constants"; - -export const metadata: Metadata = { - alternates: { - canonical: SITE_URL, - }, -}; - -export default async function Home() { - return ( -
-
- -
-
- ); -} +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-constants"; + +export const metadata: Metadata = { + alternates: { + canonical: SITE_URL, + }, +}; + +export default async function Home() { + return ( +
+
+ +
+
+ ); +} diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index f32ce2b9..8c5d9d04 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -1,1015 +1,1015 @@ -"use client"; - -import Image from "next/image"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import type { KeyboardEvent, MouseEvent } from "react"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import type { EditorCore } from "@/core"; -import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog"; -import { StoragePersistenceDialog } from "@/components/editor/dialogs/storage-persistence-dialog"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Input } from "@/components/ui/input"; -import { Skeleton } from "@/components/ui/skeleton"; -import { useEditor } from "@/hooks/use-editor"; -import { useProjectsStore } from "./store"; -import type { - TProjectMetadata, - TProjectSortKey, - TProjectSortOption, -} from "@/lib/project/types"; -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 { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; -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 }); -}; - -const VIEW_MODE_OPTIONS = [ - { mode: "grid" as const, icon: GridViewIcon, label: "Grid view" }, - { mode: "list" as const, icon: LeftToRightListDashIcon, label: "List view" }, -]; - -export default function ProjectsPage() { - const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore(); - const editor = useEditor(); - const sortOption: TProjectSortOption = `${sortKey}-${sortOrder}`; - - const isLoading = useEditor((e) => e.project.getIsLoading()); - const isInitialized = useEditor((e) => e.project.getIsInitialized()); - const projectsToDisplay = useEditor((e) => - e.project.getFilteredAndSortedProjects({ searchQuery, sortOption }), - ); - - useEffect(() => { - if (!editor.project.getIsInitialized()) { - editor.project.loadAllProjects(); - } - }, [editor.project]); - - return ( -
- - - - p.id)} /> -
- {isLoading || !isInitialized ? ( - - ) : projectsToDisplay.length === 0 ? ( - - ) : ( -
- {projectsToDisplay.map((project) => ( - p.id)} - /> - ))} -
- )} -
-
- ); -} - -function ProjectsHeader() { - const { viewMode, isHydrated, setViewMode } = useProjectsStore(); - - return ( -
-
-
- - - - - - Home - - - - - - - All projects - - - - - -
- {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => ( - - ))} -
-
- -
- - -
-
- -
- ); -} - -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 ( -
-
- - -
- - - - - - -
- -
- {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => ( - - ))} -
-
- {selectedProjectCount > 0 ? : null} -
- ); -} - -function SearchBar({ - className, - collapsed, -}: { - className?: string; - collapsed?: boolean; -}) { - const { searchQuery, setSearchQuery } = useProjectsStore(); - - return ( - <> - {collapsed ? ( -
- -
- ) : ( -
-
- )} - - ); -} - -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: EditorCore; - ids: string[]; -}) { - await editor.project.deleteProjects({ ids }); -} - -async function duplicateProjects({ - editor, - ids, -}: { - editor: EditorCore; - ids: string[]; -}) { - await editor.project.duplicateProjects({ ids }); -} - -async function renameProject({ - editor, - id, - name, -}: { - editor: EditorCore; - id: string; - name: string; -}) { - 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 ( - - ); -} - -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 [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false); - const editor = useEditor(); - const durationLabel = formatProjectDuration({ duration: project.duration }); - const isMultiSelect = selectedProjectCount > 1; - const isGridView = viewMode === "grid"; - - const handleRename = () => setIsRenameDialogOpen(true); - const handleDuplicate = async () => { - await duplicateProjects({ editor, ids: [project.id] }); - }; - const handleDeleteClick = () => setIsDeleteDialogOpen(true); - const handleInfoClick = () => setIsInfoDialogOpen(true); - const handleDeleteConfirm = async () => { - await deleteProjects({ editor, ids: [project.id] }); - setIsDeleteDialogOpen(false); - }; - - 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 ? ( - Project thumbnail - ) : ( -
- -
- )} -
- - {durationLabel && ( -
- {durationLabel} -
- )} -
- - -

- {project.name} -

-
- - Created {formatDate({ date: project.createdAt })} -
-
-
- ); - - const listRowContent = ( -
-
- {project.thumbnail ? ( - 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 && ( - - )} -
- ); - - return ( - <> - - -
- {isGridView ? ( - <> - - {gridContent} - - - 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 && ( - - )} - - ) : ( - listContent - )} -
-
- -
- - { - await renameProject({ editor, id: project.id, name: newName }); - setIsRenameDialogOpen(false); - }} - /> - - - - - - ); -} - -function ProjectContextMenuContent({ - onRenameClick, - onDuplicateClick, - onDeleteClick, - onInfoClick, -}: { - onRenameClick: () => void; - onDuplicateClick: () => void; - onDeleteClick: () => void; - onInfoClick: () => void; -}) { - return ( - - } - onClick={onRenameClick} - > - Rename - - } - onClick={onDuplicateClick} - > - Duplicate - - } - onClick={onInfoClick} - > - Info - - - } - onClick={onDeleteClick} - > - Delete - - - ); -} - -function ProjectMenu({ - isOpen, - onOpenChange, - variant = "grid", - onRenameClick, - onDuplicateClick, - onDeleteClick, - onInfoClick, -}: { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - variant?: "grid" | "list"; - onRenameClick: () => void; - onDuplicateClick: () => void; - onDeleteClick: () => void; - onInfoClick: () => void; -}) { - 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 = () => { - onRenameClick(); - onOpenChange(false); - }; - - const handleDuplicate = () => { - onDuplicateClick(); - onOpenChange(false); - }; - - const handleDeleteClick = () => { - onDeleteClick(); - onOpenChange(false); - }; - - const handleInfoClick = () => { - onInfoClick(); - onOpenChange(false); - }; - - const isGrid = variant === "grid"; - - return ( - - - - - - - - Rename - - - - Duplicate - - - - Info - - - - Delete - - - - ); -} - -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. -

-
-
- -
- ); - } - - return ( -
-
-
- -
-

No projects yet

-

- Start creating your first project. Import media, edit, and export your - videos. All privately. -

-
- -
- ); -} +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import type { KeyboardEvent, MouseEvent } from "react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import type { EditorCore } from "@/core"; +import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog"; +import { StoragePersistenceDialog } from "@/components/editor/dialogs/storage-persistence-dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useEditor } from "@/hooks/use-editor"; +import { useProjectsStore } from "./store"; +import type { + TProjectMetadata, + TProjectSortKey, + TProjectSortOption, +} from "@/lib/project/types"; +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 { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +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 }); +}; + +const VIEW_MODE_OPTIONS = [ + { mode: "grid" as const, icon: GridViewIcon, label: "Grid view" }, + { mode: "list" as const, icon: LeftToRightListDashIcon, label: "List view" }, +]; + +export default function ProjectsPage() { + const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore(); + const editor = useEditor(); + const sortOption: TProjectSortOption = `${sortKey}-${sortOrder}`; + + const isLoading = useEditor((e) => e.project.getIsLoading()); + const isInitialized = useEditor((e) => e.project.getIsInitialized()); + const projectsToDisplay = useEditor((e) => + e.project.getFilteredAndSortedProjects({ searchQuery, sortOption }), + ); + + useEffect(() => { + if (!editor.project.getIsInitialized()) { + editor.project.loadAllProjects(); + } + }, [editor.project]); + + return ( +
+ + + + p.id)} /> +
+ {isLoading || !isInitialized ? ( + + ) : projectsToDisplay.length === 0 ? ( + + ) : ( +
+ {projectsToDisplay.map((project) => ( + p.id)} + /> + ))} +
+ )} +
+
+ ); +} + +function ProjectsHeader() { + const { viewMode, isHydrated, setViewMode } = useProjectsStore(); + + return ( +
+
+
+ + + + + + Home + + + + + + + All projects + + + + + +
+ {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => ( + + ))} +
+
+ +
+ + +
+
+ +
+ ); +} + +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 ( +
+
+ + +
+ + + + + + +
+ +
+ {VIEW_MODE_OPTIONS.map(({ mode, icon, label }) => ( + + ))} +
+
+ {selectedProjectCount > 0 ? : null} +
+ ); +} + +function SearchBar({ + className, + collapsed, +}: { + className?: string; + collapsed?: boolean; +}) { + const { searchQuery, setSearchQuery } = useProjectsStore(); + + return ( + <> + {collapsed ? ( +
+ +
+ ) : ( +
+
+ )} + + ); +} + +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: EditorCore; + ids: string[]; +}) { + await editor.project.deleteProjects({ ids }); +} + +async function duplicateProjects({ + editor, + ids, +}: { + editor: EditorCore; + ids: string[]; +}) { + await editor.project.duplicateProjects({ ids }); +} + +async function renameProject({ + editor, + id, + name, +}: { + editor: EditorCore; + id: string; + name: string; +}) { + 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 ( + + ); +} + +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 [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isInfoDialogOpen, setIsInfoDialogOpen] = useState(false); + const editor = useEditor(); + const durationLabel = formatProjectDuration({ duration: project.duration }); + const isMultiSelect = selectedProjectCount > 1; + const isGridView = viewMode === "grid"; + + const handleRename = () => setIsRenameDialogOpen(true); + const handleDuplicate = async () => { + await duplicateProjects({ editor, ids: [project.id] }); + }; + const handleDeleteClick = () => setIsDeleteDialogOpen(true); + const handleInfoClick = () => setIsInfoDialogOpen(true); + const handleDeleteConfirm = async () => { + await deleteProjects({ editor, ids: [project.id] }); + setIsDeleteDialogOpen(false); + }; + + 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 ? ( + Project thumbnail + ) : ( +
+ +
+ )} +
+ + {durationLabel && ( +
+ {durationLabel} +
+ )} +
+ + +

+ {project.name} +

+
+ + Created {formatDate({ date: project.createdAt })} +
+
+
+ ); + + const listRowContent = ( +
+
+ {project.thumbnail ? ( + 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 && ( + + )} +
+ ); + + return ( + <> + + +
+ {isGridView ? ( + <> + + {gridContent} + + + 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 && ( + + )} + + ) : ( + listContent + )} +
+
+ +
+ + { + await renameProject({ editor, id: project.id, name: newName }); + setIsRenameDialogOpen(false); + }} + /> + + + + + + ); +} + +function ProjectContextMenuContent({ + onRenameClick, + onDuplicateClick, + onDeleteClick, + onInfoClick, +}: { + onRenameClick: () => void; + onDuplicateClick: () => void; + onDeleteClick: () => void; + onInfoClick: () => void; +}) { + return ( + + } + onClick={onRenameClick} + > + Rename + + } + onClick={onDuplicateClick} + > + Duplicate + + } + onClick={onInfoClick} + > + Info + + + } + onClick={onDeleteClick} + > + Delete + + + ); +} + +function ProjectMenu({ + isOpen, + onOpenChange, + variant = "grid", + onRenameClick, + onDuplicateClick, + onDeleteClick, + onInfoClick, +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + variant?: "grid" | "list"; + onRenameClick: () => void; + onDuplicateClick: () => void; + onDeleteClick: () => void; + onInfoClick: () => void; +}) { + 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 = () => { + onRenameClick(); + onOpenChange(false); + }; + + const handleDuplicate = () => { + onDuplicateClick(); + onOpenChange(false); + }; + + const handleDeleteClick = () => { + onDeleteClick(); + onOpenChange(false); + }; + + const handleInfoClick = () => { + onInfoClick(); + onOpenChange(false); + }; + + const isGrid = variant === "grid"; + + return ( + + + + + + + + Rename + + + + Duplicate + + + + Info + + + + Delete + + + + ); +} + +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. +

+
+
+ +
+ ); + } + + return ( +
+
+
+ +
+

No projects yet

+

+ Start creating your first project. Import media, edit, and export your + videos. All privately. +

+
+ +
+ ); +} diff --git a/apps/web/src/app/projects/store.ts b/apps/web/src/app/projects/store.ts index 2c79aa0a..71f89b4a 100644 --- a/apps/web/src/app/projects/store.ts +++ b/apps/web/src/app/projects/store.ts @@ -1,135 +1,135 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; -import type { TProjectSortKey } from "@/lib/project/types"; - -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: "updatedAt", - 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, - sortKey: state.sortKey, - sortOrder: state.sortOrder, - }), - onRehydrateStorage: () => (state) => { - state?.setIsHydrated({ isHydrated: true }); - }, - }, - ), -); +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import type { TProjectSortKey } from "@/lib/project/types"; + +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: "updatedAt", + 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, + sortKey: state.sortKey, + sortOrder: state.sortOrder, + }), + 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 59c36d78..a0c3d9be 100644 --- a/apps/web/src/app/roadmap/page.tsx +++ b/apps/web/src/app/roadmap/page.tsx @@ -1,146 +1,146 @@ -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 { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper"; -import { cn } from "@/utils/ui"; - -const LAST_UPDATED = "February 25, 2026"; - -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: "Native app (mobile/desktop)", - description: - "Native OpenCut apps for Mac, Windows, Linux, and iOS/Android.", - 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"], - }, -}; - -export default function RoadmapPage() { - return ( - -
-
- {roadmapItems.map((item, index) => ( - - ))} -
- -
-
- ); -} - -function RoadmapItem({ item, index }: { item: RoadmapItem; index: number }) { - return ( -
-
- {index + 1} -

{item.title}

- -
-
- {item.description} -
-
- ); -} - -function StatusBadge({ - status, - className, -}: { - status: Status; - className?: string; -}) { - return ( - - {status.text} - - ); -} +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 { ReactMarkdownWrapper } from "@/components/ui/react-markdown-wrapper"; +import { cn } from "@/utils/ui"; + +const LAST_UPDATED = "February 25, 2026"; + +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: "Native app (mobile/desktop)", + description: + "Native OpenCut apps for Mac, Windows, Linux, and iOS/Android.", + 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"], + }, +}; + +export default function RoadmapPage() { + return ( + +
+
+ {roadmapItems.map((item, index) => ( + + ))} +
+ +
+
+ ); +} + +function RoadmapItem({ item, index }: { item: RoadmapItem; index: number }) { + return ( +
+
+ {index + 1} +

{item.title}

+ +
+
+ {item.description} +
+
+ ); +} + +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 60c13829..c3e80a6a 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-constants"; - -export default function robots(): MetadataRoute.Robots { - return { - rules: { - userAgent: "*", - allow: "/", - disallow: ["/_next/", "/projects/", "/editor/"], - }, - sitemap: `${SITE_URL}/sitemap.xml`, - }; -} +import type { MetadataRoute } from "next"; +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`, + }; +} diff --git a/apps/web/src/app/rss.xml/route.ts b/apps/web/src/app/rss.xml/route.ts index 909d5a1a..9cfad635 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-constants"; - -export async function GET() { - 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 - }`, - }); - - 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 }); - } -} +import { Feed } from "feed"; +import { getPosts } from "@/lib/blog/query"; +import { SITE_INFO, SITE_URL } from "@/constants/site-constants"; + +export async function GET() { + 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 + }`, + }); + + 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 }); + } +} diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index fed1aed6..2dea655d 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-constants"; -import { getPosts } from "@/lib/blog/query"; -import type { MetadataRoute } from "next"; - -export default async function sitemap(): Promise { - 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, - })) ?? []; - - 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, - ]; -} +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 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, + ]; +} diff --git a/apps/web/src/app/sponsors/page.tsx b/apps/web/src/app/sponsors/page.tsx index a1a57fce..64f18776 100644 --- a/apps/web/src/app/sponsors/page.tsx +++ b/apps/web/src/app/sponsors/page.tsx @@ -1,86 +1,86 @@ -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"; -import { cn } from "@/utils/ui"; - -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.name} -

- -
-

{sponsor.description}

-
-
-
- - ); -} +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"; +import { cn } from "@/utils/ui"; + +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.name} +

+ +
+

{sponsor.description}

+
+
+
+ + ); +} diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx index 40390f07..c346ad53 100644 --- a/apps/web/src/components/editable-timecode.tsx +++ b/apps/web/src/components/editable-timecode.tsx @@ -1,150 +1,150 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { formatTimeCode, parseTimeCode } from "@/lib/time"; -import type { TTimeCode } from "@/lib/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 = (event: React.KeyboardEvent) => { - if (event.key === "Enter") { - event.preventDefault(); - enterPressedRef.current = true; - applyEdit(); - } else if (event.key === "Escape") { - event.preventDefault(); - cancelEditing(); - } - }; - - const handleInputChange = ({ - target, - }: React.ChangeEvent) => { - setInputValue(target.value); - setHasError(false); - }; - - const handleBlur = () => { - if (!enterPressedRef.current && isEditing) { - applyEdit(); - } - }; - - const handleDisplayKeyDown = ( - event: React.KeyboardEvent, - ) => { - if (disabled) return; - - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - startEditing(); - } - }; - - useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [isEditing]); - - if (isEditing) { - return ( - - ); - } - - return ( - - ); -} +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { formatTimeCode, parseTimeCode } from "@/lib/time"; +import type { TTimeCode } from "@/lib/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 = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + enterPressedRef.current = true; + applyEdit(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelEditing(); + } + }; + + const handleInputChange = ({ + target, + }: React.ChangeEvent) => { + setInputValue(target.value); + setHasError(false); + }; + + const handleBlur = () => { + if (!enterPressedRef.current && isEditing) { + applyEdit(); + } + }; + + const handleDisplayKeyDown = ( + event: React.KeyboardEvent, + ) => { + if (disabled) return; + + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + startEditing(); + } + }; + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + if (isEditing) { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx index 42f99c79..fad711e3 100644 --- a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx +++ b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx @@ -1,83 +1,83 @@ -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import type { TProjectMetadata } from "@/lib/project/types"; -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)} - - } - /> - - - - - - - - ); -} +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { TProjectMetadata } from "@/lib/project/types"; +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)} + + } + /> + + + + + + + + ); +} diff --git a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx index 9a353a1c..70802b5b 100644 --- a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx +++ b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx @@ -1,229 +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)} - /> - ))} -
-
- ))} -
-
- - - -
-
- ); -} - -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 ( - - ); -} +"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)} + /> + ))} +
+
+ ))} +
+
+ + + +
+
+ ); +} + +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 ( + + ); +} diff --git a/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx b/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx index d391928c..0e854a93 100644 --- a/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx +++ b/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx @@ -1,41 +1,41 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { useStoragePersistence } from "@/hooks/use-storage-persistence"; - -export function StoragePersistenceDialog() { - const { showDialog, onConfirm, onDismiss } = useStoragePersistence(); - - return ( - !open && onDismiss()}> - - - Don't lose your projects - - -

- Your browser can automatically delete your projects when storage - runs low. -

-

- Allow OpenCut to protect them? -

-
- - - - -
-
- ); -} +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useStoragePersistence } from "@/hooks/use-storage-persistence"; + +export function StoragePersistenceDialog() { + const { showDialog, onConfirm, onDismiss } = useStoragePersistence(); + + return ( + !open && onDismiss()}> + + + Don't lose your projects + + +

+ Your browser can automatically delete your projects when storage + runs low. +

+

+ Allow OpenCut to protect them? +

+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx index 8dd730b5..460e8ed6 100644 --- a/apps/web/src/components/editor/editor-header.tsx +++ b/apps/web/src/components/editor/editor-header.tsx @@ -1,245 +1,245 @@ -"use client"; - -import { Button } from "../ui/button"; -import { useRef, useState } from "react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; -import Link from "next/link"; -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 { ExportButton } from "./export-button"; -import { ThemeToggle } from "../theme-toggle"; -import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants"; -import { toast } from "sonner"; -import { useEditor } from "@/hooks/use-editor"; -import { CommandIcon, Logout05Icon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { ShortcutsDialog } from "./dialogs/shortcuts-dialog"; -import Image from "next/image"; -import { cn } from "@/utils/ui"; - -export function EditorHeader() { - 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 = useEditor((e) => e.project.getActive()); - - const handleExit = async () => { - if (isExiting) return; - setIsExiting(true); - - 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 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 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 ( - <> - - - - - - } - > - Exit project - - - setOpenDialog("shortcuts")} - icon={} - > - 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)} - /> - - ); -} - -function EditableProjectName() { - const editor = useEditor(); - const activeProject = useEditor((e) => e.project.getActive()); - const [isEditing, setIsEditing] = useState(false); - const inputRef = useRef(null); - const originalNameRef = useRef(""); - - const projectName = activeProject?.metadata.name || ""; - - const startEditing = () => { - if (isEditing) return; - originalNameRef.current = projectName; - setIsEditing(true); - - requestAnimationFrame(() => { - inputRef.current?.select(); - }); - }; - - const saveEdit = async () => { - if (!inputRef.current || !activeProject) return; - const newName = inputRef.current.value.trim(); - setIsEditing(false); - - if (!newName) { - inputRef.current.value = originalNameRef.current; - return; - } - - if (newName !== originalNameRef.current) { - try { - await editor.project.renameProject({ - id: activeProject.metadata.id, - name: newName, - }); - } catch (error) { - toast.error("Failed to rename project", { - description: - error instanceof Error ? error.message : "Please try again", - }); - } - } - }; - - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === "Enter") { - event.preventDefault(); - inputRef.current?.blur(); - } else if (event.key === "Escape") { - event.preventDefault(); - if (inputRef.current) { - inputRef.current.value = originalNameRef.current; - inputRef.current.setSelectionRange(0, 0); - } - setIsEditing(false); - inputRef.current?.blur(); - } - }; - - return ( - - ); -} +"use client"; + +import { Button } from "../ui/button"; +import { useRef, useState } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import Link from "next/link"; +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 { ExportButton } from "./export-button"; +import { ThemeToggle } from "../theme-toggle"; +import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants"; +import { toast } from "sonner"; +import { useEditor } from "@/hooks/use-editor"; +import { CommandIcon, Logout05Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ShortcutsDialog } from "./dialogs/shortcuts-dialog"; +import Image from "next/image"; +import { cn } from "@/utils/ui"; + +export function EditorHeader() { + 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 = useEditor((e) => e.project.getActive()); + + const handleExit = async () => { + if (isExiting) return; + setIsExiting(true); + + 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 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 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 ( + <> + + + + + + } + > + Exit project + + + setOpenDialog("shortcuts")} + icon={} + > + 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)} + /> + + ); +} + +function EditableProjectName() { + const editor = useEditor(); + const activeProject = useEditor((e) => e.project.getActive()); + const [isEditing, setIsEditing] = useState(false); + const inputRef = useRef(null); + const originalNameRef = useRef(""); + + const projectName = activeProject?.metadata.name || ""; + + const startEditing = () => { + if (isEditing) return; + originalNameRef.current = projectName; + setIsEditing(true); + + requestAnimationFrame(() => { + inputRef.current?.select(); + }); + }; + + const saveEdit = async () => { + if (!inputRef.current || !activeProject) return; + const newName = inputRef.current.value.trim(); + setIsEditing(false); + + if (!newName) { + inputRef.current.value = originalNameRef.current; + return; + } + + if (newName !== originalNameRef.current) { + try { + await editor.project.renameProject({ + id: activeProject.metadata.id, + name: newName, + }); + } catch (error) { + toast.error("Failed to rename project", { + description: + error instanceof Error ? error.message : "Please try again", + }); + } + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + inputRef.current?.blur(); + } else if (event.key === "Escape") { + event.preventDefault(); + if (inputRef.current) { + inputRef.current.value = originalNameRef.current; + inputRef.current.setSelectionRange(0, 0); + } + setIsEditing(false); + inputRef.current?.blur(); + } + }; + + return ( + + ); +} diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index ec39e7e6..3fa4af7a 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -1,336 +1,336 @@ -"use client"; - -import { useState } from "react"; -import { TransitionTopIcon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - 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, - downloadBuffer, -} from "@/lib/export"; -import { Check, Copy, Download, RotateCcw } from "lucide-react"; -import { - EXPORT_FORMAT_VALUES, - EXPORT_QUALITY_VALUES, - type ExportFormat, - type ExportQuality, -} from "@/lib/export"; -import { - Section, - SectionContent, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { useEditor } from "@/hooks/use-editor"; -import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants"; - -function isExportFormat(value: string): value is ExportFormat { - return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value); -} - -function isExportQuality(value: string): value is ExportQuality { - return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value); -} - -export function ExportButton() { - const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false); - const editor = useEditor(); - const activeProject = useEditor((e) => e.project.getActiveOrNull()); - const hasProject = !!activeProject; - - const handlePopoverOpenChange = ({ open }: { open: boolean }) => { - if (!open) { - editor.project.cancelExport(); - editor.project.clearExportState(); - } - setIsExportPopoverOpen(open); - }; - - return ( - handlePopoverOpenChange({ open })} - > - - - - {hasProject && } - - ); -} - -function ExportPopover({ - onOpenChange, -}: { - onOpenChange: (open: boolean) => void; -}) { - const editor = useEditor(); - const activeProject = useEditor((e) => e.project.getActive()); - const exportState = useEditor((e) => e.project.getExportState()); - const { isExporting, progress, result: exportResult } = exportState; - const [format, setFormat] = useState( - DEFAULT_EXPORT_OPTIONS.format, - ); - const [quality, setQuality] = useState( - DEFAULT_EXPORT_OPTIONS.quality, - ); - const [shouldIncludeAudio, setShouldIncludeAudio] = useState( - DEFAULT_EXPORT_OPTIONS.includeAudio ?? true, - ); - - const handleExport = async () => { - if (!activeProject) return; - - const result = await editor.project.export({ - options: { - format, - quality, - fps: activeProject.settings.fps, - includeAudio: shouldIncludeAudio, - }, - }); - - if (result.cancelled) { - editor.project.clearExportState(); - return; - } - - if (result.success && result.buffer) { - downloadBuffer({ - buffer: result.buffer, - filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`, - mimeType: getExportMimeType({ format }), - }); - - editor.project.clearExportState(); - onOpenChange(false); - } - }; - - const handleCancel = () => { - editor.project.cancelExport(); - }; - - return ( - - {exportResult && !exportResult.success ? ( - - ) : ( - <> -
-

- {isExporting ? "Exporting project" : "Export project"} -

-
- -
- {!isExporting && ( - <> -
-
- - Format - - - { - if (isExportFormat(value)) { - setFormat(value); - } - }} - > -
- - -
-
- - -
-
-
-
- -
- - Quality - - - { - if (isExportQuality(value)) { - setQuality(value); - } - }} - > -
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- -
- - Audio - - -
- - setShouldIncludeAudio(!!checked) - } - /> - -
-
-
-
- -
- -
- - )} - - {isExporting && ( -
-
-
-

- {Math.round(progress * 100)}% -

-

100%

-
- -
- - -
- )} -
- - )} -
- ); -} - -function ExportError({ - error, - onRetry, -}: { - error: string; - onRetry: () => void; -}) { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - await navigator.clipboard.writeText(error); - setCopied(true); - setTimeout(() => setCopied(false), 1000); - }; - - return ( -
-
-

Export failed

-

{error}

-
- -
- - -
-
- ); -} +"use client"; + +import { useState } from "react"; +import { TransitionTopIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + 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, + downloadBuffer, +} from "@/lib/export"; +import { Check, Copy, Download, RotateCcw } from "lucide-react"; +import { + EXPORT_FORMAT_VALUES, + EXPORT_QUALITY_VALUES, + type ExportFormat, + type ExportQuality, +} from "@/lib/export"; +import { + Section, + SectionContent, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { useEditor } from "@/hooks/use-editor"; +import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants"; + +function isExportFormat(value: string): value is ExportFormat { + return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value); +} + +function isExportQuality(value: string): value is ExportQuality { + return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value); +} + +export function ExportButton() { + const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false); + const editor = useEditor(); + const activeProject = useEditor((e) => e.project.getActiveOrNull()); + const hasProject = !!activeProject; + + const handlePopoverOpenChange = ({ open }: { open: boolean }) => { + if (!open) { + editor.project.cancelExport(); + editor.project.clearExportState(); + } + setIsExportPopoverOpen(open); + }; + + return ( + handlePopoverOpenChange({ open })} + > + + + + {hasProject && } + + ); +} + +function ExportPopover({ + onOpenChange, +}: { + onOpenChange: (open: boolean) => void; +}) { + const editor = useEditor(); + const activeProject = useEditor((e) => e.project.getActive()); + const exportState = useEditor((e) => e.project.getExportState()); + const { isExporting, progress, result: exportResult } = exportState; + const [format, setFormat] = useState( + DEFAULT_EXPORT_OPTIONS.format, + ); + const [quality, setQuality] = useState( + DEFAULT_EXPORT_OPTIONS.quality, + ); + const [shouldIncludeAudio, setShouldIncludeAudio] = useState( + DEFAULT_EXPORT_OPTIONS.includeAudio ?? true, + ); + + const handleExport = async () => { + if (!activeProject) return; + + const result = await editor.project.export({ + options: { + format, + quality, + fps: activeProject.settings.fps, + includeAudio: shouldIncludeAudio, + }, + }); + + if (result.cancelled) { + editor.project.clearExportState(); + return; + } + + if (result.success && result.buffer) { + downloadBuffer({ + buffer: result.buffer, + filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`, + mimeType: getExportMimeType({ format }), + }); + + editor.project.clearExportState(); + onOpenChange(false); + } + }; + + const handleCancel = () => { + editor.project.cancelExport(); + }; + + return ( + + {exportResult && !exportResult.success ? ( + + ) : ( + <> +
+

+ {isExporting ? "Exporting project" : "Export project"} +

+
+ +
+ {!isExporting && ( + <> +
+
+ + Format + + + { + if (isExportFormat(value)) { + setFormat(value); + } + }} + > +
+ + +
+
+ + +
+
+
+
+ +
+ + Quality + + + { + if (isExportQuality(value)) { + setQuality(value); + } + }} + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ + Audio + + +
+ + setShouldIncludeAudio(!!checked) + } + /> + +
+
+
+
+ +
+ +
+ + )} + + {isExporting && ( +
+
+
+

+ {Math.round(progress * 100)}% +

+

100%

+
+ +
+ + +
+ )} +
+ + )} +
+ ); +} + +function ExportError({ + error, + onRetry, +}: { + error: string; + onRetry: () => void; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(error); + setCopied(true); + setTimeout(() => setCopied(false), 1000); + }; + + return ( +
+
+

Export failed

+

{error}

+
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/editor/mobile-gate.tsx b/apps/web/src/components/editor/mobile-gate.tsx index 6483e438..3dde2fa7 100644 --- a/apps/web/src/components/editor/mobile-gate.tsx +++ b/apps/web/src/components/editor/mobile-gate.tsx @@ -1,72 +1,72 @@ -"use client"; - -import { useEffect, useState } from "react"; -import Link from "next/link"; -import { Button } from "../ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons"; -import { useRouter } from "next/navigation"; - -const STORAGE_KEY = "mobile-acknowledged"; - -interface MobileGateProps { - children: React.ReactNode; -} - -export function MobileGate({ children }: MobileGateProps) { - const router = useRouter(); - const [show, setShow] = useState(null); - - useEffect(() => { - const isMobile = window.innerWidth < 1024; - const acknowledged = localStorage.getItem(STORAGE_KEY) === "true"; - setShow(isMobile && !acknowledged); - }, []); - - if (show === null) return null; - if (!show) return <>{children}; - - const handleContinue = () => { - localStorage.setItem(STORAGE_KEY, "true"); - setShow(false); - }; - - const handleGoBack = () => { - router.back(); - }; - - return ( -
- - -
-
-

- Desktop only (for now) -

-

- OpenCut isn't optimized for mobile or iPad yet. Things will break - and the layout will be a mess. Come back on a desktop for the real - experience. -

-
-
- - -
-
-
- ); -} +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Button } from "../ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons"; +import { useRouter } from "next/navigation"; + +const STORAGE_KEY = "mobile-acknowledged"; + +interface MobileGateProps { + children: React.ReactNode; +} + +export function MobileGate({ children }: MobileGateProps) { + const router = useRouter(); + const [show, setShow] = useState(null); + + useEffect(() => { + const isMobile = window.innerWidth < 1024; + const acknowledged = localStorage.getItem(STORAGE_KEY) === "true"; + setShow(isMobile && !acknowledged); + }, []); + + if (show === null) return null; + if (!show) return <>{children}; + + const handleContinue = () => { + localStorage.setItem(STORAGE_KEY, "true"); + setShow(false); + }; + + const handleGoBack = () => { + router.back(); + }; + + return ( +
+ + +
+
+

+ Desktop only (for now) +

+

+ OpenCut isn't optimized for mobile or iPad yet. Things will break + and the layout will be a mess. Come back on a desktop for the real + experience. +

+
+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx index 9d9ae903..00ccc6e4 100644 --- a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx +++ b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx @@ -1,61 +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 ( - - ); -} +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 ( + + ); +} diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index 8c9acce8..166c81db 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -1,239 +1,239 @@ -"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 "@/lib/timeline/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; -} - -export function DraggableItem({ - name, - preview, - dragData, - onDragStart, - onAddToTimeline, - aspectRatio = 16 / 9, - className = "", - containerClassName, - shouldShowPlusOnDrag = true, - shouldShowLabel = true, - isRounded = true, - variant = "card", - isDraggable = true, -}: DraggableItemProps) { - const [isDragging, setIsDragging] = useState(false); - const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); - const dragRef = useRef(null); - const editor = useEditor(); - - 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} - - - )} -
-
- ) : ( -
- -
- )} - - {isDraggable && - isDragging && - typeof document !== "undefined" && - createPortal( -
-
- -
- {preview} -
- {shouldShowPlusOnDrag && ( - - )} -
-
-
, - document.body, - )} - - ); -} - -function PlusButton({ - className, - onClick, - tooltipText, -}: { - className?: string; - onClick?: () => void; - tooltipText?: string; -}) { - const button = ( - - ); - - if (tooltipText) { - return ( - - {button} - -

{tooltipText}

-
-
- ); - } - - return button; -} +"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 "@/lib/timeline/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; +} + +export function DraggableItem({ + name, + preview, + dragData, + onDragStart, + onAddToTimeline, + aspectRatio = 16 / 9, + className = "", + containerClassName, + shouldShowPlusOnDrag = true, + shouldShowLabel = true, + isRounded = true, + variant = "card", + isDraggable = true, +}: DraggableItemProps) { + const [isDragging, setIsDragging] = useState(false); + const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); + const dragRef = useRef(null); + const editor = useEditor(); + + 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} + + + )} +
+
+ ) : ( +
+ +
+ )} + + {isDraggable && + isDragging && + typeof document !== "undefined" && + createPortal( +
+
+ +
+ {preview} +
+ {shouldShowPlusOnDrag && ( + + )} +
+
+
, + document.body, + )} + + ); +} + +function PlusButton({ + className, + onClick, + tooltipText, +}: { + className?: string; + onClick?: () => void; + tooltipText?: string; +}) { + const button = ( + + ); + + if (tooltipText) { + return ( + + {button} + +

{tooltipText}

+
+
+ ); + } + + return button; +} diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx index a4e072f1..da53bed0 100644 --- a/apps/web/src/components/editor/panels/assets/tabbar.tsx +++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx @@ -1,111 +1,111 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/utils/ui"; -import { - TAB_KEYS, - tabs, - useAssetsPanelStore, -} from "@/stores/assets-panel-store"; - -export function TabBar() { - const { activeTab, setActiveTab } = useAssetsPanelStore(); - const [showTopFade, setShowTopFade] = useState(false); - const [showBottomFade, setShowBottomFade] = useState(false); - const scrollRef = useRef(null); - - 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 ( - - - - - -
- {tab.label} -
-
-
- ); - })} -
- - - -
- ); -} - -function FadeOverlay({ - direction, - show, -}: { - direction: "top" | "bottom"; - show: boolean; -}) { - return ( -
- ); -} +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/utils/ui"; +import { + TAB_KEYS, + tabs, + useAssetsPanelStore, +} from "@/stores/assets-panel-store"; + +export function TabBar() { + const { activeTab, setActiveTab } = useAssetsPanelStore(); + const [showTopFade, setShowTopFade] = useState(false); + const [showBottomFade, setShowBottomFade] = useState(false); + const scrollRef = useRef(null); + + 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 ( + + + + + +
+ {tab.label} +
+
+
+ ); + })} +
+ + + +
+ ); +} + +function FadeOverlay({ + direction, + show, +}: { + direction: "top" | "bottom"; + show: boolean; +}) { + return ( +
+ ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/base-panel.tsx b/apps/web/src/components/editor/panels/assets/views/base-panel.tsx index 0ece9b19..869304cd 100644 --- a/apps/web/src/components/editor/panels/assets/views/base-panel.tsx +++ b/apps/web/src/components/editor/panels/assets/views/base-panel.tsx @@ -1,57 +1,57 @@ -import { cn } from "@/utils/ui"; - -interface PanelViewProps extends React.HTMLAttributes { - title?: string; - actions?: React.ReactNode; - children: React.ReactNode; - contentClassName?: string; - scrollClassName?: string; - hideHeader?: boolean; - ref?: React.Ref; - onScroll?: React.UIEventHandler; - scrollRef?: React.Ref; -} - -export function PanelView({ - title, - actions, - children, - className, - contentClassName, - scrollClassName, - hideHeader = false, - ref, - onScroll, - scrollRef, - ...rest -}: PanelViewProps) { - return ( -
- {!hideHeader && ( -
- {title && ( - {title} - )} - {actions} -
- )} -
-
- {children} -
-
-
- ); -} +import { cn } from "@/utils/ui"; + +interface PanelViewProps extends React.HTMLAttributes { + title?: string; + actions?: React.ReactNode; + children: React.ReactNode; + contentClassName?: string; + scrollClassName?: string; + hideHeader?: boolean; + ref?: React.Ref; + onScroll?: React.UIEventHandler; + scrollRef?: React.Ref; +} + +export function PanelView({ + title, + actions, + children, + className, + contentClassName, + scrollClassName, + hideHeader = false, + ref, + onScroll, + scrollRef, + ...rest +}: PanelViewProps) { + return ( +
+ {!hideHeader && ( +
+ {title && ( + {title} + )} + {actions} +
+ )} +
+
+ {children} +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx index 023e1578..f22c0211 100644 --- a/apps/web/src/components/editor/panels/assets/views/captions.tsx +++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx @@ -1,179 +1,179 @@ -import { Button } from "@/components/ui/button"; -import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; -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_TRANSCRIPTION_SAMPLE_RATE, - TRANSCRIPTION_LANGUAGES, -} from "@/constants/transcription-constants"; -import type { - TranscriptionLanguage, - TranscriptionProgress, -} from "@/lib/transcription/types"; -import { transcriptionService } from "@/services/transcription/service"; -import { decodeAudioToFloat32 } from "@/lib/media/audio"; -import { buildCaptionChunks } from "@/lib/transcription/caption"; -import { Spinner } from "@/components/ui/spinner"; -import { - Section, - SectionContent, - SectionField, - SectionFields, -} from "@/components/section"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { - AddTrackCommand, - BatchCommand, - InsertElementCommand, -} from "@/lib/commands"; - -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, - sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE, - }); - - const result = await transcriptionService.transcribe({ - audioData: samples, - language: selectedLanguage === "auto" ? undefined : selectedLanguage, - onProgress: handleProgress, - }); - - setProcessingStep("Generating captions..."); - const captionChunks = buildCaptionChunks({ segments: result.segments }); - - const addTrackCommand = new AddTrackCommand("text", 0); - const insertCommands = captionChunks.map( - (caption, i) => - new InsertElementCommand({ - placement: { - mode: "explicit", - trackId: addTrackCommand.getTrackId(), - }, - element: { - ...DEFAULTS.text.element, - name: `Caption ${i + 1}`, - content: caption.text, - duration: caption.duration, - startTime: caption.startTime, - fontSize: 65, - fontWeight: "bold", - }, - }), - ); - - editor.command.execute({ - command: new BatchCommand([addTrackCommand, ...insertCommands]), - }); - } 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 ( - -
- - - - - - - - {error && ( -
-

{error}

-
- )} -
-
-
- - - -
-
- ); -} +import { Button } from "@/components/ui/button"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; +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_TRANSCRIPTION_SAMPLE_RATE, + TRANSCRIPTION_LANGUAGES, +} from "@/constants/transcription-constants"; +import type { + TranscriptionLanguage, + TranscriptionProgress, +} from "@/lib/transcription/types"; +import { transcriptionService } from "@/services/transcription/service"; +import { decodeAudioToFloat32 } from "@/lib/media/audio"; +import { buildCaptionChunks } from "@/lib/transcription/caption"; +import { Spinner } from "@/components/ui/spinner"; +import { + Section, + SectionContent, + SectionField, + SectionFields, +} from "@/components/section"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { + AddTrackCommand, + BatchCommand, + InsertElementCommand, +} from "@/lib/commands"; + +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, + sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE, + }); + + const result = await transcriptionService.transcribe({ + audioData: samples, + language: selectedLanguage === "auto" ? undefined : selectedLanguage, + onProgress: handleProgress, + }); + + setProcessingStep("Generating captions..."); + const captionChunks = buildCaptionChunks({ segments: result.segments }); + + const addTrackCommand = new AddTrackCommand("text", 0); + const insertCommands = captionChunks.map( + (caption, i) => + new InsertElementCommand({ + placement: { + mode: "explicit", + trackId: addTrackCommand.getTrackId(), + }, + element: { + ...DEFAULTS.text.element, + name: `Caption ${i + 1}`, + content: caption.text, + duration: caption.duration, + startTime: caption.startTime, + fontSize: 65, + fontWeight: "bold", + }, + }), + ); + + editor.command.execute({ + command: new BatchCommand([addTrackCommand, ...insertCommands]), + }); + } 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 ( + +
+ + + + + + + + {error && ( +
+

{error}

+
+ )} +
+
+
+ + + +
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx index b3ef1eb1..7a81188f 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx @@ -1,227 +1,227 @@ -"use client"; - -import { memo, useCallback, useEffect, useMemo, useRef } from "react"; -import { - Section, - SectionContent, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { - BLUR_INTENSITY_PRESETS, - DEFAULT_BLUR_INTENSITY, - DEFAULT_COLOR, -} 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 { effectPreviewService } from "@/services/renderer/effect-preview"; -import type { TCanvasSize } from "@/lib/project/types"; -import { cn } from "@/utils/ui"; - -const BlurPreview = memo( - ({ - blur, - canvasSize, - isSelected, - onSelect, - }: { - blur: { label: string; value: number }; - canvasSize: TCanvasSize; - isSelected: boolean; - onSelect: () => void; - }) => { - const canvasRef = useRef(null); - const { width, height } = canvasSize; - - useEffect(() => { - const renderPreview = () => { - if (!canvasRef.current) return; - - effectPreviewService.renderPreview({ - effectType: "blur", - params: { intensity: blur.value }, - targetCanvas: canvasRef.current, - uniformDimensions: { width, height }, - }); - }; - - renderPreview(); - return effectPreviewService.onPreviewImageReady({ callback: renderPreview }); - }, [blur.value, width, height]); - - return ( - - ); - }, -); - -BlurPreview.displayName = "BlurPreview"; - -const BackgroundPreviews = memo( - ({ - backgrounds, - currentBackgroundColor, - isColorBackground, - onSelect, - useBackgroundColor = false, - }: { - backgrounds: string[]; - currentBackgroundColor: string; - isColorBackground: boolean; - onSelect: (bg: string) => void; - useBackgroundColor?: boolean; - }) => { - return useMemo( - () => - backgrounds.map((bg) => ( - + ); + }, +); + +BlurPreview.displayName = "BlurPreview"; + +const BackgroundPreviews = memo( + ({ + backgrounds, + currentBackgroundColor, + isColorBackground, + onSelect, + useBackgroundColor = false, + }: { + backgrounds: string[]; + currentBackgroundColor: string; + isColorBackground: boolean; + onSelect: (bg: string) => void; + useBackgroundColor?: boolean; + }) => { + return useMemo( + () => + backgrounds.map((bg) => ( + - ); -} - -function AspectRatioPreview({ ratio }: { ratio?: string }) { - if (!ratio) return null; - - const [w, h] = ratio.split(":").map(Number); - const maxSize = 16; - const width = w >= h ? maxSize : (w / h) * maxSize; - const height = h >= w ? maxSize : (h / w) * maxSize; - - return ( -
- ); -} +"use client"; + +import { useRef, useState } from "react"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { FPS_PRESETS } from "@/constants/project-constants"; +import { useEditor } from "@/hooks/use-editor"; +import { + Section, + SectionContent, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { BackgroundContent } from "./background"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { NumberField } from "@/components/ui/number-field"; +import { useEditorStore } from "@/stores/editor-store"; +import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Tick02Icon } from "@hugeicons/core-free-icons"; +import { cn } from "@/utils/ui"; +import { dimensionToAspectRatio } from "@/utils/geometry"; +import { formatNumberForDisplay } from "@/utils/math"; +import { OcSquarePlusIcon } from "@opencut/ui/icons"; +import type { TCanvasSize } from "@/lib/project/types"; + +type SettingsView = "project-info" | "background"; + +const PRESET_LABELS: Record = { + "1:1": "1:1", + "16:9": "16:9", + "9:16": "9:16", + "4:3": "4:3", +}; + +function areCanvasSizesEqual({ + left, + right, +}: { + left: TCanvasSize; + right: TCanvasSize; +}) { + return left.width === right.width && left.height === right.height; +} + +function formatCanvasDimension({ value }: { value: number }) { + return formatNumberForDisplay({ value, maxFractionDigits: 0 }); +} + +function parseCanvasDimension({ input }: { input: string }): number | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) return null; + + const rounded = Math.round(parsed); + return rounded > 0 ? rounded : null; +} + +function useCanvasDimensionDraft({ + value, + onCommit, +}: { + value: number; + onCommit: (value: number) => void; +}) { + const pendingValueRef = useRef(value); + const syncedValueRef = useRef(value); + + if (syncedValueRef.current !== value) { + syncedValueRef.current = value; + pendingValueRef.current = value; + } + + return usePropertyDraft({ + displayValue: formatCanvasDimension({ value }), + parse: (input) => parseCanvasDimension({ input }), + onPreview: (nextValue) => { + pendingValueRef.current = nextValue; + }, + onCommit: () => { + if (pendingValueRef.current !== value) { + onCommit(pendingValueRef.current); + } + }, + }); +} + +export function SettingsView() { + const [view, setView] = useState("project-info"); + const editor = useEditor(); + const activeProject = useEditor((e) => e.project.getActive()); + const { canvasPresets } = useEditorStore(); + const currentCanvasSize = activeProject.settings.canvasSize; + const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset"; + const lastCustomCanvasSize = + activeProject.settings.lastCustomCanvasSize ?? null; + + const presetItems = canvasPresets.map((preset, index) => { + const ratio = dimensionToAspectRatio(preset); + return { + id: index.toString(), + label: PRESET_LABELS[ratio] ?? ratio, + ratio, + canvasSize: preset, + }; + }); + + const selectedPresetId = canvasSizeMode === "preset" + ? (presetItems.find((preset) => + areCanvasSizesEqual({ + left: preset.canvasSize, + right: currentCanvasSize, + }), + )?.id ?? null) + : null; + + const updateCustomCanvasSize = ({ + canvasSize, + }: { + canvasSize: TCanvasSize; + }) => { + const shouldUpdateCanvasSize = !areCanvasSizesEqual({ + left: canvasSize, + right: currentCanvasSize, + }); + const shouldUpdateLastCustomCanvasSize = + lastCustomCanvasSize === null || + !areCanvasSizesEqual({ + left: canvasSize, + right: lastCustomCanvasSize, + }); + const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom"; + + if ( + !shouldUpdateCanvasSize && + !shouldUpdateLastCustomCanvasSize && + !shouldUpdateCanvasSizeMode + ) { + return; + } + + editor.project.updateSettings({ + settings: { + ...(shouldUpdateCanvasSize ? { canvasSize } : {}), + ...(shouldUpdateCanvasSizeMode + ? { canvasSizeMode: "custom" as const } + : {}), + lastCustomCanvasSize: canvasSize, + }, + }); + }; + + const selectPresetCanvasSize = ({ + canvasSize, + }: { + canvasSize: TCanvasSize; + }) => { + const shouldUpdateCanvasSize = !areCanvasSizesEqual({ + left: canvasSize, + right: currentCanvasSize, + }); + const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset"; + + if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return; + + editor.project.updateSettings({ + settings: { + ...(shouldUpdateCanvasSize ? { canvasSize } : {}), + ...(shouldUpdateCanvasSizeMode + ? { canvasSizeMode: "preset" as const } + : {}), + }, + }); + }; + + const selectCustomCanvasSize = () => { + updateCustomCanvasSize({ + canvasSize: lastCustomCanvasSize ?? currentCanvasSize, + }); + }; + + const widthDraft = useCanvasDimensionDraft({ + value: currentCanvasSize.width, + onCommit: (width) => + updateCustomCanvasSize({ + canvasSize: { width, height: currentCanvasSize.height }, + }), + }); + + const heightDraft = useCanvasDimensionDraft({ + value: currentCanvasSize.height, + onCommit: (height) => + updateCustomCanvasSize({ + canvasSize: { width: currentCanvasSize.width, height }, + }), + }); + + const isCustomSelected = canvasSizeMode === "custom"; + + return ( + setView(v as SettingsView)}> + + Project info + Background + + + } + > + {view === "project-info" && ( +
+
+ + Name + + {activeProject.metadata.name} + + +
+
+ + Frame rate + + +
+
+ + Aspect ratio + + + {presetItems.map((preset) => ( + } + isSelected={selectedPresetId === preset.id} + onClick={() => { + selectPresetCanvasSize({ + canvasSize: preset.canvasSize, + }); + }} + /> + ))} +
+ } + isSelected={isCustomSelected} + onClick={selectCustomCanvasSize} + uiOptions={ +
+ + +
+ } + /> +
+
+
+
+ )} + {view === "background" && } +
+ ); +} + +function AspectRatioItem({ + label, + previewIcon, + isSelected, + onClick, + uiOptions, +}: { + label: string; + previewIcon: React.ReactNode; + isSelected: boolean; + onClick: () => void; + uiOptions?: React.ReactNode; +}) { + return ( + + ); +} + +function AspectRatioPreview({ ratio }: { ratio?: string }) { + if (!ratio) return null; + + const [w, h] = ratio.split(":").map(Number); + const maxSize = 16; + const width = w >= h ? maxSize : (w / h) * maxSize; + const height = h >= w ? maxSize : (h / w) * maxSize; + + return ( +
+ ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx index 4fb43ac2..dc92ad9c 100644 --- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx +++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx @@ -1,574 +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 "@/lib/sounds/types"; -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 saved sounds? - - This will permanently remove all {savedSounds.length} saved - sounds from your collection. This action cannot be undone. - - - - - - - - -
- -
- -
- {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 ( -
- - -
- - -
-
- ); -} +"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 "@/lib/sounds/types"; +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 saved sounds? + + This will permanently remove all {savedSounds.length} saved + sounds from your collection. This action cannot be undone. + + + + + + + + +
+ +
+ +
+ {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 ( +
+ + +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx index af489630..285b37d3 100644 --- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -1,470 +1,470 @@ -"use client"; - -import Image from "next/image"; -import type { CSSProperties } from "react"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Spinner } from "@/components/ui/spinner"; -import { useEditor } from "@/hooks/use-editor"; -import { resolveStickerIntrinsicSize } from "@/lib/stickers"; -import { - buildGraphicElement, - buildStickerElement, -} from "@/lib/timeline/element-utils"; -import { STICKER_CATEGORIES } from "@/constants/sticker-constants"; -import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers"; -import { parseShapeStickerId } from "@/lib/stickers/providers/shapes"; -import type { TimelineDragData } from "@/lib/timeline/drag"; -import type { - StickerBrowseSection, - StickerCategory, - StickerItem as StickerData, -} from "@/lib/stickers"; -import { useStickersStore } from "@/stores/stickers-store"; -import { cn } from "@/utils/ui"; -import { - HappyIcon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; - -export function StickersView() { - const { - browseContent, - browseStickers, - searchQuery, - searchStickers, - selectedCategory, - setSearchQuery, - setSelectedCategory, - viewMode, - } = useStickersStore(); - - useEffect(() => { - if (viewMode === "browse" && !browseContent) { - void browseStickers(); - } - }, [browseContent, browseStickers, viewMode]); - - return ( -
-
- { - setSearchQuery({ query: e.target.value }); - void searchStickers({ query: e.target.value }); - }} - showClearIcon - onClear={() => { - setSearchQuery({ query: "" }); - void searchStickers({ query: "" }); - }} - className="w-full" - containerClassName="w-full" - /> -
- -
-
-
- {Object.entries(STICKER_CATEGORIES).map(([key, label]) => { - const isActive = key === selectedCategory; - return ( - - ); - })} -
-
-
- -
-
-
- ); -} - -function StickerGrid({ - items, - shouldCapSize = false, -}: { - items: StickerData[]; - shouldCapSize?: boolean; -}) { - const gridStyle: CSSProperties & { - "--sticker-min": string; - "--sticker-max"?: string; - } = { - gridTemplateColumns: shouldCapSize - ? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))" - : "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))", - "--sticker-min": "80px", - ...(shouldCapSize ? { "--sticker-max": "140px" } : {}), - }; - - return ( -
- {items.map((item) => ( - - ))} -
- ); -} - -function StickerRow({ items }: { items: StickerData[] }) { - return ( -
- {items.map((item) => ( -
- -
- ))} -
- ); -} - -function EmptyView({ message }: { message: string }) { - return ( -
- -
-

No stickers found

-

{message}

-
-
- ); -} - -function RegionBanner({ region }: { region: string }) { - return ( -
- - {region} -
- ); -} - -function StickersContentView() { - const { - browseContent, - clearRecentStickers, - isBrowsing, - isSearching, - searchQuery, - searchResults, - selectedCategory, - setSelectedCategory, - viewMode, - } = useStickersStore(); - - if (viewMode === "search") { - if (isSearching) { - return ( -
- -
- ); - } - - if (searchResults?.items.length) { - const normalizedQuery = searchQuery.trim().toLowerCase(); - const isRegionSearch = - selectedCategory === "flags" && - resolveQueryToRegions({ query: normalizedQuery }) !== null; - const regionLabel = getRegionLabel({ query: normalizedQuery }); - - return ( -
- {isRegionSearch && } -
- - {searchResults.total} results - -
- -
- ); - } - - // "all" tab search — sections are in browseContent, fall through to section rendering below - if (selectedCategory !== "all" && searchQuery) { - return ; - } - } - - if (isBrowsing && !browseContent) { - return ( -
- -
- ); - } - - if (!browseContent?.sections.length) { - const categoryLabel = STICKER_CATEGORIES[selectedCategory]; - return ( - - ); - } - - return ( -
- {browseContent.sections.map((section) => ( - { - setSelectedCategory({ category }); - }} - /> - ))} -
- ); -} - -function StickerSection({ - section, - onClearRecent, - onSeeAll, -}: { - section: StickerBrowseSection; - onClearRecent: () => void; - onSeeAll: (category: StickerCategory) => void; -}) { - const hasHeader = - Boolean(section.title) || section.id === "recent" || section.action; - - return ( -
- {hasHeader && ( -
- {section.title ? ( -

{section.title}

- ) : ( -
- )} - -
- {section.id === "recent" && ( - - )} - - {section.action?.type === "see-all" && section.action.category && ( - - )} -
-
- )} - - {section.layout === "row" ? ( - - ) : ( - - )} -
- ); -} - -interface StickerItemProps { - item: StickerData; - shouldCapSize?: boolean; - containerClassName?: string; -} - -function StickerItem({ - item, - shouldCapSize = false, - containerClassName, -}: StickerItemProps) { - const editor = useEditor(); - const { addToRecentStickers } = useStickersStore(); - const [isAdding, setIsAdding] = useState(false); - const [hasImageError, setHasImageError] = useState(false); - - useEffect(() => { - if (!item.id) { - return; - } - - setHasImageError(false); - }, [item.id]); - - const displayName = item.name; - const shapePreset = - item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null; - - const handleAdd = async () => { - setIsAdding(true); - try { - const currentTime = editor.playback.getCurrentTime(); - - let element: - | ReturnType - | ReturnType; - if (shapePreset) { - element = buildGraphicElement({ - definitionId: shapePreset.definitionId, - name: shapePreset.name, - startTime: currentTime, - params: shapePreset.params, - }); - } else { - const { width: intrinsicWidth, height: intrinsicHeight } = - await resolveStickerIntrinsicSize({ stickerId: item.id }); - element = buildStickerElement({ - stickerId: item.id, - name: item.name, - startTime: currentTime, - intrinsicWidth, - intrinsicHeight, - }); - } - - editor.timeline.insertElement({ - placement: { mode: "auto" }, - element, - }); - - addToRecentStickers({ stickerId: item.id }); - } catch (error) { - console.error("Failed to add sticker:", error); - toast.error("Failed to add sticker to timeline"); - } finally { - setIsAdding(false); - } - }; - - const preview = ( -
- {hasImageError ? ( - - {displayName} - - ) : ( - {displayName} setHasImageError(true)} - loading="lazy" - unoptimized - /> - )} -
- ); - - const dragData: TimelineDragData = shapePreset - ? { - id: item.id, - type: "graphic", - name: displayName, - definitionId: shapePreset.definitionId, - params: shapePreset.params ?? {}, - } - : { - id: item.id, - type: "sticker", - name: displayName, - stickerId: item.id, - }; - - return ( -
- - {isAdding && ( -
- -
- )} -
- ); -} +"use client"; + +import Image from "next/image"; +import type { CSSProperties } from "react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { useEditor } from "@/hooks/use-editor"; +import { resolveStickerIntrinsicSize } from "@/lib/stickers"; +import { + buildGraphicElement, + buildStickerElement, +} from "@/lib/timeline/element-utils"; +import { STICKER_CATEGORIES } from "@/constants/sticker-constants"; +import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers"; +import { parseShapeStickerId } from "@/lib/stickers/providers/shapes"; +import type { TimelineDragData } from "@/lib/timeline/drag"; +import type { + StickerBrowseSection, + StickerCategory, + StickerItem as StickerData, +} from "@/lib/stickers"; +import { useStickersStore } from "@/stores/stickers-store"; +import { cn } from "@/utils/ui"; +import { + HappyIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +export function StickersView() { + const { + browseContent, + browseStickers, + searchQuery, + searchStickers, + selectedCategory, + setSearchQuery, + setSelectedCategory, + viewMode, + } = useStickersStore(); + + useEffect(() => { + if (viewMode === "browse" && !browseContent) { + void browseStickers(); + } + }, [browseContent, browseStickers, viewMode]); + + return ( +
+
+ { + setSearchQuery({ query: e.target.value }); + void searchStickers({ query: e.target.value }); + }} + showClearIcon + onClear={() => { + setSearchQuery({ query: "" }); + void searchStickers({ query: "" }); + }} + className="w-full" + containerClassName="w-full" + /> +
+ +
+
+
+ {Object.entries(STICKER_CATEGORIES).map(([key, label]) => { + const isActive = key === selectedCategory; + return ( + + ); + })} +
+
+
+ +
+
+
+ ); +} + +function StickerGrid({ + items, + shouldCapSize = false, +}: { + items: StickerData[]; + shouldCapSize?: boolean; +}) { + const gridStyle: CSSProperties & { + "--sticker-min": string; + "--sticker-max"?: string; + } = { + gridTemplateColumns: shouldCapSize + ? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))" + : "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))", + "--sticker-min": "80px", + ...(shouldCapSize ? { "--sticker-max": "140px" } : {}), + }; + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +function StickerRow({ items }: { items: StickerData[] }) { + return ( +
+ {items.map((item) => ( +
+ +
+ ))} +
+ ); +} + +function EmptyView({ message }: { message: string }) { + return ( +
+ +
+

No stickers found

+

{message}

+
+
+ ); +} + +function RegionBanner({ region }: { region: string }) { + return ( +
+ + {region} +
+ ); +} + +function StickersContentView() { + const { + browseContent, + clearRecentStickers, + isBrowsing, + isSearching, + searchQuery, + searchResults, + selectedCategory, + setSelectedCategory, + viewMode, + } = useStickersStore(); + + if (viewMode === "search") { + if (isSearching) { + return ( +
+ +
+ ); + } + + if (searchResults?.items.length) { + const normalizedQuery = searchQuery.trim().toLowerCase(); + const isRegionSearch = + selectedCategory === "flags" && + resolveQueryToRegions({ query: normalizedQuery }) !== null; + const regionLabel = getRegionLabel({ query: normalizedQuery }); + + return ( +
+ {isRegionSearch && } +
+ + {searchResults.total} results + +
+ +
+ ); + } + + // "all" tab search — sections are in browseContent, fall through to section rendering below + if (selectedCategory !== "all" && searchQuery) { + return ; + } + } + + if (isBrowsing && !browseContent) { + return ( +
+ +
+ ); + } + + if (!browseContent?.sections.length) { + const categoryLabel = STICKER_CATEGORIES[selectedCategory]; + return ( + + ); + } + + return ( +
+ {browseContent.sections.map((section) => ( + { + setSelectedCategory({ category }); + }} + /> + ))} +
+ ); +} + +function StickerSection({ + section, + onClearRecent, + onSeeAll, +}: { + section: StickerBrowseSection; + onClearRecent: () => void; + onSeeAll: (category: StickerCategory) => void; +}) { + const hasHeader = + Boolean(section.title) || section.id === "recent" || section.action; + + return ( +
+ {hasHeader && ( +
+ {section.title ? ( +

{section.title}

+ ) : ( +
+ )} + +
+ {section.id === "recent" && ( + + )} + + {section.action?.type === "see-all" && section.action.category && ( + + )} +
+
+ )} + + {section.layout === "row" ? ( + + ) : ( + + )} +
+ ); +} + +interface StickerItemProps { + item: StickerData; + shouldCapSize?: boolean; + containerClassName?: string; +} + +function StickerItem({ + item, + shouldCapSize = false, + containerClassName, +}: StickerItemProps) { + const editor = useEditor(); + const { addToRecentStickers } = useStickersStore(); + const [isAdding, setIsAdding] = useState(false); + const [hasImageError, setHasImageError] = useState(false); + + useEffect(() => { + if (!item.id) { + return; + } + + setHasImageError(false); + }, [item.id]); + + const displayName = item.name; + const shapePreset = + item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null; + + const handleAdd = async () => { + setIsAdding(true); + try { + const currentTime = editor.playback.getCurrentTime(); + + let element: + | ReturnType + | ReturnType; + if (shapePreset) { + element = buildGraphicElement({ + definitionId: shapePreset.definitionId, + name: shapePreset.name, + startTime: currentTime, + params: shapePreset.params, + }); + } else { + const { width: intrinsicWidth, height: intrinsicHeight } = + await resolveStickerIntrinsicSize({ stickerId: item.id }); + element = buildStickerElement({ + stickerId: item.id, + name: item.name, + startTime: currentTime, + intrinsicWidth, + intrinsicHeight, + }); + } + + editor.timeline.insertElement({ + placement: { mode: "auto" }, + element, + }); + + addToRecentStickers({ stickerId: item.id }); + } catch (error) { + console.error("Failed to add sticker:", error); + toast.error("Failed to add sticker to timeline"); + } finally { + setIsAdding(false); + } + }; + + const preview = ( +
+ {hasImageError ? ( + + {displayName} + + ) : ( + {displayName} setHasImageError(true)} + loading="lazy" + unoptimized + /> + )} +
+ ); + + const dragData: TimelineDragData = shapePreset + ? { + id: item.id, + type: "graphic", + name: displayName, + definitionId: shapePreset.definitionId, + params: shapePreset.params ?? {}, + } + : { + id: item.id, + type: "sticker", + name: displayName, + stickerId: item.id, + }; + + return ( +
+ + {isAdding && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx index 075cc25b..da295da3 100644 --- a/apps/web/src/components/editor/panels/assets/views/text.tsx +++ b/apps/web/src/components/editor/panels/assets/views/text.tsx @@ -1,46 +1,46 @@ -import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; -import { useEditor } from "@/hooks/use-editor"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -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: DEFAULTS.text.element, - startTime: currentTime, - }); - - editor.timeline.insertElement({ - element, - placement: { mode: "auto" }, - }); - }; - - return ( - - - Default text -
- } - dragData={{ - id: "temp-text-id", - type: DEFAULTS.text.element.type, - name: DEFAULTS.text.element.name, - content: DEFAULTS.text.element.content, - }} - aspectRatio={1} - onAddToTimeline={handleAddToTimeline} - shouldShowLabel={false} - /> - - ); -} +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; +import { useEditor } from "@/hooks/use-editor"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +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: DEFAULTS.text.element, + startTime: currentTime, + }); + + editor.timeline.insertElement({ + element, + placement: { mode: "auto" }, + }); + }; + + return ( + + + Default text +
+ } + dragData={{ + id: "temp-text-id", + type: DEFAULTS.text.element.type, + name: DEFAULTS.text.element.name, + content: DEFAULTS.text.element.content, + }} + aspectRatio={1} + onAddToTimeline={handleAddToTimeline} + shouldShowLabel={false} + /> + + ); +} diff --git a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx index 4ace25ac..3d96f762 100644 --- a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx @@ -1,59 +1,59 @@ -"use client"; - -import { useEditor } from "@/hooks/use-editor"; -import { findCurrentScene } from "@/lib/scenes"; -import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks"; - -export function BookmarkNoteOverlay() { - const editor = useEditor(); - const currentTime = editor.playback.getCurrentTime(); - const activeProject = editor.project.getActive(); - - if (!activeProject) { - return null; - } - - const activeScene = findCurrentScene({ - scenes: activeProject.scenes, - currentSceneId: activeProject.currentSceneId, - }); - - if (!activeScene) { - return null; - } - - const bookmarks = activeScene.bookmarks; - const activeBookmarks = getBookmarksActiveAtTime({ - bookmarks, - time: currentTime, - }); - const bookmarksWithNotes = activeBookmarks.filter( - (bookmark) => bookmark.note != null && bookmark.note.trim() !== "", - ); - - if (bookmarksWithNotes.length === 0) { - return null; - } - - return ( -
- {bookmarksWithNotes.map((bookmark) => ( -
- {bookmark.note} -
- ))} -
- ); -} +"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import { findCurrentScene } from "@/lib/scenes"; +import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks"; + +export function BookmarkNoteOverlay() { + const editor = useEditor(); + const currentTime = editor.playback.getCurrentTime(); + const activeProject = editor.project.getActive(); + + if (!activeProject) { + return null; + } + + const activeScene = findCurrentScene({ + scenes: activeProject.scenes, + currentSceneId: activeProject.currentSceneId, + }); + + if (!activeScene) { + return null; + } + + const bookmarks = activeScene.bookmarks; + const activeBookmarks = getBookmarksActiveAtTime({ + bookmarks, + time: currentTime, + }); + const bookmarksWithNotes = activeBookmarks.filter( + (bookmark) => bookmark.note != null && bookmark.note.trim() !== "", + ); + + if (bookmarksWithNotes.length === 0) { + return null; + } + + return ( +
+ {bookmarksWithNotes.map((bookmark) => ( +
+ {bookmark.note} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx index 13226378..677f35db 100644 --- a/apps/web/src/components/editor/panels/preview/context-menu.tsx +++ b/apps/web/src/components/editor/panels/preview/context-menu.tsx @@ -1,73 +1,73 @@ -"use client"; - -import { - ContextMenuCheckboxItem, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, -} from "@/components/ui/context-menu"; -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import { useEditor } from "@/hooks/use-editor"; -import { usePreviewStore } from "@/stores/preview-store"; -import { toast } from "sonner"; - -export function PreviewContextMenu({ - onToggleFullscreen, - containerRef, -}: { - onToggleFullscreen: () => void; - containerRef: React.RefObject; -}) { - const editor = useEditor(); - const viewport = usePreviewViewport(); - const { overlays, setOverlayVisibility } = usePreviewStore(); - - const handleCopySnapshot = async () => { - const result = await editor.renderer.copySnapshot(); - - if (!result.success) { - toast.error("Failed to copy snapshot", { - description: result.error ?? "Please try again", - }); - return; - } - }; - - const handleSaveSnapshot = async () => { - const result = await editor.renderer.saveSnapshot(); - - if (!result.success) { - toast.error("Failed to save snapshot", { - description: result.error ?? "Please try again", - }); - return; - } - }; - - return ( - - - Fit to screen - - - - Full screen - - - Save snapshot - - - Copy snapshot - - - - setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked }) - } - > - Show bookmark notes - - - ); -} +"use client"; + +import { + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, +} from "@/components/ui/context-menu"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import { useEditor } from "@/hooks/use-editor"; +import { usePreviewStore } from "@/stores/preview-store"; +import { toast } from "sonner"; + +export function PreviewContextMenu({ + onToggleFullscreen, + containerRef, +}: { + onToggleFullscreen: () => void; + containerRef: React.RefObject; +}) { + const editor = useEditor(); + const viewport = usePreviewViewport(); + const { overlays, setOverlayVisibility } = usePreviewStore(); + + const handleCopySnapshot = async () => { + const result = await editor.renderer.copySnapshot(); + + if (!result.success) { + toast.error("Failed to copy snapshot", { + description: result.error ?? "Please try again", + }); + return; + } + }; + + const handleSaveSnapshot = async () => { + const result = await editor.renderer.saveSnapshot(); + + if (!result.success) { + toast.error("Failed to save snapshot", { + description: result.error ?? "Please try again", + }); + return; + } + }; + + return ( + + + Fit to screen + + + + Full screen + + + Save snapshot + + + Copy snapshot + + + + setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked }) + } + > + Show bookmark notes + + + ); +} diff --git a/apps/web/src/components/editor/panels/preview/guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx index ad7a621f..4270fcb3 100644 --- a/apps/web/src/components/editor/panels/preview/guide-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx @@ -1,32 +1,32 @@ -"use client"; - -import { getGuideById } from "@/lib/guides"; -import { usePreviewStore } from "@/stores/preview-store"; -import { usePreviewViewport } from "./preview-viewport"; - -export function GuideOverlay() { - const activeGuide = usePreviewStore((state) => state.activeGuide); - const viewport = usePreviewViewport(); - const guide = getGuideById(activeGuide); - - if (!guide) { - return null; - } - - return ( -
- {guide.renderOverlay({ - width: viewport.sceneWidth, - height: viewport.sceneHeight, - })} -
- ); -} +"use client"; + +import { getGuideById } from "@/lib/guides"; +import { usePreviewStore } from "@/stores/preview-store"; +import { usePreviewViewport } from "./preview-viewport"; + +export function GuideOverlay() { + const activeGuide = usePreviewStore((state) => state.activeGuide); + const viewport = usePreviewViewport(); + const guide = getGuideById(activeGuide); + + if (!guide) { + return null; + } + + return ( +
+ {guide.renderOverlay({ + width: viewport.sceneWidth, + height: viewport.sceneHeight, + })} +
+ ); +} diff --git a/apps/web/src/components/editor/panels/preview/guide-popover.tsx b/apps/web/src/components/editor/panels/preview/guide-popover.tsx index e6d3df51..bcac8075 100644 --- a/apps/web/src/components/editor/panels/preview/guide-popover.tsx +++ b/apps/web/src/components/editor/panels/preview/guide-popover.tsx @@ -1,100 +1,100 @@ -"use client"; - -import { AnimatePresence, motion } from "motion/react"; -import { GUIDE_REGISTRY, getGuideById } from "@/lib/guides"; -import { usePreviewStore } from "@/stores/preview-store"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Label } from "@/components/ui/label"; -import { Separator } from "@/components/ui/separator"; -import { cn } from "@/utils/ui"; - -export function GridPopover({ children }: { children: React.ReactNode }) { - const activeGuide = usePreviewStore((state) => state.activeGuide); - const toggleGuide = usePreviewStore((state) => state.toggleGuide); - const activeGuideDef = getGuideById(activeGuide); - const options = activeGuideDef?.renderOptions?.(); - - return ( - - {children} - -
- -
- {GUIDE_REGISTRY.map((guide) => ( - toggleGuide(guide.id)} - /> - ))} -
-
- - {options && ( - - -
{options}
-
- )} -
-
-
- ); -} - -function GridItem({ - label, - preview, - isSelected, - onClick, -}: { - label: string; - preview: React.ReactNode; - isSelected?: boolean; - onClick: () => void; -}) { - return ( - - ); -} +"use client"; + +import { AnimatePresence, motion } from "motion/react"; +import { GUIDE_REGISTRY, getGuideById } from "@/lib/guides"; +import { usePreviewStore } from "@/stores/preview-store"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/utils/ui"; + +export function GridPopover({ children }: { children: React.ReactNode }) { + const activeGuide = usePreviewStore((state) => state.activeGuide); + const toggleGuide = usePreviewStore((state) => state.toggleGuide); + const activeGuideDef = getGuideById(activeGuide); + const options = activeGuideDef?.renderOptions?.(); + + return ( + + {children} + +
+ +
+ {GUIDE_REGISTRY.map((guide) => ( + toggleGuide(guide.id)} + /> + ))} +
+
+ + {options && ( + + +
{options}
+
+ )} +
+
+
+ ); +} + +function GridItem({ + label, + preview, + isSelected, + onClick, +}: { + label: string; + preview: React.ReactNode; + isSelected?: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx index 44b8a7f1..d4de3332 100644 --- a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx +++ b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx @@ -1,332 +1,332 @@ -"use client"; - -import { cn } from "@/utils/ui"; -import { HugeiconsIcon } from "@hugeicons/react"; -import type { IconSvgElement } from "@hugeicons/react"; - -export const HANDLE_SIZE = 10; -export const HANDLE_HIT_AREA_SIZE = 18; -export const ICON_HANDLE_RADIUS = 10; -export const EDGE_HANDLE_THIN_SIZE = 6; -export const EDGE_HANDLE_THICK_SIZE = 14; -export const LINE_HIT_AREA_SIZE = 48; - -export function getResizeCursor({ angleDeg }: { angleDeg: number }): string { - const normalized = ((angleDeg % 180) + 180) % 180; - if (normalized < 22.5 || normalized >= 157.5) return "cursor-ew-resize"; - if (normalized < 67.5) return "cursor-nwse-resize"; - if (normalized < 112.5) return "cursor-ns-resize"; - return "cursor-nesw-resize"; -} - -export function HandleButton({ - screen, - cursor, - hitAreaSize, - className, - onPointerDown, - onPointerMove, - onPointerUp, - children, -}: { - screen: { x: number; y: number }; - cursor?: string; - hitAreaSize: number; - className?: string; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; - children: React.ReactNode; -}) { - return ( - - ); -} - -export function CornerHandle({ - cursor, - screen, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - cursor?: string; - screen: { x: number; y: number }; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - return ( - -
- - ); -} - -export function EdgeHandle({ - edge, - screen, - rotation, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - edge: "right" | "left" | "bottom"; - screen: { x: number; y: number }; - rotation: number; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - const isHorizontalEdge = edge === "right" || edge === "left"; - const width = isHorizontalEdge - ? EDGE_HANDLE_THIN_SIZE - : EDGE_HANDLE_THICK_SIZE; - const height = isHorizontalEdge - ? EDGE_HANDLE_THICK_SIZE - : EDGE_HANDLE_THIN_SIZE; - const cursor = getResizeCursor({ - angleDeg: isHorizontalEdge ? rotation : rotation + 90, - }); - - return ( - -
- - ); -} - -export function IconHandle({ - icon, - screen, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - icon: IconSvgElement; - screen: { x: number; y: number }; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - return ( - - - - ); -} - -export function BoundingBoxOutline({ - center, - outlineWidth, - outlineHeight, - rotation, - cursor, - dashed = false, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - center: { x: number; y: number }; - outlineWidth: number; - outlineHeight: number; - rotation: number; - cursor?: string; - dashed?: boolean; - onPointerDown?: (event: React.PointerEvent) => void; - onPointerMove?: (event: React.PointerEvent) => void; - onPointerUp?: (event: React.PointerEvent) => void; -}) { - return ( - - ); -} - -export function ShapeOutline({ - center, - outlineWidth, - outlineHeight, - rotation, - pathData, - cursor, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - center: { x: number; y: number }; - outlineWidth: number; - outlineHeight: number; - rotation: number; - pathData: string; - cursor?: string; - onPointerDown?: (event: React.PointerEvent) => void; - onPointerMove?: (event: React.PointerEvent) => void; - onPointerUp?: (event: React.PointerEvent) => void; -}) { - return ( - - ); -} - -export function LineOverlay({ - start, - end, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - start: { x: number; y: number }; - end: { x: number; y: number }; - onPointerDown?: (event: React.PointerEvent) => void; - onPointerMove?: (event: React.PointerEvent) => void; - onPointerUp?: (event: React.PointerEvent) => void; -}) { - const dx = end.x - start.x; - const dy = end.y - start.y; - const length = Math.sqrt(dx * dx + dy * dy); - const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI; - const cx = (start.x + end.x) / 2; - const cy = (start.y + end.y) / 2; - - const sharedStyle = { - left: cx - length / 2, - width: length, - transform: `rotate(${angleDeg}deg)`, - transformOrigin: "center center", - }; - - return ( - <> - {onPointerDown && ( -
- )} -
- - ); -} +"use client"; + +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import type { IconSvgElement } from "@hugeicons/react"; + +export const HANDLE_SIZE = 10; +export const HANDLE_HIT_AREA_SIZE = 18; +export const ICON_HANDLE_RADIUS = 10; +export const EDGE_HANDLE_THIN_SIZE = 6; +export const EDGE_HANDLE_THICK_SIZE = 14; +export const LINE_HIT_AREA_SIZE = 48; + +export function getResizeCursor({ angleDeg }: { angleDeg: number }): string { + const normalized = ((angleDeg % 180) + 180) % 180; + if (normalized < 22.5 || normalized >= 157.5) return "cursor-ew-resize"; + if (normalized < 67.5) return "cursor-nwse-resize"; + if (normalized < 112.5) return "cursor-ns-resize"; + return "cursor-nesw-resize"; +} + +export function HandleButton({ + screen, + cursor, + hitAreaSize, + className, + onPointerDown, + onPointerMove, + onPointerUp, + children, +}: { + screen: { x: number; y: number }; + cursor?: string; + hitAreaSize: number; + className?: string; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +export function CornerHandle({ + cursor, + screen, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + cursor?: string; + screen: { x: number; y: number }; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; +}) { + return ( + +
+ + ); +} + +export function EdgeHandle({ + edge, + screen, + rotation, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + edge: "right" | "left" | "bottom"; + screen: { x: number; y: number }; + rotation: number; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; +}) { + const isHorizontalEdge = edge === "right" || edge === "left"; + const width = isHorizontalEdge + ? EDGE_HANDLE_THIN_SIZE + : EDGE_HANDLE_THICK_SIZE; + const height = isHorizontalEdge + ? EDGE_HANDLE_THICK_SIZE + : EDGE_HANDLE_THIN_SIZE; + const cursor = getResizeCursor({ + angleDeg: isHorizontalEdge ? rotation : rotation + 90, + }); + + return ( + +
+ + ); +} + +export function IconHandle({ + icon, + screen, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + icon: IconSvgElement; + screen: { x: number; y: number }; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; +}) { + return ( + + + + ); +} + +export function BoundingBoxOutline({ + center, + outlineWidth, + outlineHeight, + rotation, + cursor, + dashed = false, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + center: { x: number; y: number }; + outlineWidth: number; + outlineHeight: number; + rotation: number; + cursor?: string; + dashed?: boolean; + onPointerDown?: (event: React.PointerEvent) => void; + onPointerMove?: (event: React.PointerEvent) => void; + onPointerUp?: (event: React.PointerEvent) => void; +}) { + return ( + + ); +} + +export function ShapeOutline({ + center, + outlineWidth, + outlineHeight, + rotation, + pathData, + cursor, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + center: { x: number; y: number }; + outlineWidth: number; + outlineHeight: number; + rotation: number; + pathData: string; + cursor?: string; + onPointerDown?: (event: React.PointerEvent) => void; + onPointerMove?: (event: React.PointerEvent) => void; + onPointerUp?: (event: React.PointerEvent) => void; +}) { + return ( + + ); +} + +export function LineOverlay({ + start, + end, + onPointerDown, + onPointerMove, + onPointerUp, +}: { + start: { x: number; y: number }; + end: { x: number; y: number }; + onPointerDown?: (event: React.PointerEvent) => void; + onPointerMove?: (event: React.PointerEvent) => void; + onPointerUp?: (event: React.PointerEvent) => void; +}) { + const dx = end.x - start.x; + const dy = end.y - start.y; + const length = Math.sqrt(dx * dx + dy * dy); + const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI; + const cx = (start.x + end.x) / 2; + const cy = (start.y + end.y) / 2; + + const sharedStyle = { + left: cx - length / 2, + width: length, + transform: `rotate(${angleDeg}deg)`, + transformOrigin: "center center", + }; + + return ( + <> + {onPointerDown && ( +
+ )} +
+ + ); +} diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index c625b9bf..8cfe7f49 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -9,7 +9,6 @@ import { useFullscreen } from "@/hooks/use-fullscreen"; 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"; import { PreviewInteractionOverlay } from "./preview-interaction-overlay"; import { BookmarkNoteOverlay } from "./bookmark-note-overlay"; import { GuideOverlay } from "./guide-overlay"; @@ -134,12 +133,7 @@ function PreviewCanvas({ const render = useCallback(() => { if (canvasRef.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 renderTime = editor.playback.getCurrentTime(); const frame = Math.floor(renderTime * renderer.fps); if ( diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx index 65c11726..515cb91a 100644 --- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx @@ -1,117 +1,117 @@ -import { useState } from "react"; -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import { usePreviewInteraction } from "@/hooks/use-preview-interaction"; -import type { SnapLine } from "@/lib/preview/preview-snap"; -import { TransformHandles } from "./transform-handles"; -import { MaskHandles } from "./mask-handles"; -import { SnapGuides } from "./snap-guides"; -import { TextEditOverlay } from "./text-edit-overlay"; -import { usePropertiesStore } from "../properties/stores/properties-store"; -import { useEditor } from "@/hooks/use-editor"; - -export function PreviewInteractionOverlay() { - const [snapLines, setSnapLines] = useState([]); - const editor = useEditor(); - const viewport = usePreviewViewport(); - const selectedElements = useEditor((e) => e.selection.getSelectedElements()); - const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType); - - const selectedRef = - selectedElements.length === 1 ? selectedElements[0] : null; - const activeTrack = selectedRef - ? editor.timeline.getTrackById({ trackId: selectedRef.trackId }) - : null; - const activeElement = - activeTrack?.elements.find( - (element) => element.id === selectedRef?.elementId, - ) ?? null; - const isMaskMode = activeElement - ? activeTabPerType[activeElement.type] === "masks" - : false; - - const { - onPointerDown, - onPointerMove, - onPointerUp, - onDoubleClick, - editingText, - commitTextEdit, - } = usePreviewInteraction({ - onSnapLinesChange: setSnapLines, - isMaskMode, - }); - - const handlePointerDown = (event: React.PointerEvent) => { - if (viewport.handlePanPointerDown({ event })) { - return; - } - - onPointerDown(event); - }; - - const handlePointerMove = (event: React.PointerEvent) => { - if (viewport.handlePanPointerMove({ event })) { - return; - } - - onPointerMove(event); - }; - - const handlePointerUp = (event: React.PointerEvent) => { - if (viewport.handlePanPointerUp({ event })) { - return; - } - - onPointerUp(event); - }; - - const handleContextMenu = (e: React.MouseEvent) => { - const x = e.nativeEvent.offsetX; - const y = e.nativeEvent.offsetY; - const outsideCanvas = - x < viewport.sceneLeft || - x > viewport.sceneLeft + viewport.sceneWidth || - y < viewport.sceneTop || - y > viewport.sceneTop + viewport.sceneHeight; - if (outsideCanvas) { - e.stopPropagation(); - } - }; - - return ( -
-
e.preventDefault()} - onContextMenu={handleContextMenu} - /> - {editingText ? ( - - ) : isMaskMode ? ( - - ) : ( - - )} - -
- ); -} +import { useState } from "react"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import { usePreviewInteraction } from "@/hooks/use-preview-interaction"; +import type { SnapLine } from "@/lib/preview/preview-snap"; +import { TransformHandles } from "./transform-handles"; +import { MaskHandles } from "./mask-handles"; +import { SnapGuides } from "./snap-guides"; +import { TextEditOverlay } from "./text-edit-overlay"; +import { usePropertiesStore } from "../properties/stores/properties-store"; +import { useEditor } from "@/hooks/use-editor"; + +export function PreviewInteractionOverlay() { + const [snapLines, setSnapLines] = useState([]); + const editor = useEditor(); + const viewport = usePreviewViewport(); + const selectedElements = useEditor((e) => e.selection.getSelectedElements()); + const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType); + + const selectedRef = + selectedElements.length === 1 ? selectedElements[0] : null; + const activeTrack = selectedRef + ? editor.timeline.getTrackById({ trackId: selectedRef.trackId }) + : null; + const activeElement = + activeTrack?.elements.find( + (element) => element.id === selectedRef?.elementId, + ) ?? null; + const isMaskMode = activeElement + ? activeTabPerType[activeElement.type] === "masks" + : false; + + const { + onPointerDown, + onPointerMove, + onPointerUp, + onDoubleClick, + editingText, + commitTextEdit, + } = usePreviewInteraction({ + onSnapLinesChange: setSnapLines, + isMaskMode, + }); + + const handlePointerDown = (event: React.PointerEvent) => { + if (viewport.handlePanPointerDown({ event })) { + return; + } + + onPointerDown(event); + }; + + const handlePointerMove = (event: React.PointerEvent) => { + if (viewport.handlePanPointerMove({ event })) { + return; + } + + onPointerMove(event); + }; + + const handlePointerUp = (event: React.PointerEvent) => { + if (viewport.handlePanPointerUp({ event })) { + return; + } + + onPointerUp(event); + }; + + const handleContextMenu = (e: React.MouseEvent) => { + const x = e.nativeEvent.offsetX; + const y = e.nativeEvent.offsetY; + const outsideCanvas = + x < viewport.sceneLeft || + x > viewport.sceneLeft + viewport.sceneWidth || + y < viewport.sceneTop || + y > viewport.sceneTop + viewport.sceneHeight; + if (outsideCanvas) { + e.stopPropagation(); + } + }; + + return ( +
+
e.preventDefault()} + onContextMenu={handleContextMenu} + /> + {editingText ? ( + + ) : isMaskMode ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx index e38183af..8fd38768 100644 --- a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx +++ b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx @@ -1,576 +1,576 @@ -"use client"; - -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { - canvasToOverlay, - getDisplayScale, - positionToOverlay, - screenPixelsToLogicalThreshold, - screenToCanvas, -} from "@/lib/preview/preview-coords"; -import { clamp, isNearlyEqual } from "@/utils/math"; -import { PREVIEW_ZOOM } from "@/constants/editor-constants"; - -const MIDDLE_MOUSE_BUTTON = 1; -const IS_AT_FIT_EPSILON = 0.001; -const IS_AT_ACTUAL_SIZE_EPSILON = 0.01; - -interface PreviewViewportContextValue { - canPan: boolean; - isAtFit: boolean; - isAtActualSize: boolean; - isPanning: boolean; - sceneHeight: number; - sceneLeft: number; - sceneTop: number; - sceneWidth: number; - zoomPercent: number; - canvasToOverlay: ({ - canvasX, - canvasY, - }: { - canvasX: number; - canvasY: number; - }) => { x: number; y: number }; - fitToScreen: () => void; - getDisplayScale: () => { x: number; y: number }; - handlePanPointerDown: ({ event }: { event: React.PointerEvent }) => boolean; - handlePanPointerMove: ({ event }: { event: React.PointerEvent }) => boolean; - handlePanPointerUp: ({ event }: { event: React.PointerEvent }) => boolean; - positionToOverlay: ({ - positionX, - positionY, - }: { - positionX: number; - positionY: number; - }) => { x: number; y: number }; - panByScreenDelta: ({ - deltaX, - deltaY, - }: { - deltaX: number; - deltaY: number; - }) => void; - resetPan: () => void; - scaleZoom: ({ factor }: { factor: number }) => void; - screenPixelsToLogicalThreshold: ({ - screenPixels, - }: { - screenPixels: number; - }) => { x: number; y: number }; - screenToCanvas: ({ - clientX, - clientY, - }: { - clientX: number; - clientY: number; - }) => { x: number; y: number } | null; - setActualSize: () => void; - setViewportPercent: ({ percent }: { percent: number }) => void; - zoomIn: () => void; - zoomOut: () => void; -} - -const PreviewViewportContext = - createContext(null); - -interface PreviewViewportProviderProps { - children: React.ReactNode; - value: PreviewViewportContextValue; -} - -interface PreviewViewportStateOptions { - canvasHeight: number; - canvasWidth: number; - viewportHeight: number; - viewportRef: React.RefObject; - viewportWidth: number; -} - -interface PanSession { - centerX: number; - centerY: number; - clientX: number; - clientY: number; - pointerId: number; -} - -function getClampedCenterAxis({ - axisSize, - center, - scale, - viewportSize, -}: { - axisSize: number; - center: number; - scale: number; - viewportSize: number; -}): number { - if (axisSize <= 0 || scale <= 0 || viewportSize <= 0) { - return axisSize / 2; - } - - const visibleHalfSpan = viewportSize / (2 * scale); - if (visibleHalfSpan >= axisSize / 2) { - return axisSize / 2; - } - - return clamp({ - value: center, - min: visibleHalfSpan, - max: axisSize - visibleHalfSpan, - }); -} - -function clampViewportCenter({ - canvasHeight, - canvasWidth, - centerX, - centerY, - scale, - viewportHeight, - viewportWidth, -}: { - canvasHeight: number; - canvasWidth: number; - centerX: number; - centerY: number; - scale: number; - viewportHeight: number; - viewportWidth: number; -}): { x: number; y: number } { - return { - x: getClampedCenterAxis({ - center: centerX, - axisSize: canvasWidth, - scale, - viewportSize: viewportWidth, - }), - y: getClampedCenterAxis({ - center: centerY, - axisSize: canvasHeight, - scale, - viewportSize: viewportHeight, - }), - }; -} - -function getFitScale({ - canvasHeight, - canvasWidth, - viewportHeight, - viewportWidth, -}: { - canvasHeight: number; - canvasWidth: number; - viewportHeight: number; - viewportWidth: number; -}): number { - if ( - canvasHeight <= 0 || - canvasWidth <= 0 || - viewportHeight <= 0 || - viewportWidth <= 0 - ) { - return 1; - } - - return Math.min(viewportWidth / canvasWidth, viewportHeight / canvasHeight); -} - -function getClampedZoom({ zoom }: { zoom: number }): number { - return clamp({ - value: zoom, - min: PREVIEW_ZOOM.min, - max: PREVIEW_ZOOM.max, - }); -} - -export function PreviewViewportProvider({ - children, - value, -}: PreviewViewportProviderProps) { - return ( - - {children} - - ); -} - -export function usePreviewViewportState({ - canvasHeight, - canvasWidth, - viewportHeight, - viewportRef, - viewportWidth, -}: PreviewViewportStateOptions): PreviewViewportContextValue { - const [zoom, setZoomState] = useState(1); - const [center, setCenter] = useState(() => ({ - x: canvasWidth / 2, - y: canvasHeight / 2, - })); - const [isPanning, setIsPanning] = useState(false); - const panSessionRef = useRef(null); - const centerRef = useRef(center); - centerRef.current = center; - - const fitScale = useMemo( - () => - getFitScale({ - canvasHeight, - canvasWidth, - viewportHeight, - viewportWidth, - }), - [canvasHeight, canvasWidth, viewportHeight, viewportWidth], - ); - const viewportScale = fitScale * zoom; - const geometry = useMemo( - () => ({ - canvasHeight, - canvasWidth, - centerX: center.x, - centerY: center.y, - scale: viewportScale, - viewportHeight, - viewportWidth, - }), - [ - canvasHeight, - canvasWidth, - center.x, - center.y, - viewportHeight, - viewportScale, - viewportWidth, - ], - ); - - const scaleZoom = useCallback(({ factor }: { factor: number }) => { - setZoomState((previousZoom) => - getClampedZoom({ - zoom: previousZoom * factor, - }), - ); - }, []); - - const panByScreenDelta = useCallback( - ({ deltaX, deltaY }: { deltaX: number; deltaY: number }) => { - if (zoom <= 1 || (deltaX === 0 && deltaY === 0)) { - return; - } - - setCenter((previousCenter) => - clampViewportCenter({ - canvasHeight, - canvasWidth, - centerX: previousCenter.x + deltaX / viewportScale, - centerY: previousCenter.y + deltaY / viewportScale, - scale: viewportScale, - viewportHeight, - viewportWidth, - }), - ); - }, - [ - canvasHeight, - canvasWidth, - viewportHeight, - viewportScale, - viewportWidth, - zoom, - ], - ); - - const resetPan = useCallback(() => { - setCenter({ - x: canvasWidth / 2, - y: canvasHeight / 2, - }); - }, [canvasHeight, canvasWidth]); - - const fitToScreen = useCallback(() => { - setZoomState(1); - setCenter({ - x: canvasWidth / 2, - y: canvasHeight / 2, - }); - }, [canvasHeight, canvasWidth]); - - const zoomIn = useCallback(() => { - setZoomState((previousZoom) => - getClampedZoom({ - zoom: previousZoom * PREVIEW_ZOOM.step, - }), - ); - }, []); - - const zoomOut = useCallback(() => { - setZoomState((previousZoom) => - getClampedZoom({ - zoom: previousZoom / PREVIEW_ZOOM.step, - }), - ); - }, []); - - const setActualSize = useCallback(() => { - const actualSizeZoom = fitScale > 0 ? 1 / fitScale : 1; - setZoomState( - getClampedZoom({ - zoom: actualSizeZoom, - }), - ); - }, [fitScale]); - - const setViewportPercent = useCallback( - ({ percent }: { percent: number }) => { - const targetZoom = fitScale > 0 ? percent / 100 / fitScale : 1; - setZoomState(getClampedZoom({ zoom: targetZoom })); - }, - [fitScale], - ); - - const getViewportRect = useCallback( - () => viewportRef.current?.getBoundingClientRect() ?? null, - [viewportRef], - ); - - const screenToCanvasWithViewport = useCallback( - ({ clientX, clientY }: { clientX: number; clientY: number }) => { - const viewportRect = getViewportRect(); - if (!viewportRect) return null; - - return screenToCanvas({ - clientX, - clientY, - geometry, - viewportRect, - }); - }, - [getViewportRect, geometry], - ); - - const canvasToOverlayWithViewport = useCallback( - ({ canvasX, canvasY }: { canvasX: number; canvasY: number }) => - canvasToOverlay({ - canvasX, - canvasY, - geometry, - }), - [geometry], - ); - - const positionToOverlayWithViewport = useCallback( - ({ positionX, positionY }: { positionX: number; positionY: number }) => - positionToOverlay({ - geometry, - positionX, - positionY, - }), - [geometry], - ); - - const getDisplayScaleWithViewport = useCallback( - () => getDisplayScale({ geometry }), - [geometry], - ); - - const getLogicalThresholdForScreenPixels = useCallback( - ({ screenPixels }: { screenPixels: number }) => - screenPixelsToLogicalThreshold({ - geometry, - screenPixels, - }), - [geometry], - ); - - const handlePanPointerDown = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if (event.button !== MIDDLE_MOUSE_BUTTON || zoom <= 1) { - return false; - } - - event.preventDefault(); - event.stopPropagation(); - - panSessionRef.current = { - centerX: centerRef.current.x, - centerY: centerRef.current.y, - clientX: event.clientX, - clientY: event.clientY, - pointerId: event.pointerId, - }; - setIsPanning(true); - (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); - return true; - }, - [zoom], - ); - - const handlePanPointerMove = useCallback( - ({ event }: { event: React.PointerEvent }) => { - const panSession = panSessionRef.current; - if (!panSession) { - return false; - } - - event.preventDefault(); - event.stopPropagation(); - - const deltaX = event.clientX - panSession.clientX; - const deltaY = event.clientY - panSession.clientY; - const nextCenter = clampViewportCenter({ - canvasHeight, - canvasWidth, - centerX: panSession.centerX - deltaX / viewportScale, - centerY: panSession.centerY - deltaY / viewportScale, - scale: viewportScale, - viewportHeight, - viewportWidth, - }); - - setCenter(nextCenter); - return true; - }, - [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth], - ); - - const handlePanPointerUp = useCallback( - ({ event }: { event: React.PointerEvent }) => { - const panSession = panSessionRef.current; - if (!panSession) { - return false; - } - - if ( - (event.currentTarget as HTMLElement).hasPointerCapture( - panSession.pointerId, - ) - ) { - (event.currentTarget as HTMLElement).releasePointerCapture( - panSession.pointerId, - ); - } - - panSessionRef.current = null; - setIsPanning(false); - return true; - }, - [], - ); - - useEffect(() => { - setZoomState(1); - setCenter({ - x: canvasWidth / 2, - y: canvasHeight / 2, - }); - panSessionRef.current = null; - setIsPanning(false); - }, [canvasHeight, canvasWidth]); - - useEffect(() => { - setCenter((previousCenter) => - clampViewportCenter({ - canvasHeight, - canvasWidth, - centerX: previousCenter.x, - centerY: previousCenter.y, - scale: viewportScale, - viewportHeight, - viewportWidth, - }), - ); - }, [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth]); - - const sceneWidth = canvasWidth * viewportScale; - const sceneHeight = canvasHeight * viewportScale; - const sceneLeft = viewportWidth / 2 - center.x * viewportScale; - const sceneTop = viewportHeight / 2 - center.y * viewportScale; - const canPan = zoom > 1; - const zoomPercent = Math.round(viewportScale * 100); - - return useMemo( - () => ({ - canPan, - isAtActualSize: isNearlyEqual({ - leftValue: viewportScale, - rightValue: 1, - epsilon: IS_AT_ACTUAL_SIZE_EPSILON, - }), - isAtFit: isNearlyEqual({ - leftValue: zoom, - rightValue: 1, - epsilon: IS_AT_FIT_EPSILON, - }), - isPanning, - sceneHeight, - sceneLeft, - sceneTop, - sceneWidth, - zoomPercent, - canvasToOverlay: canvasToOverlayWithViewport, - fitToScreen, - getDisplayScale: getDisplayScaleWithViewport, - handlePanPointerDown, - handlePanPointerMove, - handlePanPointerUp, - panByScreenDelta, - positionToOverlay: positionToOverlayWithViewport, - resetPan, - scaleZoom, - screenPixelsToLogicalThreshold: getLogicalThresholdForScreenPixels, - screenToCanvas: screenToCanvasWithViewport, - setActualSize, - setViewportPercent, - zoomIn, - zoomOut, - }), - [ - canPan, - viewportScale, - zoom, - isPanning, - sceneHeight, - sceneLeft, - sceneTop, - sceneWidth, - zoomPercent, - canvasToOverlayWithViewport, - fitToScreen, - getDisplayScaleWithViewport, - handlePanPointerDown, - handlePanPointerMove, - handlePanPointerUp, - panByScreenDelta, - positionToOverlayWithViewport, - resetPan, - scaleZoom, - getLogicalThresholdForScreenPixels, - screenToCanvasWithViewport, - setActualSize, - setViewportPercent, - zoomIn, - zoomOut, - ], - ); -} - -export function usePreviewViewport() { - const context = useContext(PreviewViewportContext); - if (!context) { - throw new Error( - "usePreviewViewport must be used within PreviewViewportProvider", - ); - } - - return context; -} +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + canvasToOverlay, + getDisplayScale, + positionToOverlay, + screenPixelsToLogicalThreshold, + screenToCanvas, +} from "@/lib/preview/preview-coords"; +import { clamp, isNearlyEqual } from "@/utils/math"; +import { PREVIEW_ZOOM } from "@/constants/editor-constants"; + +const MIDDLE_MOUSE_BUTTON = 1; +const IS_AT_FIT_EPSILON = 0.001; +const IS_AT_ACTUAL_SIZE_EPSILON = 0.01; + +interface PreviewViewportContextValue { + canPan: boolean; + isAtFit: boolean; + isAtActualSize: boolean; + isPanning: boolean; + sceneHeight: number; + sceneLeft: number; + sceneTop: number; + sceneWidth: number; + zoomPercent: number; + canvasToOverlay: ({ + canvasX, + canvasY, + }: { + canvasX: number; + canvasY: number; + }) => { x: number; y: number }; + fitToScreen: () => void; + getDisplayScale: () => { x: number; y: number }; + handlePanPointerDown: ({ event }: { event: React.PointerEvent }) => boolean; + handlePanPointerMove: ({ event }: { event: React.PointerEvent }) => boolean; + handlePanPointerUp: ({ event }: { event: React.PointerEvent }) => boolean; + positionToOverlay: ({ + positionX, + positionY, + }: { + positionX: number; + positionY: number; + }) => { x: number; y: number }; + panByScreenDelta: ({ + deltaX, + deltaY, + }: { + deltaX: number; + deltaY: number; + }) => void; + resetPan: () => void; + scaleZoom: ({ factor }: { factor: number }) => void; + screenPixelsToLogicalThreshold: ({ + screenPixels, + }: { + screenPixels: number; + }) => { x: number; y: number }; + screenToCanvas: ({ + clientX, + clientY, + }: { + clientX: number; + clientY: number; + }) => { x: number; y: number } | null; + setActualSize: () => void; + setViewportPercent: ({ percent }: { percent: number }) => void; + zoomIn: () => void; + zoomOut: () => void; +} + +const PreviewViewportContext = + createContext(null); + +interface PreviewViewportProviderProps { + children: React.ReactNode; + value: PreviewViewportContextValue; +} + +interface PreviewViewportStateOptions { + canvasHeight: number; + canvasWidth: number; + viewportHeight: number; + viewportRef: React.RefObject; + viewportWidth: number; +} + +interface PanSession { + centerX: number; + centerY: number; + clientX: number; + clientY: number; + pointerId: number; +} + +function getClampedCenterAxis({ + axisSize, + center, + scale, + viewportSize, +}: { + axisSize: number; + center: number; + scale: number; + viewportSize: number; +}): number { + if (axisSize <= 0 || scale <= 0 || viewportSize <= 0) { + return axisSize / 2; + } + + const visibleHalfSpan = viewportSize / (2 * scale); + if (visibleHalfSpan >= axisSize / 2) { + return axisSize / 2; + } + + return clamp({ + value: center, + min: visibleHalfSpan, + max: axisSize - visibleHalfSpan, + }); +} + +function clampViewportCenter({ + canvasHeight, + canvasWidth, + centerX, + centerY, + scale, + viewportHeight, + viewportWidth, +}: { + canvasHeight: number; + canvasWidth: number; + centerX: number; + centerY: number; + scale: number; + viewportHeight: number; + viewportWidth: number; +}): { x: number; y: number } { + return { + x: getClampedCenterAxis({ + center: centerX, + axisSize: canvasWidth, + scale, + viewportSize: viewportWidth, + }), + y: getClampedCenterAxis({ + center: centerY, + axisSize: canvasHeight, + scale, + viewportSize: viewportHeight, + }), + }; +} + +function getFitScale({ + canvasHeight, + canvasWidth, + viewportHeight, + viewportWidth, +}: { + canvasHeight: number; + canvasWidth: number; + viewportHeight: number; + viewportWidth: number; +}): number { + if ( + canvasHeight <= 0 || + canvasWidth <= 0 || + viewportHeight <= 0 || + viewportWidth <= 0 + ) { + return 1; + } + + return Math.min(viewportWidth / canvasWidth, viewportHeight / canvasHeight); +} + +function getClampedZoom({ zoom }: { zoom: number }): number { + return clamp({ + value: zoom, + min: PREVIEW_ZOOM.min, + max: PREVIEW_ZOOM.max, + }); +} + +export function PreviewViewportProvider({ + children, + value, +}: PreviewViewportProviderProps) { + return ( + + {children} + + ); +} + +export function usePreviewViewportState({ + canvasHeight, + canvasWidth, + viewportHeight, + viewportRef, + viewportWidth, +}: PreviewViewportStateOptions): PreviewViewportContextValue { + const [zoom, setZoomState] = useState(1); + const [center, setCenter] = useState(() => ({ + x: canvasWidth / 2, + y: canvasHeight / 2, + })); + const [isPanning, setIsPanning] = useState(false); + const panSessionRef = useRef(null); + const centerRef = useRef(center); + centerRef.current = center; + + const fitScale = useMemo( + () => + getFitScale({ + canvasHeight, + canvasWidth, + viewportHeight, + viewportWidth, + }), + [canvasHeight, canvasWidth, viewportHeight, viewportWidth], + ); + const viewportScale = fitScale * zoom; + const geometry = useMemo( + () => ({ + canvasHeight, + canvasWidth, + centerX: center.x, + centerY: center.y, + scale: viewportScale, + viewportHeight, + viewportWidth, + }), + [ + canvasHeight, + canvasWidth, + center.x, + center.y, + viewportHeight, + viewportScale, + viewportWidth, + ], + ); + + const scaleZoom = useCallback(({ factor }: { factor: number }) => { + setZoomState((previousZoom) => + getClampedZoom({ + zoom: previousZoom * factor, + }), + ); + }, []); + + const panByScreenDelta = useCallback( + ({ deltaX, deltaY }: { deltaX: number; deltaY: number }) => { + if (zoom <= 1 || (deltaX === 0 && deltaY === 0)) { + return; + } + + setCenter((previousCenter) => + clampViewportCenter({ + canvasHeight, + canvasWidth, + centerX: previousCenter.x + deltaX / viewportScale, + centerY: previousCenter.y + deltaY / viewportScale, + scale: viewportScale, + viewportHeight, + viewportWidth, + }), + ); + }, + [ + canvasHeight, + canvasWidth, + viewportHeight, + viewportScale, + viewportWidth, + zoom, + ], + ); + + const resetPan = useCallback(() => { + setCenter({ + x: canvasWidth / 2, + y: canvasHeight / 2, + }); + }, [canvasHeight, canvasWidth]); + + const fitToScreen = useCallback(() => { + setZoomState(1); + setCenter({ + x: canvasWidth / 2, + y: canvasHeight / 2, + }); + }, [canvasHeight, canvasWidth]); + + const zoomIn = useCallback(() => { + setZoomState((previousZoom) => + getClampedZoom({ + zoom: previousZoom * PREVIEW_ZOOM.step, + }), + ); + }, []); + + const zoomOut = useCallback(() => { + setZoomState((previousZoom) => + getClampedZoom({ + zoom: previousZoom / PREVIEW_ZOOM.step, + }), + ); + }, []); + + const setActualSize = useCallback(() => { + const actualSizeZoom = fitScale > 0 ? 1 / fitScale : 1; + setZoomState( + getClampedZoom({ + zoom: actualSizeZoom, + }), + ); + }, [fitScale]); + + const setViewportPercent = useCallback( + ({ percent }: { percent: number }) => { + const targetZoom = fitScale > 0 ? percent / 100 / fitScale : 1; + setZoomState(getClampedZoom({ zoom: targetZoom })); + }, + [fitScale], + ); + + const getViewportRect = useCallback( + () => viewportRef.current?.getBoundingClientRect() ?? null, + [viewportRef], + ); + + const screenToCanvasWithViewport = useCallback( + ({ clientX, clientY }: { clientX: number; clientY: number }) => { + const viewportRect = getViewportRect(); + if (!viewportRect) return null; + + return screenToCanvas({ + clientX, + clientY, + geometry, + viewportRect, + }); + }, + [getViewportRect, geometry], + ); + + const canvasToOverlayWithViewport = useCallback( + ({ canvasX, canvasY }: { canvasX: number; canvasY: number }) => + canvasToOverlay({ + canvasX, + canvasY, + geometry, + }), + [geometry], + ); + + const positionToOverlayWithViewport = useCallback( + ({ positionX, positionY }: { positionX: number; positionY: number }) => + positionToOverlay({ + geometry, + positionX, + positionY, + }), + [geometry], + ); + + const getDisplayScaleWithViewport = useCallback( + () => getDisplayScale({ geometry }), + [geometry], + ); + + const getLogicalThresholdForScreenPixels = useCallback( + ({ screenPixels }: { screenPixels: number }) => + screenPixelsToLogicalThreshold({ + geometry, + screenPixels, + }), + [geometry], + ); + + const handlePanPointerDown = useCallback( + ({ event }: { event: React.PointerEvent }) => { + if (event.button !== MIDDLE_MOUSE_BUTTON || zoom <= 1) { + return false; + } + + event.preventDefault(); + event.stopPropagation(); + + panSessionRef.current = { + centerX: centerRef.current.x, + centerY: centerRef.current.y, + clientX: event.clientX, + clientY: event.clientY, + pointerId: event.pointerId, + }; + setIsPanning(true); + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); + return true; + }, + [zoom], + ); + + const handlePanPointerMove = useCallback( + ({ event }: { event: React.PointerEvent }) => { + const panSession = panSessionRef.current; + if (!panSession) { + return false; + } + + event.preventDefault(); + event.stopPropagation(); + + const deltaX = event.clientX - panSession.clientX; + const deltaY = event.clientY - panSession.clientY; + const nextCenter = clampViewportCenter({ + canvasHeight, + canvasWidth, + centerX: panSession.centerX - deltaX / viewportScale, + centerY: panSession.centerY - deltaY / viewportScale, + scale: viewportScale, + viewportHeight, + viewportWidth, + }); + + setCenter(nextCenter); + return true; + }, + [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth], + ); + + const handlePanPointerUp = useCallback( + ({ event }: { event: React.PointerEvent }) => { + const panSession = panSessionRef.current; + if (!panSession) { + return false; + } + + if ( + (event.currentTarget as HTMLElement).hasPointerCapture( + panSession.pointerId, + ) + ) { + (event.currentTarget as HTMLElement).releasePointerCapture( + panSession.pointerId, + ); + } + + panSessionRef.current = null; + setIsPanning(false); + return true; + }, + [], + ); + + useEffect(() => { + setZoomState(1); + setCenter({ + x: canvasWidth / 2, + y: canvasHeight / 2, + }); + panSessionRef.current = null; + setIsPanning(false); + }, [canvasHeight, canvasWidth]); + + useEffect(() => { + setCenter((previousCenter) => + clampViewportCenter({ + canvasHeight, + canvasWidth, + centerX: previousCenter.x, + centerY: previousCenter.y, + scale: viewportScale, + viewportHeight, + viewportWidth, + }), + ); + }, [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth]); + + const sceneWidth = canvasWidth * viewportScale; + const sceneHeight = canvasHeight * viewportScale; + const sceneLeft = viewportWidth / 2 - center.x * viewportScale; + const sceneTop = viewportHeight / 2 - center.y * viewportScale; + const canPan = zoom > 1; + const zoomPercent = Math.round(viewportScale * 100); + + return useMemo( + () => ({ + canPan, + isAtActualSize: isNearlyEqual({ + leftValue: viewportScale, + rightValue: 1, + epsilon: IS_AT_ACTUAL_SIZE_EPSILON, + }), + isAtFit: isNearlyEqual({ + leftValue: zoom, + rightValue: 1, + epsilon: IS_AT_FIT_EPSILON, + }), + isPanning, + sceneHeight, + sceneLeft, + sceneTop, + sceneWidth, + zoomPercent, + canvasToOverlay: canvasToOverlayWithViewport, + fitToScreen, + getDisplayScale: getDisplayScaleWithViewport, + handlePanPointerDown, + handlePanPointerMove, + handlePanPointerUp, + panByScreenDelta, + positionToOverlay: positionToOverlayWithViewport, + resetPan, + scaleZoom, + screenPixelsToLogicalThreshold: getLogicalThresholdForScreenPixels, + screenToCanvas: screenToCanvasWithViewport, + setActualSize, + setViewportPercent, + zoomIn, + zoomOut, + }), + [ + canPan, + viewportScale, + zoom, + isPanning, + sceneHeight, + sceneLeft, + sceneTop, + sceneWidth, + zoomPercent, + canvasToOverlayWithViewport, + fitToScreen, + getDisplayScaleWithViewport, + handlePanPointerDown, + handlePanPointerMove, + handlePanPointerUp, + panByScreenDelta, + positionToOverlayWithViewport, + resetPan, + scaleZoom, + getLogicalThresholdForScreenPixels, + screenToCanvasWithViewport, + setActualSize, + setViewportPercent, + zoomIn, + zoomOut, + ], + ); +} + +export function usePreviewViewport() { + const context = useContext(PreviewViewportContext); + if (!context) { + throw new Error( + "usePreviewViewport must be used within PreviewViewportProvider", + ); + } + + return context; +} diff --git a/apps/web/src/components/editor/panels/preview/snap-guides.tsx b/apps/web/src/components/editor/panels/preview/snap-guides.tsx index 744304ea..ced5e921 100644 --- a/apps/web/src/components/editor/panels/preview/snap-guides.tsx +++ b/apps/web/src/components/editor/panels/preview/snap-guides.tsx @@ -1,47 +1,47 @@ -"use client"; - -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import type { SnapLine } from "@/lib/preview/preview-snap"; - -export function SnapGuides({ lines }: { lines: SnapLine[] }) { - const viewport = usePreviewViewport(); - - if (lines.length === 0) { - return null; - } - - const toOverlayX = (logicalX: number) => - viewport.positionToOverlay({ - positionX: logicalX, - positionY: 0, - }).x; - - const toOverlayY = (logicalY: number) => - viewport.positionToOverlay({ - positionX: 0, - positionY: logicalY, - }).y; - - return ( -
- {lines.map((line) => { - if (line.type === "vertical") { - return ( -
- ); - } - return ( -
- ); - })} -
- ); -} +"use client"; + +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import type { SnapLine } from "@/lib/preview/preview-snap"; + +export function SnapGuides({ lines }: { lines: SnapLine[] }) { + const viewport = usePreviewViewport(); + + if (lines.length === 0) { + return null; + } + + const toOverlayX = (logicalX: number) => + viewport.positionToOverlay({ + positionX: logicalX, + positionY: 0, + }).x; + + const toOverlayY = (logicalY: number) => + viewport.positionToOverlay({ + positionX: 0, + positionY: logicalY, + }).y; + + return ( +
+ {lines.map((line) => { + if (line.type === "vertical") { + return ( +
+ ); + } + return ( +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx index d3c61a24..aff77f0f 100644 --- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx @@ -1,212 +1,212 @@ -"use client"; - -import { useCallback, useEffect, useRef } from "react"; -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import { useEditor } from "@/hooks/use-editor"; -import type { TextElement } from "@/lib/timeline"; -import { - FONT_SIZE_SCALE_REFERENCE, -} from "@/constants/text-constants"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { - getMetricAscent, - getMetricDescent, - setCanvasLetterSpacing, -} from "@/lib/text/layout"; - -let cachedCanvas: HTMLCanvasElement | null = null; - -function getMeasurementContext(): CanvasRenderingContext2D | null { - if (!cachedCanvas) cachedCanvas = document.createElement("canvas"); - return cachedCanvas.getContext("2d"); -} - -/** - * Uses fontBoundingBox metrics (which CSS uses for line box layout) rather than - * actualBoundingBox to model where the browser places the baseline within the - * line box, then measures actual glyph bounds to find the visual center. - */ -function measureCSSVisualCenterOffset({ - lines, - fontString, - letterSpacingPx, - lineHeightPx, - displayFontSize, -}: { - lines: string[]; - fontString: string; - letterSpacingPx: number; - lineHeightPx: number; - displayFontSize: number; -}): number { - const ctx = getMeasurementContext(); - if (!ctx) return 0; - - ctx.font = fontString; - ctx.textBaseline = "alphabetic"; - setCanvasLetterSpacing({ ctx, letterSpacingPx }); - - const probe = ctx.measureText("M"); - const fontAscent = probe.fontBoundingBoxAscent ?? displayFontSize * 0.8; - const fontDescent = probe.fontBoundingBoxDescent ?? displayFontSize * 0.2; - const halfLeading = (lineHeightPx - fontAscent - fontDescent) / 2; - - let visualTop = Number.POSITIVE_INFINITY; - let visualBottom = Number.NEGATIVE_INFINITY; - - for (let i = 0; i < lines.length; i++) { - const metrics = ctx.measureText(lines[i] || " "); - const baseline = i * lineHeightPx + halfLeading + fontAscent; - visualTop = Math.min( - visualTop, - baseline - getMetricAscent({ metrics, fallbackFontSize: displayFontSize }), - ); - visualBottom = Math.max( - visualBottom, - baseline + getMetricDescent({ metrics, fallbackFontSize: displayFontSize }), - ); - } - - const cssBlockHeight = lines.length * lineHeightPx; - return (visualTop + visualBottom) / 2 - cssBlockHeight / 2; -} - -export function TextEditOverlay({ - trackId, - elementId, - element, - onCommit, -}: { - trackId: string; - elementId: string; - element: TextElement; - onCommit: () => void; -}) { - const editor = useEditor(); - const viewport = usePreviewViewport(); - const divRef = useRef(null); - - useEffect(() => { - const div = divRef.current; - if (!div) return; - div.focus(); - const range = document.createRange(); - range.selectNodeContents(div); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - }, []); - - const handleInput = useCallback(() => { - const div = divRef.current; - if (!div) return; - const text = div.innerText; - editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: { content: text } }], - }); - }, [editor.timeline, trackId, elementId]); - - const handleKeyDown = useCallback( - ({ event }: { event: React.KeyboardEvent }) => { - const { key } = event; - if (key === "Escape") { - event.preventDefault(); - onCommit(); - return; - } - }, - [onCommit], - ); - - const canvasSize = editor.project.getActive().settings.canvasSize; - - if (!canvasSize) return null; - - const { x: posX, y: posY } = viewport.positionToOverlay({ - positionX: element.transform.position.x, - positionY: element.transform.position.y, - }); - - const { x: displayScaleX } = viewport.getDisplayScale(); - - const displayFontSize = - element.fontSize * - (canvasSize.height / FONT_SIZE_SCALE_REFERENCE) * - displayScaleX; - - const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight; - const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; - const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; - const displayLetterSpacing = (element.letterSpacing ?? 0) * displayScaleX; - const lineHeightPx = displayFontSize * lineHeight; - const lines = (element.content || "").split("\n"); - const fontString = `${fontStyle} ${fontWeight} ${displayFontSize}px "${element.fontFamily}", sans-serif`; - - const cssVisualCenterOffset = measureCSSVisualCenterOffset({ - lines, - fontString, - letterSpacingPx: displayLetterSpacing, - lineHeightPx, - displayFontSize, - }); - - const bg = element.background; - const shouldShowBackground = - bg.enabled && bg.color && bg.color !== "transparent"; - const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize; - const displayPaddingX = shouldShowBackground - ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * - fontSizeRatio * - displayScaleX - : 0; - const displayPaddingY = shouldShowBackground - ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) * - fontSizeRatio * - displayScaleX - : 0; - - return ( -
- {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */} -
handleKeyDown({ event })} - > - {element.content || ""} -
-
- ); -} +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import { useEditor } from "@/hooks/use-editor"; +import type { TextElement } from "@/lib/timeline"; +import { + FONT_SIZE_SCALE_REFERENCE, +} from "@/constants/text-constants"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { + getMetricAscent, + getMetricDescent, + setCanvasLetterSpacing, +} from "@/lib/text/layout"; + +let cachedCanvas: HTMLCanvasElement | null = null; + +function getMeasurementContext(): CanvasRenderingContext2D | null { + if (!cachedCanvas) cachedCanvas = document.createElement("canvas"); + return cachedCanvas.getContext("2d"); +} + +/** + * Uses fontBoundingBox metrics (which CSS uses for line box layout) rather than + * actualBoundingBox to model where the browser places the baseline within the + * line box, then measures actual glyph bounds to find the visual center. + */ +function measureCSSVisualCenterOffset({ + lines, + fontString, + letterSpacingPx, + lineHeightPx, + displayFontSize, +}: { + lines: string[]; + fontString: string; + letterSpacingPx: number; + lineHeightPx: number; + displayFontSize: number; +}): number { + const ctx = getMeasurementContext(); + if (!ctx) return 0; + + ctx.font = fontString; + ctx.textBaseline = "alphabetic"; + setCanvasLetterSpacing({ ctx, letterSpacingPx }); + + const probe = ctx.measureText("M"); + const fontAscent = probe.fontBoundingBoxAscent ?? displayFontSize * 0.8; + const fontDescent = probe.fontBoundingBoxDescent ?? displayFontSize * 0.2; + const halfLeading = (lineHeightPx - fontAscent - fontDescent) / 2; + + let visualTop = Number.POSITIVE_INFINITY; + let visualBottom = Number.NEGATIVE_INFINITY; + + for (let i = 0; i < lines.length; i++) { + const metrics = ctx.measureText(lines[i] || " "); + const baseline = i * lineHeightPx + halfLeading + fontAscent; + visualTop = Math.min( + visualTop, + baseline - getMetricAscent({ metrics, fallbackFontSize: displayFontSize }), + ); + visualBottom = Math.max( + visualBottom, + baseline + getMetricDescent({ metrics, fallbackFontSize: displayFontSize }), + ); + } + + const cssBlockHeight = lines.length * lineHeightPx; + return (visualTop + visualBottom) / 2 - cssBlockHeight / 2; +} + +export function TextEditOverlay({ + trackId, + elementId, + element, + onCommit, +}: { + trackId: string; + elementId: string; + element: TextElement; + onCommit: () => void; +}) { + const editor = useEditor(); + const viewport = usePreviewViewport(); + const divRef = useRef(null); + + useEffect(() => { + const div = divRef.current; + if (!div) return; + div.focus(); + const range = document.createRange(); + range.selectNodeContents(div); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }, []); + + const handleInput = useCallback(() => { + const div = divRef.current; + if (!div) return; + const text = div.innerText; + editor.timeline.previewElements({ + updates: [{ trackId, elementId, updates: { content: text } }], + }); + }, [editor.timeline, trackId, elementId]); + + const handleKeyDown = useCallback( + ({ event }: { event: React.KeyboardEvent }) => { + const { key } = event; + if (key === "Escape") { + event.preventDefault(); + onCommit(); + return; + } + }, + [onCommit], + ); + + const canvasSize = editor.project.getActive().settings.canvasSize; + + if (!canvasSize) return null; + + const { x: posX, y: posY } = viewport.positionToOverlay({ + positionX: element.transform.position.x, + positionY: element.transform.position.y, + }); + + const { x: displayScaleX } = viewport.getDisplayScale(); + + const displayFontSize = + element.fontSize * + (canvasSize.height / FONT_SIZE_SCALE_REFERENCE) * + displayScaleX; + + const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight; + const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; + const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; + const displayLetterSpacing = (element.letterSpacing ?? 0) * displayScaleX; + const lineHeightPx = displayFontSize * lineHeight; + const lines = (element.content || "").split("\n"); + const fontString = `${fontStyle} ${fontWeight} ${displayFontSize}px "${element.fontFamily}", sans-serif`; + + const cssVisualCenterOffset = measureCSSVisualCenterOffset({ + lines, + fontString, + letterSpacingPx: displayLetterSpacing, + lineHeightPx, + displayFontSize, + }); + + const bg = element.background; + const shouldShowBackground = + bg.enabled && bg.color && bg.color !== "transparent"; + const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize; + const displayPaddingX = shouldShowBackground + ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * + fontSizeRatio * + displayScaleX + : 0; + const displayPaddingY = shouldShowBackground + ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) * + fontSizeRatio * + displayScaleX + : 0; + + return ( +
+ {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */} +
handleKeyDown({ event })} + > + {element.content || ""} +
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx index 56cb784a..5c183bbb 100644 --- a/apps/web/src/components/editor/panels/preview/toolbar.tsx +++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx @@ -1,151 +1,151 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { formatTimeCode } from "@/lib/time"; -import { invokeAction } from "@/lib/actions"; -import { EditableTimecode } from "@/components/editable-timecode"; -import { Button } from "@/components/ui/button"; -import { - FullScreenIcon, - GridTableIcon, - PauseIcon, - PlayIcon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { getGuideById } from "@/lib/guides"; -import { Separator } from "@/components/ui/separator"; -import { - Select, - SelectTrigger, - SelectContent, - SelectItem, - SelectSeparator, -} from "@/components/ui/select"; -import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants"; -import { usePreviewViewport } from "./preview-viewport"; -import { GridPopover } from "./guide-popover"; -import { usePreviewStore } from "@/stores/preview-store"; - -export function PreviewToolbar({ - onToggleFullscreen, -}: { - onToggleFullscreen: () => void; -}) { - const activeGuide = usePreviewStore((state) => state.activeGuide); - const activeGuideDefinition = getGuideById(activeGuide); - - return ( -
- - -
- - - - - - -
-
- ); -} - -function TimecodeDisplay() { - const editor = useEditor(); - const totalDuration = useEditor((e) => e.timeline.getTotalDuration()); - const fps = useEditor((e) => e.project.getActive().settings.fps); - const [currentTime, setCurrentTime] = useState(() => - editor.playback.getCurrentTime(), - ); - - useEffect(() => { - const handler = (e: Event) => - setCurrentTime((e as CustomEvent<{ time: number }>).detail.time); - window.addEventListener("playback-update", handler); - window.addEventListener("playback-seek", handler); - return () => { - window.removeEventListener("playback-update", handler); - window.removeEventListener("playback-seek", handler); - }; - }, []); - - return ( -
- editor.playback.seek({ time })} - className="text-center" - /> - / - - {formatTimeCode({ - timeInSeconds: totalDuration, - format: "HH:MM:SS:FF", - fps, - })} - -
- ); -} - -function ZoomSelect() { - const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } = - usePreviewViewport(); - - const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`; - - const onValueChange = (value: string) => { - if (value === "fit") { - fitToScreen(); - } else { - setViewportPercent({ percent: Number(value) }); - } - }; - - return ( - - ); -} - -function PlayPauseButton() { - const isPlaying = useEditor((e) => e.playback.getIsPlaying()); - - return ( - - ); -} +"use client"; + +import { useState, useEffect } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { formatTimeCode } from "@/lib/time"; +import { invokeAction } from "@/lib/actions"; +import { EditableTimecode } from "@/components/editable-timecode"; +import { Button } from "@/components/ui/button"; +import { + FullScreenIcon, + GridTableIcon, + PauseIcon, + PlayIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { getGuideById } from "@/lib/guides"; +import { Separator } from "@/components/ui/separator"; +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectSeparator, +} from "@/components/ui/select"; +import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants"; +import { usePreviewViewport } from "./preview-viewport"; +import { GridPopover } from "./guide-popover"; +import { usePreviewStore } from "@/stores/preview-store"; + +export function PreviewToolbar({ + onToggleFullscreen, +}: { + onToggleFullscreen: () => void; +}) { + const activeGuide = usePreviewStore((state) => state.activeGuide); + const activeGuideDefinition = getGuideById(activeGuide); + + return ( +
+ + +
+ + + + + + +
+
+ ); +} + +function TimecodeDisplay() { + const editor = useEditor(); + const totalDuration = useEditor((e) => e.timeline.getTotalDuration()); + const fps = useEditor((e) => e.project.getActive().settings.fps); + const [currentTime, setCurrentTime] = useState(() => + editor.playback.getCurrentTime(), + ); + + useEffect(() => { + const handler = (e: Event) => + setCurrentTime((e as CustomEvent<{ time: number }>).detail.time); + window.addEventListener("playback-update", handler); + window.addEventListener("playback-seek", handler); + return () => { + window.removeEventListener("playback-update", handler); + window.removeEventListener("playback-seek", handler); + }; + }, []); + + return ( +
+ editor.playback.seek({ time })} + className="text-center" + /> + / + + {formatTimeCode({ + timeInSeconds: totalDuration, + format: "HH:MM:SS:FF", + fps, + })} + +
+ ); +} + +function ZoomSelect() { + const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } = + usePreviewViewport(); + + const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`; + + const onValueChange = (value: string) => { + if (value === "fit") { + fitToScreen(); + } else { + setViewportPercent({ percent: Number(value) }); + } + }; + + return ( + + ); +} + +function PlayPauseButton() { + const isPlaying = useEditor((e) => e.playback.getIsPlaying()); + + return ( + + ); +} diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx index 88332625..141359c8 100644 --- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx +++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx @@ -1,140 +1,140 @@ -"use client"; - -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import { useTransformHandles } from "@/hooks/use-transform-handles"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import { - getCornerPosition, - getEdgeHandlePosition, - getRotationHandlePosition, - type Corner, - type Edge, -} from "@/lib/preview/element-bounds"; -import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction"; -import { - BoundingBoxOutline, - CornerHandle, - EdgeHandle, - IconHandle, - getResizeCursor, -} from "./handle-primitives"; -import { Rotate01Icon } from "@hugeicons/core-free-icons"; - -const CORNERS: Corner[] = [ - "top-left", - "top-right", - "bottom-left", - "bottom-right", -]; -const EDGES: Edge[] = ["right", "left", "bottom"]; - -export function TransformHandles({ - onSnapLinesChange, -}: { - onSnapLinesChange?: OnSnapLinesChange; -}) { - const viewport = usePreviewViewport(); - const { - selectedWithBounds, - hasVisualSelection, - handleCornerPointerDown, - handleEdgePointerDown, - handleRotationPointerDown, - handlePointerMove, - handlePointerUp, - } = useTransformHandles({ onSnapLinesChange }); - - if (!hasVisualSelection || !selectedWithBounds) return null; - - const { bounds, element } = selectedWithBounds; - if (!isVisualElement(element)) return null; - - const displayScale = viewport.getDisplayScale(); - - const toOverlay = ({ - canvasX, - canvasY, - }: { - canvasX: number; - canvasY: number; - }) => - viewport.canvasToOverlay({ - canvasX, - canvasY, - }); - - const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy }); - const outlineWidth = Math.abs(bounds.width) * displayScale.x; - const outlineHeight = Math.abs(bounds.height) * displayScale.y; - - const rotationHandleCanvas = getRotationHandlePosition({ bounds }); - const rotationHandleScreen = toOverlay({ - canvasX: rotationHandleCanvas.x, - canvasY: rotationHandleCanvas.y, - }); - - const onPointerMove = (event: React.PointerEvent) => - handlePointerMove({ event }); - const onPointerUp = () => handlePointerUp(); - - return ( -
- - {CORNERS.map((corner) => { - const cornerPosition = getCornerPosition({ bounds, corner }); - const screen = toOverlay({ - canvasX: cornerPosition.x, - canvasY: cornerPosition.y, - }); - const angleDeg = - Math.atan2(screen.y - center.y, screen.x - center.x) * - (180 / Math.PI); - return ( - - handleCornerPointerDown({ event, corner }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - })} - {EDGES.map((edge) => { - const edgePosition = getEdgeHandlePosition({ bounds, edge }); - const screen = toOverlay({ - canvasX: edgePosition.x, - canvasY: edgePosition.y, - }); - return ( - handleEdgePointerDown({ event, edge })} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - })} - handleRotationPointerDown({ event })} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> -
- ); -} +"use client"; + +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import { useTransformHandles } from "@/hooks/use-transform-handles"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import { + getCornerPosition, + getEdgeHandlePosition, + getRotationHandlePosition, + type Corner, + type Edge, +} from "@/lib/preview/element-bounds"; +import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction"; +import { + BoundingBoxOutline, + CornerHandle, + EdgeHandle, + IconHandle, + getResizeCursor, +} from "./handle-primitives"; +import { Rotate01Icon } from "@hugeicons/core-free-icons"; + +const CORNERS: Corner[] = [ + "top-left", + "top-right", + "bottom-left", + "bottom-right", +]; +const EDGES: Edge[] = ["right", "left", "bottom"]; + +export function TransformHandles({ + onSnapLinesChange, +}: { + onSnapLinesChange?: OnSnapLinesChange; +}) { + const viewport = usePreviewViewport(); + const { + selectedWithBounds, + hasVisualSelection, + handleCornerPointerDown, + handleEdgePointerDown, + handleRotationPointerDown, + handlePointerMove, + handlePointerUp, + } = useTransformHandles({ onSnapLinesChange }); + + if (!hasVisualSelection || !selectedWithBounds) return null; + + const { bounds, element } = selectedWithBounds; + if (!isVisualElement(element)) return null; + + const displayScale = viewport.getDisplayScale(); + + const toOverlay = ({ + canvasX, + canvasY, + }: { + canvasX: number; + canvasY: number; + }) => + viewport.canvasToOverlay({ + canvasX, + canvasY, + }); + + const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy }); + const outlineWidth = Math.abs(bounds.width) * displayScale.x; + const outlineHeight = Math.abs(bounds.height) * displayScale.y; + + const rotationHandleCanvas = getRotationHandlePosition({ bounds }); + const rotationHandleScreen = toOverlay({ + canvasX: rotationHandleCanvas.x, + canvasY: rotationHandleCanvas.y, + }); + + const onPointerMove = (event: React.PointerEvent) => + handlePointerMove({ event }); + const onPointerUp = () => handlePointerUp(); + + return ( +
+ + {CORNERS.map((corner) => { + const cornerPosition = getCornerPosition({ bounds, corner }); + const screen = toOverlay({ + canvasX: cornerPosition.x, + canvasY: cornerPosition.y, + }); + const angleDeg = + Math.atan2(screen.y - center.y, screen.x - center.x) * + (180 / Math.PI); + return ( + + handleCornerPointerDown({ event, corner }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + })} + {EDGES.map((edge) => { + const edgePosition = getEdgeHandlePosition({ bounds, edge }); + const screen = toOverlay({ + canvasX: edgePosition.x, + canvasY: edgePosition.y, + }); + return ( + handleEdgePointerDown({ event, edge })} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + })} + handleRotationPointerDown({ event })} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> +
+ ); +} diff --git a/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx b/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx index b5b8685f..00cea82b 100644 --- a/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx +++ b/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx @@ -1,32 +1,32 @@ -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { KeyframeIcon } from "@hugeicons/core-free-icons"; -import { cn } from "@/utils/ui"; - -export function KeyframeToggle({ - isActive, - isDisabled = false, - title, - onToggle, -}: { - isActive: boolean; - isDisabled?: boolean; - title: string; - onToggle: () => void; -}) { - return ( - - ); -} +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { KeyframeIcon } from "@hugeicons/core-free-icons"; +import { cn } from "@/utils/ui"; + +export function KeyframeToggle({ + isActive, + isDisabled = false, + title, + onToggle, +}: { + isActive: boolean; + isDisabled?: boolean; + title: string; + onToggle: () => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/editor/panels/properties/empty-view.tsx b/apps/web/src/components/editor/panels/properties/empty-view.tsx index c5576dda..547c0883 100644 --- a/apps/web/src/components/editor/panels/properties/empty-view.tsx +++ b/apps/web/src/components/editor/panels/properties/empty-view.tsx @@ -1,20 +1,20 @@ -import { HugeiconsIcon } from "@hugeicons/react"; -import { Settings05Icon } from "@hugeicons/core-free-icons"; - -export function EmptyView() { - return ( -
- -
-

It's empty here

-

- Click an element on the timeline to edit its properties -

-
-
- ); -} +import { HugeiconsIcon } from "@hugeicons/react"; +import { Settings05Icon } from "@hugeicons/core-free-icons"; + +export 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/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts index 46e6cb6d..95f338e1 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts @@ -1,24 +1,24 @@ -import { useEditor } from "@/hooks/use-editor"; -import { getElementLocalTime } from "@/lib/animation"; -import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; - -export function useElementPlayhead({ - startTime, - duration, -}: { - startTime: number; - duration: number; -}) { - const editor = useEditor(); - const playheadTime = editor.playback.getCurrentTime(); - const localTime = getElementLocalTime({ - timelineTime: playheadTime, - elementStartTime: startTime, - elementDuration: duration, - }); - const isPlayheadWithinElementRange = - playheadTime >= startTime - TIME_EPSILON_SECONDS && - playheadTime <= startTime + duration + TIME_EPSILON_SECONDS; - - return { localTime, isPlayheadWithinElementRange }; -} +import { useEditor } from "@/hooks/use-editor"; +import { getElementLocalTime } from "@/lib/animation"; +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; + +export function useElementPlayhead({ + startTime, + duration, +}: { + startTime: number; + duration: number; +}) { + const editor = useEditor(); + const playheadTime = editor.playback.getCurrentTime(); + const localTime = getElementLocalTime({ + timelineTime: playheadTime, + elementStartTime: startTime, + elementDuration: duration, + }); + const isPlayheadWithinElementRange = + playheadTime >= startTime - TIME_EPSILON_SECONDS && + playheadTime <= startTime + duration + TIME_EPSILON_SECONDS; + + return { localTime, isPlayheadWithinElementRange }; +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts index 957ee1c9..a42e422f 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts @@ -1,95 +1,95 @@ -import { useEditor } from "@/hooks/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, - upsertElementKeyframe, -} from "@/lib/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types"; -import type { TimelineElement } from "@/lib/timeline"; - -export function useKeyframedColorProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - resolvedColor, - buildBaseUpdates, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedColor: string; - buildBaseUpdates: ({ value }: { value: string }) => Partial; -}) { - const editor = useEditor(); - - const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const onChange = ({ color }: { color: string }) => { - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertElementKeyframe({ - animations, - propertyPath, - time: localTime, - value: color, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }], - }); - }; - - const onChangeEnd = () => editor.timeline.commitPreview(); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: resolvedColor }, - ], - }); - }; - - return { - isKeyframedAtTime, - hasAnimatedKeyframes, - keyframeIdAtTime, - onChange, - onChangeEnd, - toggleKeyframe, - }; -} +import { useEditor } from "@/hooks/use-editor"; +import { + getKeyframeAtTime, + hasKeyframesForPath, + upsertElementKeyframe, +} from "@/lib/animation"; +import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types"; +import type { TimelineElement } from "@/lib/timeline"; + +export function useKeyframedColorProperty({ + trackId, + elementId, + animations, + propertyPath, + localTime, + isPlayheadWithinElementRange, + resolvedColor, + buildBaseUpdates, +}: { + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedColor: string; + buildBaseUpdates: ({ value }: { value: string }) => Partial; +}) { + const editor = useEditor(); + + const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const onChange = ({ color }: { color: string }) => { + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertElementKeyframe({ + animations, + propertyPath, + time: localTime, + value: color, + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }], + }); + }; + + const onChangeEnd = () => editor.timeline.commitPreview(); + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { trackId, elementId, propertyPath, time: localTime, value: resolvedColor }, + ], + }); + }; + + return { + isKeyframedAtTime, + hasAnimatedKeyframes, + keyframeIdAtTime, + onChange, + onChangeEnd, + toggleKeyframe, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts index 0f0168f8..ddd3d2e4 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts @@ -1,161 +1,161 @@ -import { useEditor } from "@/hooks/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, - upsertElementKeyframe, -} from "@/lib/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types"; -import type { TimelineElement } from "@/lib/timeline"; -import { snapToStep } from "@/utils/math"; -import { usePropertyDraft } from "./use-property-draft"; - -export function useKeyframedNumberProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - displayValue, - parse, - valueAtPlayhead, - step, - buildBaseUpdates, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - displayValue: string; - parse: (input: string) => number | null; - valueAtPlayhead: number; - step?: number; - buildBaseUpdates: ({ value }: { value: number }) => Partial; -}) { - const editor = useEditor(); - const snapValue = (value: number) => - step != null ? snapToStep({ value, step }) : value; - - const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const previewValue = ({ value }: { value: number }) => { - const nextValue = snapValue(value); - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertElementKeyframe({ - animations, - propertyPath, - time: localTime, - value: nextValue, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: buildBaseUpdates({ value: nextValue }), - }, - ], - }); - }; - - const propertyDraft = usePropertyDraft({ - displayValue, - parse: (input) => { - const parsedValue = parse(input); - return parsedValue === null ? null : snapValue(parsedValue); - }, - onPreview: (value) => previewValue({ value }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - keyframeId: keyframeIdAtTime, - }, - ], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: snapValue(valueAtPlayhead), - }, - ], - }); - }; - - const commitValue = ({ value }: { value: number }) => { - const nextValue = snapValue(value); - if (shouldUseAnimatedChannel) { - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: nextValue, - }, - ], - }); - return; - } - - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId, - updates: buildBaseUpdates({ value: nextValue }), - }, - ], - }); - }; - - return { - ...propertyDraft, - hasAnimatedKeyframes, - isKeyframedAtTime, - keyframeIdAtTime, - toggleKeyframe, - commitValue, - }; -} +import { useEditor } from "@/hooks/use-editor"; +import { + getKeyframeAtTime, + hasKeyframesForPath, + upsertElementKeyframe, +} from "@/lib/animation"; +import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types"; +import type { TimelineElement } from "@/lib/timeline"; +import { snapToStep } from "@/utils/math"; +import { usePropertyDraft } from "./use-property-draft"; + +export function useKeyframedNumberProperty({ + trackId, + elementId, + animations, + propertyPath, + localTime, + isPlayheadWithinElementRange, + displayValue, + parse, + valueAtPlayhead, + step, + buildBaseUpdates, +}: { + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; + isPlayheadWithinElementRange: boolean; + displayValue: string; + parse: (input: string) => number | null; + valueAtPlayhead: number; + step?: number; + buildBaseUpdates: ({ value }: { value: number }) => Partial; +}) { + const editor = useEditor(); + const snapValue = (value: number) => + step != null ? snapToStep({ value, step }) : value; + + const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const previewValue = ({ value }: { value: number }) => { + const nextValue = snapValue(value); + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertElementKeyframe({ + animations, + propertyPath, + time: localTime, + value: nextValue, + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value: nextValue }), + }, + ], + }); + }; + + const propertyDraft = usePropertyDraft({ + displayValue, + parse: (input) => { + const parsedValue = parse(input); + return parsedValue === null ? null : snapValue(parsedValue); + }, + onPreview: (value) => previewValue({ value }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + keyframeId: keyframeIdAtTime, + }, + ], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: snapValue(valueAtPlayhead), + }, + ], + }); + }; + + const commitValue = ({ value }: { value: number }) => { + const nextValue = snapValue(value); + if (shouldUseAnimatedChannel) { + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: nextValue, + }, + ], + }); + return; + } + + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value: nextValue }), + }, + ], + }); + }; + + return { + ...propertyDraft, + hasAnimatedKeyframes, + isKeyframedAtTime, + keyframeIdAtTime, + toggleKeyframe, + commitValue, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts index 335e3fb6..92b5d634 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts @@ -1,148 +1,148 @@ -"use client"; - -import { useEditor } from "@/hooks/use-editor"; -import { - buildGraphicParamPath, - getKeyframeAtTime, - getParamDefaultInterpolation, - getParamValueKind, - hasKeyframesForPath, - upsertPathKeyframe, -} from "@/lib/animation"; -import type { - ElementAnimations, -} from "@/lib/animation/types"; -import type { ParamDefinition } from "@/lib/params"; -import type { TimelineElement } from "@/lib/timeline"; - -export interface KeyframedParamPropertyResult { - hasAnimatedKeyframes: boolean; - isKeyframedAtTime: boolean; - keyframeIdAtTime: string | null; - onPreview: (value: number | string | boolean) => void; - onCommit: () => void; - toggleKeyframe: () => void; -} - -export function useKeyframedParamProperty({ - param, - trackId, - elementId, - animations, - localTime, - isPlayheadWithinElementRange, - resolvedValue, - buildBaseUpdates, -}: { - param: ParamDefinition; - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedValue: number | string | boolean; - buildBaseUpdates: ({ - value, - }: { - value: number | string | boolean; - }) => Partial; -}): KeyframedParamPropertyResult { - const editor = useEditor(); - const propertyPath = buildGraphicParamPath({ paramKey: param.key }); - const hasAnimatedKeyframes = hasKeyframesForPath({ - animations, - propertyPath, - }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ - animations, - propertyPath, - time: localTime, - }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => { - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertPathKeyframe({ - animations, - propertyPath, - time: localTime, - value, - valueKind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ - param, - }), - numericRange: - param.type === "number" - ? { min: param.min, max: param.max, step: param.step } - : undefined, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: buildBaseUpdates({ value }), - }, - ], - }); - }; - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - keyframeId: keyframeIdAtTime, - }, - ], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: resolvedValue, - }, - ], - }); - }; - - return { - hasAnimatedKeyframes, - isKeyframedAtTime, - keyframeIdAtTime, - onPreview: previewValue, - onCommit: () => editor.timeline.commitPreview(), - toggleKeyframe, - }; -} +"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import { + buildGraphicParamPath, + getKeyframeAtTime, + getParamDefaultInterpolation, + getParamValueKind, + hasKeyframesForPath, + upsertPathKeyframe, +} from "@/lib/animation"; +import type { + ElementAnimations, +} from "@/lib/animation/types"; +import type { ParamDefinition } from "@/lib/params"; +import type { TimelineElement } from "@/lib/timeline"; + +export interface KeyframedParamPropertyResult { + hasAnimatedKeyframes: boolean; + isKeyframedAtTime: boolean; + keyframeIdAtTime: string | null; + onPreview: (value: number | string | boolean) => void; + onCommit: () => void; + toggleKeyframe: () => void; +} + +export function useKeyframedParamProperty({ + param, + trackId, + elementId, + animations, + localTime, + isPlayheadWithinElementRange, + resolvedValue, + buildBaseUpdates, +}: { + param: ParamDefinition; + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedValue: number | string | boolean; + buildBaseUpdates: ({ + value, + }: { + value: number | string | boolean; + }) => Partial; +}): KeyframedParamPropertyResult { + const editor = useEditor(); + const propertyPath = buildGraphicParamPath({ paramKey: param.key }); + const hasAnimatedKeyframes = hasKeyframesForPath({ + animations, + propertyPath, + }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ + animations, + propertyPath, + time: localTime, + }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => { + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertPathKeyframe({ + animations, + propertyPath, + time: localTime, + value, + valueKind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ + param, + }), + numericRange: + param.type === "number" + ? { min: param.min, max: param.max, step: param.step } + : undefined, + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value }), + }, + ], + }); + }; + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + keyframeId: keyframeIdAtTime, + }, + ], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: resolvedValue, + }, + ], + }); + }; + + return { + hasAnimatedKeyframes, + isKeyframedAtTime, + keyframeIdAtTime, + onPreview: previewValue, + onCommit: () => editor.timeline.commitPreview(), + toggleKeyframe, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts index 188f05cb..a342859c 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts @@ -1,190 +1,190 @@ -import { useEditor } from "@/hooks/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, - upsertElementKeyframe, -} from "@/lib/animation"; -import type { - AnimationPropertyPath, - ElementAnimations, - VectorValue, -} from "@/lib/animation/types"; -import type { TimelineElement } from "@/lib/timeline"; -import { snapToStep } from "@/utils/math"; -import { usePropertyDraft } from "./use-property-draft"; - -export function useKeyframedVectorProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - resolvedValue, - displayX, - displayY, - parseComponent, - step, - buildBaseUpdates, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedValue: VectorValue; - displayX: string; - displayY: string; - parseComponent: (input: string) => number | null; - step?: number; - buildBaseUpdates: ({ - value, - }: { - value: VectorValue; - }) => Partial; -}) { - const editor = useEditor(); - const snapComponentValue = (value: number) => - step != null ? snapToStep({ value, step }) : value; - - const hasAnimatedKeyframes = hasKeyframesForPath({ - animations, - propertyPath, - }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const previewVector = ({ value }: { value: VectorValue }) => { - const nextValue = { - x: snapComponentValue(value.x), - y: snapComponentValue(value.y), - }; - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertElementKeyframe({ - animations, - propertyPath, - time: localTime, - value: nextValue, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: nextValue }) }], - }); - }; - - const x = usePropertyDraft({ - displayValue: displayX, - parse: (input) => { - const parsedValue = parseComponent(input); - return parsedValue === null ? null : snapComponentValue(parsedValue); - }, - onPreview: (xVal) => - previewVector({ value: { x: xVal, y: resolvedValue.y } }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const y = usePropertyDraft({ - displayValue: displayY, - parse: (input) => { - const parsedValue = parseComponent(input); - return parsedValue === null ? null : snapComponentValue(parsedValue); - }, - onPreview: (yVal) => - previewVector({ value: { x: resolvedValue.x, y: yVal } }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) return; - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }, - ], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: resolvedValue, - }, - ], - }); - }; - - const commitX = ({ value }: { value: number }) => { - const vector: VectorValue = { - x: snapComponentValue(value), - y: snapComponentValue(resolvedValue.y), - }; - if (shouldUseAnimatedChannel) { - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: vector }, - ], - }); - return; - } - editor.timeline.updateElements({ - updates: [ - { trackId, elementId, updates: buildBaseUpdates({ value: vector }) }, - ], - }); - }; - - const commitY = ({ value }: { value: number }) => { - const vector: VectorValue = { - x: snapComponentValue(resolvedValue.x), - y: snapComponentValue(value), - }; - if (shouldUseAnimatedChannel) { - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: vector }, - ], - }); - return; - } - editor.timeline.updateElements({ - updates: [ - { trackId, elementId, updates: buildBaseUpdates({ value: vector }) }, - ], - }); - }; - - return { - x, - y, - hasAnimatedKeyframes, - isKeyframedAtTime, - keyframeIdAtTime, - toggleKeyframe, - commitX, - commitY, - }; -} +import { useEditor } from "@/hooks/use-editor"; +import { + getKeyframeAtTime, + hasKeyframesForPath, + upsertElementKeyframe, +} from "@/lib/animation"; +import type { + AnimationPropertyPath, + ElementAnimations, + VectorValue, +} from "@/lib/animation/types"; +import type { TimelineElement } from "@/lib/timeline"; +import { snapToStep } from "@/utils/math"; +import { usePropertyDraft } from "./use-property-draft"; + +export function useKeyframedVectorProperty({ + trackId, + elementId, + animations, + propertyPath, + localTime, + isPlayheadWithinElementRange, + resolvedValue, + displayX, + displayY, + parseComponent, + step, + buildBaseUpdates, +}: { + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedValue: VectorValue; + displayX: string; + displayY: string; + parseComponent: (input: string) => number | null; + step?: number; + buildBaseUpdates: ({ + value, + }: { + value: VectorValue; + }) => Partial; +}) { + const editor = useEditor(); + const snapComponentValue = (value: number) => + step != null ? snapToStep({ value, step }) : value; + + const hasAnimatedKeyframes = hasKeyframesForPath({ + animations, + propertyPath, + }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const previewVector = ({ value }: { value: VectorValue }) => { + const nextValue = { + x: snapComponentValue(value.x), + y: snapComponentValue(value.y), + }; + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertElementKeyframe({ + animations, + propertyPath, + time: localTime, + value: nextValue, + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: nextValue }) }], + }); + }; + + const x = usePropertyDraft({ + displayValue: displayX, + parse: (input) => { + const parsedValue = parseComponent(input); + return parsedValue === null ? null : snapComponentValue(parsedValue); + }, + onPreview: (xVal) => + previewVector({ value: { x: xVal, y: resolvedValue.y } }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const y = usePropertyDraft({ + displayValue: displayY, + parse: (input) => { + const parsedValue = parseComponent(input); + return parsedValue === null ? null : snapComponentValue(parsedValue); + }, + onPreview: (yVal) => + previewVector({ value: { x: resolvedValue.x, y: yVal } }), + onCommit: () => editor.timeline.commitPreview(), + }); + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) return; + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [ + { trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }, + ], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: resolvedValue, + }, + ], + }); + }; + + const commitX = ({ value }: { value: number }) => { + const vector: VectorValue = { + x: snapComponentValue(value), + y: snapComponentValue(resolvedValue.y), + }; + if (shouldUseAnimatedChannel) { + editor.timeline.upsertKeyframes({ + keyframes: [ + { trackId, elementId, propertyPath, time: localTime, value: vector }, + ], + }); + return; + } + editor.timeline.updateElements({ + updates: [ + { trackId, elementId, updates: buildBaseUpdates({ value: vector }) }, + ], + }); + }; + + const commitY = ({ value }: { value: number }) => { + const vector: VectorValue = { + x: snapComponentValue(resolvedValue.x), + y: snapComponentValue(value), + }; + if (shouldUseAnimatedChannel) { + editor.timeline.upsertKeyframes({ + keyframes: [ + { trackId, elementId, propertyPath, time: localTime, value: vector }, + ], + }); + return; + } + editor.timeline.updateElements({ + updates: [ + { trackId, elementId, updates: buildBaseUpdates({ value: vector }) }, + ], + }); + }; + + return { + x, + y, + hasAnimatedKeyframes, + isKeyframedAtTime, + keyframeIdAtTime, + toggleKeyframe, + commitX, + commitY, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts index 395666cb..0e7ae6a7 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts @@ -1,72 +1,72 @@ -import { useReducer, useRef } from "react"; -import { evaluateMathExpression } from "@/utils/math"; - -function looksLikeExpression({ input }: { input: string }): boolean { - const trimmed = input.trim(); - if (!trimmed) return false; - if (/[+*/]/.test(input)) return true; - const minusIndex = trimmed.indexOf("-"); - return minusIndex > 0; -} - -export function usePropertyDraft({ - displayValue: sourceDisplay, - parse, - onPreview, - onCommit, - supportsExpressions = true, -}: { - displayValue: string; - parse: (input: string) => T | null; - onPreview: (value: T) => void; - onCommit: () => void; - supportsExpressions?: boolean; -}) { - const [, forceRender] = useReducer( - (renderVersion: number) => renderVersion + 1, - 0, - ); - const isEditing = useRef(false); - const draft = useRef(""); - - return { - displayValue: isEditing.current ? draft.current : sourceDisplay, - scrubTo: (value: number) => { - const parsed = parse(String(value)); - if (parsed !== null) onPreview(parsed); - }, - commitScrub: onCommit, - onFocus: () => { - isEditing.current = true; - draft.current = sourceDisplay; - forceRender(); - }, - onChange: ( - event: React.ChangeEvent, - ) => { - draft.current = event.target.value; - forceRender(); - - const parsed = parse(event.target.value); - if (parsed !== null) { - onPreview(parsed); - } - }, - onBlur: () => { - if ( - supportsExpressions && - looksLikeExpression({ input: draft.current }) - ) { - const evaluated = evaluateMathExpression({ input: draft.current }); - if (evaluated !== null) { - const parsed = parse(String(evaluated)); - if (parsed !== null) onPreview(parsed); - } - } - onCommit(); - isEditing.current = false; - draft.current = ""; - forceRender(); - }, - }; -} +import { useReducer, useRef } from "react"; +import { evaluateMathExpression } from "@/utils/math"; + +function looksLikeExpression({ input }: { input: string }): boolean { + const trimmed = input.trim(); + if (!trimmed) return false; + if (/[+*/]/.test(input)) return true; + const minusIndex = trimmed.indexOf("-"); + return minusIndex > 0; +} + +export function usePropertyDraft({ + displayValue: sourceDisplay, + parse, + onPreview, + onCommit, + supportsExpressions = true, +}: { + displayValue: string; + parse: (input: string) => T | null; + onPreview: (value: T) => void; + onCommit: () => void; + supportsExpressions?: boolean; +}) { + const [, forceRender] = useReducer( + (renderVersion: number) => renderVersion + 1, + 0, + ); + const isEditing = useRef(false); + const draft = useRef(""); + + return { + displayValue: isEditing.current ? draft.current : sourceDisplay, + scrubTo: (value: number) => { + const parsed = parse(String(value)); + if (parsed !== null) onPreview(parsed); + }, + commitScrub: onCommit, + onFocus: () => { + isEditing.current = true; + draft.current = sourceDisplay; + forceRender(); + }, + onChange: ( + event: React.ChangeEvent, + ) => { + draft.current = event.target.value; + forceRender(); + + const parsed = parse(event.target.value); + if (parsed !== null) { + onPreview(parsed); + } + }, + onBlur: () => { + if ( + supportsExpressions && + looksLikeExpression({ input: draft.current }) + ) { + const evaluated = evaluateMathExpression({ input: draft.current }); + if (evaluated !== null) { + const parsed = parse(String(evaluated)); + if (parsed !== null) onPreview(parsed); + } + } + onCommit(); + isEditing.current = false; + draft.current = ""; + forceRender(); + }, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx index 706faef4..38d0a189 100644 --- a/apps/web/src/components/editor/panels/properties/registry.tsx +++ b/apps/web/src/components/editor/panels/properties/registry.tsx @@ -1,303 +1,303 @@ -import type { ReactNode } from "react"; -import type { - EffectElement, - GraphicElement, - ImageElement, - MaskableElement, - RetimableElement, - StickerElement, - TextElement, - VisualElement, - VideoElement, - AudioElement, - TimelineElement, -} from "@/lib/timeline"; -import type { MediaAsset } from "@/lib/media/types"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - TextFontIcon, - ArrowExpandIcon, - RainDropIcon, - MusicNote03Icon, - MagicWand05Icon, - DashboardSpeed02Icon, -} from "@hugeicons/core-free-icons"; -import { TransformTab } from "./tabs/transform-tab"; -import { BlendingTab } from "./tabs/blending-tab"; -import { AudioTab } from "./tabs/audio-tab"; -import { TextTab } from "./tabs/text-tab"; -import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab"; -import { MasksTab } from "./tabs/masks-tab"; -import { SpeedTab } from "./tabs/speed-tab"; -import { GraphicTab } from "./tabs/graphic-tab"; -import { OcShapesIcon } from "@opencut/ui/icons"; - -export type TabContentProps = { - trackId: string; -}; - -export type PropertiesTabDef = { - id: string; - label: string; - icon: ReactNode; - content: (props: TabContentProps) => ReactNode; -}; - -export type ElementPropertiesConfig = { - defaultTab: string; - tabs: PropertiesTabDef[]; -}; - -function buildTransformTab({ - element, -}: { - element: VisualElement; -}): PropertiesTabDef { - return { - id: "transform", - label: "Transform", - icon: , - content: ({ trackId }) => ( - - ), - }; -} - -function buildBlendingTab({ - element, -}: { - element: VisualElement; -}): PropertiesTabDef { - return { - id: "blending", - label: "Blending", - icon: , - content: ({ trackId }) => ( - - ), - }; -} - -function buildAudioTab({ - element, -}: { - element: AudioElement | VideoElement; -}): PropertiesTabDef { - return { - id: "audio", - label: "Audio", - icon: , - content: ({ trackId }) => , - }; -} - -function buildSpeedTab({ - element, -}: { - element: RetimableElement; -}): PropertiesTabDef { - return { - id: "speed", - label: "Speed", - icon: , - content: ({ trackId }) => , - }; -} - -function buildMasksTab({ - element, -}: { - element: MaskableElement; -}): PropertiesTabDef { - return { - id: "masks", - label: "Masks", - icon: , - content: ({ trackId }) => , - }; -} - -function buildClipEffectsTab({ - element, -}: { - element: VisualElement; -}): PropertiesTabDef { - return { - id: "effects", - label: "Effects", - icon: , - content: ({ trackId }) => ( - - ), - }; -} - -function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef { - return { - id: "text", - label: "Text", - icon: , - content: ({ trackId }) => , - }; -} - -function buildGraphicTab({ - element, -}: { - element: GraphicElement; -}): PropertiesTabDef { - return { - id: "graphic", - label: "Graphic", - icon: , - content: ({ trackId }) => , - }; -} - -function buildStandaloneEffectTab({ - element, -}: { - element: EffectElement; -}): PropertiesTabDef { - return { - id: "effects", - label: "Effects", - icon: , - content: ({ trackId }) => ( - - ), - }; -} - -function getTextConfig({ - element, -}: { - element: TextElement; -}): ElementPropertiesConfig { - return { - defaultTab: "text", - tabs: [ - buildTextTab({ element }), - buildTransformTab({ element }), - buildBlendingTab({ element }), - ], - }; -} - -function getVideoConfig({ - element, - mediaAsset, -}: { - element: VideoElement; - mediaAsset: MediaAsset | undefined; -}): ElementPropertiesConfig { - const showAudioTab = mediaAsset?.hasAudio !== false; - return { - defaultTab: "transform", - tabs: [ - buildTransformTab({ element }), - ...(showAudioTab ? [buildAudioTab({ element })] : []), - buildSpeedTab({ element }), - buildBlendingTab({ element }), - buildMasksTab({ element }), - buildClipEffectsTab({ element }), - ], - }; -} - -function getImageConfig({ - element, -}: { - element: ImageElement; -}): ElementPropertiesConfig { - return { - defaultTab: "transform", - tabs: [ - buildTransformTab({ element }), - buildBlendingTab({ element }), - buildMasksTab({ element }), - buildClipEffectsTab({ element }), - ], - }; -} - -function getStickerConfig({ - element, -}: { - element: StickerElement; -}): ElementPropertiesConfig { - return { - defaultTab: "transform", - tabs: [ - buildTransformTab({ element }), - buildBlendingTab({ element }), - buildClipEffectsTab({ element }), - ], - }; -} - -function getGraphicConfig({ - element, -}: { - element: GraphicElement; -}): ElementPropertiesConfig { - return { - defaultTab: "graphic", - tabs: [ - buildGraphicTab({ element }), - buildTransformTab({ element }), - buildBlendingTab({ element }), - buildMasksTab({ element }), - buildClipEffectsTab({ element }), - ], - }; -} - -function getAudioConfig({ - element, -}: { - element: AudioElement; -}): ElementPropertiesConfig { - return { - defaultTab: "audio", - tabs: [buildAudioTab({ element }), buildSpeedTab({ element })], - }; -} - -function getEffectConfig({ - element, -}: { - element: EffectElement; -}): ElementPropertiesConfig { - return { - defaultTab: "effects", - tabs: [buildStandaloneEffectTab({ element })], - }; -} - -export function getPropertiesConfig({ - element, - mediaAssets, -}: { - element: TimelineElement; - mediaAssets: MediaAsset[]; -}): ElementPropertiesConfig { - switch (element.type) { - case "text": - return getTextConfig({ element }); - case "video": { - const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId); - return getVideoConfig({ element, mediaAsset }); - } - case "image": - return getImageConfig({ element }); - case "sticker": - return getStickerConfig({ element }); - case "graphic": - return getGraphicConfig({ element }); - case "audio": - return getAudioConfig({ element }); - case "effect": - return getEffectConfig({ element }); - } -} +import type { ReactNode } from "react"; +import type { + EffectElement, + GraphicElement, + ImageElement, + MaskableElement, + RetimableElement, + StickerElement, + TextElement, + VisualElement, + VideoElement, + AudioElement, + TimelineElement, +} from "@/lib/timeline"; +import type { MediaAsset } from "@/lib/media/types"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + TextFontIcon, + ArrowExpandIcon, + RainDropIcon, + MusicNote03Icon, + MagicWand05Icon, + DashboardSpeed02Icon, +} from "@hugeicons/core-free-icons"; +import { TransformTab } from "./tabs/transform-tab"; +import { BlendingTab } from "./tabs/blending-tab"; +import { AudioTab } from "./tabs/audio-tab"; +import { TextTab } from "./tabs/text-tab"; +import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab"; +import { MasksTab } from "./tabs/masks-tab"; +import { SpeedTab } from "./tabs/speed-tab"; +import { GraphicTab } from "./tabs/graphic-tab"; +import { OcShapesIcon } from "@opencut/ui/icons"; + +export type TabContentProps = { + trackId: string; +}; + +export type PropertiesTabDef = { + id: string; + label: string; + icon: ReactNode; + content: (props: TabContentProps) => ReactNode; +}; + +export type ElementPropertiesConfig = { + defaultTab: string; + tabs: PropertiesTabDef[]; +}; + +function buildTransformTab({ + element, +}: { + element: VisualElement; +}): PropertiesTabDef { + return { + id: "transform", + label: "Transform", + icon: , + content: ({ trackId }) => ( + + ), + }; +} + +function buildBlendingTab({ + element, +}: { + element: VisualElement; +}): PropertiesTabDef { + return { + id: "blending", + label: "Blending", + icon: , + content: ({ trackId }) => ( + + ), + }; +} + +function buildAudioTab({ + element, +}: { + element: AudioElement | VideoElement; +}): PropertiesTabDef { + return { + id: "audio", + label: "Audio", + icon: , + content: ({ trackId }) => , + }; +} + +function buildSpeedTab({ + element, +}: { + element: RetimableElement; +}): PropertiesTabDef { + return { + id: "speed", + label: "Speed", + icon: , + content: ({ trackId }) => , + }; +} + +function buildMasksTab({ + element, +}: { + element: MaskableElement; +}): PropertiesTabDef { + return { + id: "masks", + label: "Masks", + icon: , + content: ({ trackId }) => , + }; +} + +function buildClipEffectsTab({ + element, +}: { + element: VisualElement; +}): PropertiesTabDef { + return { + id: "effects", + label: "Effects", + icon: , + content: ({ trackId }) => ( + + ), + }; +} + +function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef { + return { + id: "text", + label: "Text", + icon: , + content: ({ trackId }) => , + }; +} + +function buildGraphicTab({ + element, +}: { + element: GraphicElement; +}): PropertiesTabDef { + return { + id: "graphic", + label: "Graphic", + icon: , + content: ({ trackId }) => , + }; +} + +function buildStandaloneEffectTab({ + element, +}: { + element: EffectElement; +}): PropertiesTabDef { + return { + id: "effects", + label: "Effects", + icon: , + content: ({ trackId }) => ( + + ), + }; +} + +function getTextConfig({ + element, +}: { + element: TextElement; +}): ElementPropertiesConfig { + return { + defaultTab: "text", + tabs: [ + buildTextTab({ element }), + buildTransformTab({ element }), + buildBlendingTab({ element }), + ], + }; +} + +function getVideoConfig({ + element, + mediaAsset, +}: { + element: VideoElement; + mediaAsset: MediaAsset | undefined; +}): ElementPropertiesConfig { + const showAudioTab = mediaAsset?.hasAudio !== false; + return { + defaultTab: "transform", + tabs: [ + buildTransformTab({ element }), + ...(showAudioTab ? [buildAudioTab({ element })] : []), + buildSpeedTab({ element }), + buildBlendingTab({ element }), + buildMasksTab({ element }), + buildClipEffectsTab({ element }), + ], + }; +} + +function getImageConfig({ + element, +}: { + element: ImageElement; +}): ElementPropertiesConfig { + return { + defaultTab: "transform", + tabs: [ + buildTransformTab({ element }), + buildBlendingTab({ element }), + buildMasksTab({ element }), + buildClipEffectsTab({ element }), + ], + }; +} + +function getStickerConfig({ + element, +}: { + element: StickerElement; +}): ElementPropertiesConfig { + return { + defaultTab: "transform", + tabs: [ + buildTransformTab({ element }), + buildBlendingTab({ element }), + buildClipEffectsTab({ element }), + ], + }; +} + +function getGraphicConfig({ + element, +}: { + element: GraphicElement; +}): ElementPropertiesConfig { + return { + defaultTab: "graphic", + tabs: [ + buildGraphicTab({ element }), + buildTransformTab({ element }), + buildBlendingTab({ element }), + buildMasksTab({ element }), + buildClipEffectsTab({ element }), + ], + }; +} + +function getAudioConfig({ + element, +}: { + element: AudioElement; +}): ElementPropertiesConfig { + return { + defaultTab: "audio", + tabs: [buildAudioTab({ element }), buildSpeedTab({ element })], + }; +} + +function getEffectConfig({ + element, +}: { + element: EffectElement; +}): ElementPropertiesConfig { + return { + defaultTab: "effects", + tabs: [buildStandaloneEffectTab({ element })], + }; +} + +export function getPropertiesConfig({ + element, + mediaAssets, +}: { + element: TimelineElement; + mediaAssets: MediaAsset[]; +}): ElementPropertiesConfig { + switch (element.type) { + case "text": + return getTextConfig({ element }); + case "video": { + const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId); + return getVideoConfig({ element, mediaAsset }); + } + case "image": + return getImageConfig({ element }); + case "sticker": + return getStickerConfig({ element }); + case "graphic": + return getGraphicConfig({ element }); + case "audio": + return getAudioConfig({ element }); + case "effect": + return getEffectConfig({ element }); + } +} diff --git a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts index 1783e3fc..bd57d4b0 100644 --- a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts +++ b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts @@ -1,18 +1,18 @@ -import { create } from "zustand"; - -interface PropertiesState { - activeTabPerType: Record; - setActiveTab: (elementType: string, tabId: string) => void; - isTransformScaleLocked: boolean; - setTransformScaleLocked: (locked: boolean) => void; -} - -export const usePropertiesStore = create()((set) => ({ - activeTabPerType: {}, - setActiveTab: (elementType, tabId) => - set((state) => ({ - activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId }, - })), - isTransformScaleLocked: false, - setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }), -})); +import { create } from "zustand"; + +interface PropertiesState { + activeTabPerType: Record; + setActiveTab: (elementType: string, tabId: string) => void; + isTransformScaleLocked: boolean; + setTransformScaleLocked: (locked: boolean) => void; +} + +export const usePropertiesStore = create()((set) => ({ + activeTabPerType: {}, + setActiveTab: (elementType, tabId) => + set((state) => ({ + activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId }, + })), + isTransformScaleLocked: false, + setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }), +})); diff --git a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx index 41b6ec6d..a92f7be2 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx @@ -1,109 +1,109 @@ -import { NumberField } from "@/components/ui/number-field"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { clamp, isNearlyEqual } from "@/utils/math"; -import type { AudioElement, VideoElement } from "@/lib/timeline"; -import { resolveNumberAtTime } from "@/lib/animation"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -import { KeyframeToggle } from "../components/keyframe-toggle"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { VolumeHighIcon } from "@hugeicons/core-free-icons"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; - -export function AudioTab({ - element, - trackId, -}: { - element: AudioElement | VideoElement; - trackId: string; -}) { - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedVolume = resolveNumberAtTime({ - baseValue: element.volume ?? DEFAULTS.element.volume, - animations: element.animations, - propertyPath: "volume", - localTime, - }); - const volume = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "volume", - localTime, - isPlayheadWithinElementRange, - displayValue: resolvedVolume.toFixed(1), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) { - return null; - } - - return clamp({ value: parsed, min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }); - }, - valueAtPlayhead: resolvedVolume, - step: 0.1, - buildBaseUpdates: ({ value }) => ({ - volume: value, - }), - }); - const isDefault = - volume.hasAnimatedKeyframes && isPlayheadWithinElementRange - ? isNearlyEqual({ - leftValue: resolvedVolume, - rightValue: DEFAULTS.element.volume, - }) - : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume; - - return ( -
- - Audio - - - - - } - > - } - value={volume.displayValue} - onFocus={volume.onFocus} - onChange={volume.onChange} - onBlur={volume.onBlur} - dragSensitivity="slow" - scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }} - onScrub={volume.scrubTo} - onScrubEnd={volume.commitScrub} - onReset={() => - volume.commitValue({ - value: DEFAULTS.element.volume, - }) - } - isDefault={isDefault} - suffix="dB" - /> - - - -
- ); -} +import { NumberField } from "@/components/ui/number-field"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { clamp, isNearlyEqual } from "@/utils/math"; +import type { AudioElement, VideoElement } from "@/lib/timeline"; +import { resolveNumberAtTime } from "@/lib/animation"; +import { useElementPlayhead } from "../hooks/use-element-playhead"; +import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; +import { KeyframeToggle } from "../components/keyframe-toggle"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { VolumeHighIcon } from "@hugeicons/core-free-icons"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; + +export function AudioTab({ + element, + trackId, +}: { + element: AudioElement | VideoElement; + trackId: string; +}) { + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedVolume = resolveNumberAtTime({ + baseValue: element.volume ?? DEFAULTS.element.volume, + animations: element.animations, + propertyPath: "volume", + localTime, + }); + const volume = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "volume", + localTime, + isPlayheadWithinElementRange, + displayValue: resolvedVolume.toFixed(1), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) { + return null; + } + + return clamp({ value: parsed, min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }); + }, + valueAtPlayhead: resolvedVolume, + step: 0.1, + buildBaseUpdates: ({ value }) => ({ + volume: value, + }), + }); + const isDefault = + volume.hasAnimatedKeyframes && isPlayheadWithinElementRange + ? isNearlyEqual({ + leftValue: resolvedVolume, + rightValue: DEFAULTS.element.volume, + }) + : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume; + + return ( +
+ + Audio + + + + + } + > + } + value={volume.displayValue} + onFocus={volume.onFocus} + onChange={volume.onChange} + onBlur={volume.onBlur} + dragSensitivity="slow" + scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }} + onScrub={volume.scrubTo} + onScrubEnd={volume.commitScrub} + onReset={() => + volume.commitValue({ + value: DEFAULTS.element.volume, + }) + } + isDefault={isDefault} + suffix="dB" + /> + + + +
+ ); +} diff --git a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx index c360817c..fdcde148 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx @@ -1,230 +1,230 @@ -import { useEditor } from "@/hooks/use-editor"; -import { clamp } from "@/utils/math"; -import { NumberField } from "@/components/ui/number-field"; -import { OcCheckerboardIcon } from "@opencut/ui/icons"; -import { Fragment, useRef } from "react"; -import { useMenuPreview } from "@/hooks/use-menu-preview"; -import { - Section, - SectionContent, - SectionField, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { - Select, - SelectContent, - SelectItem, - SelectSeparator, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import type { BlendMode } from "@/lib/rendering"; -import type { ElementType } from "@/lib/timeline"; -import type { ElementAnimations } from "@/lib/animation/types"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { RainDropIcon } from "@hugeicons/core-free-icons"; -import { KeyframeToggle } from "../components/keyframe-toggle"; -import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { resolveOpacityAtTime } from "@/lib/animation"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { isPropertyAtDefault } from "./transform-tab"; - -type BlendingElement = { - id: string; - opacity: number; - type: ElementType; - blendMode?: BlendMode; - startTime: number; - duration: number; - animations?: ElementAnimations; -}; - -const BLEND_MODE_GROUPS = [ - [{ value: "normal", label: "Normal" }], - [ - { value: "darken", label: "Darken" }, - { value: "multiply", label: "Multiply" }, - { value: "color-burn", label: "Color Burn" }, - ], - [ - { value: "lighten", label: "Lighten" }, - { value: "screen", label: "Screen" }, - { value: "plus-lighter", label: "Plus Lighter" }, - { value: "color-dodge", label: "Color Dodge" }, - ], - [ - { value: "overlay", label: "Overlay" }, - { value: "soft-light", label: "Soft Light" }, - { value: "hard-light", label: "Hard Light" }, - ], - [ - { value: "difference", label: "Difference" }, - { value: "exclusion", label: "Exclusion" }, - ], - [ - { value: "hue", label: "Hue" }, - { value: "saturation", label: "Saturation" }, - { value: "color", label: "Color" }, - { value: "luminosity", label: "Luminosity" }, - ], -]; - -export function BlendingTab({ - element, - trackId, -}: { - element: BlendingElement; - trackId: string; -}) { - const editor = useEditor(); - const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive()); - const blendMode = element.blendMode ?? DEFAULTS.element.blendMode; - const committedBlendModeRef = useRef(blendMode); - if (!isPreviewActive) { - committedBlendModeRef.current = blendMode; - } - - const { - onPointerLeave, - onOpenChange: handleBlendModeOpenChange, - markCommitted, - } = useMenuPreview(); - - const previewBlendMode = ({ value }: { value: BlendMode }) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { blendMode: value } }, - ], - }); - - const commitBlendMode = (value: string) => { - if (editor.timeline.isPreviewActive()) { - editor.timeline.commitPreview(); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { blendMode: value as BlendMode }, - }, - ], - }); - } - markCommitted(); - }; - - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedOpacity = resolveOpacityAtTime({ - baseOpacity: element.opacity, - animations: element.animations, - localTime, - }); - - const opacity = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "opacity", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOpacity * 100).toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ value: parsed, min: 0, max: 100 }) / 100; - }, - valueAtPlayhead: resolvedOpacity, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ opacity: value }), - }); - - return ( -
- - Blending - - -
- - } - > - - } - value={opacity.displayValue} - min={0} - max={100} - onFocus={opacity.onFocus} - onChange={opacity.onChange} - onBlur={opacity.onBlur} - onScrub={opacity.scrubTo} - onScrubEnd={opacity.commitScrub} - onReset={() => - opacity.commitValue({ value: DEFAULTS.element.opacity }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: opacity.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOpacity, - staticValue: element.opacity, - defaultValue: DEFAULTS.element.opacity, - })} - dragSensitivity="slow" - /> - - - - -
-
-
- ); -} +import { useEditor } from "@/hooks/use-editor"; +import { clamp } from "@/utils/math"; +import { NumberField } from "@/components/ui/number-field"; +import { OcCheckerboardIcon } from "@opencut/ui/icons"; +import { Fragment, useRef } from "react"; +import { useMenuPreview } from "@/hooks/use-menu-preview"; +import { + Section, + SectionContent, + SectionField, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { + Select, + SelectContent, + SelectItem, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { BlendMode } from "@/lib/rendering"; +import type { ElementType } from "@/lib/timeline"; +import type { ElementAnimations } from "@/lib/animation/types"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { RainDropIcon } from "@hugeicons/core-free-icons"; +import { KeyframeToggle } from "../components/keyframe-toggle"; +import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; +import { useElementPlayhead } from "../hooks/use-element-playhead"; +import { resolveOpacityAtTime } from "@/lib/animation"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { isPropertyAtDefault } from "./transform-tab"; + +type BlendingElement = { + id: string; + opacity: number; + type: ElementType; + blendMode?: BlendMode; + startTime: number; + duration: number; + animations?: ElementAnimations; +}; + +const BLEND_MODE_GROUPS = [ + [{ value: "normal", label: "Normal" }], + [ + { value: "darken", label: "Darken" }, + { value: "multiply", label: "Multiply" }, + { value: "color-burn", label: "Color Burn" }, + ], + [ + { value: "lighten", label: "Lighten" }, + { value: "screen", label: "Screen" }, + { value: "plus-lighter", label: "Plus Lighter" }, + { value: "color-dodge", label: "Color Dodge" }, + ], + [ + { value: "overlay", label: "Overlay" }, + { value: "soft-light", label: "Soft Light" }, + { value: "hard-light", label: "Hard Light" }, + ], + [ + { value: "difference", label: "Difference" }, + { value: "exclusion", label: "Exclusion" }, + ], + [ + { value: "hue", label: "Hue" }, + { value: "saturation", label: "Saturation" }, + { value: "color", label: "Color" }, + { value: "luminosity", label: "Luminosity" }, + ], +]; + +export function BlendingTab({ + element, + trackId, +}: { + element: BlendingElement; + trackId: string; +}) { + const editor = useEditor(); + const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive()); + const blendMode = element.blendMode ?? DEFAULTS.element.blendMode; + const committedBlendModeRef = useRef(blendMode); + if (!isPreviewActive) { + committedBlendModeRef.current = blendMode; + } + + const { + onPointerLeave, + onOpenChange: handleBlendModeOpenChange, + markCommitted, + } = useMenuPreview(); + + const previewBlendMode = ({ value }: { value: BlendMode }) => + editor.timeline.previewElements({ + updates: [ + { trackId, elementId: element.id, updates: { blendMode: value } }, + ], + }); + + const commitBlendMode = (value: string) => { + if (editor.timeline.isPreviewActive()) { + editor.timeline.commitPreview(); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { blendMode: value as BlendMode }, + }, + ], + }); + } + markCommitted(); + }; + + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedOpacity = resolveOpacityAtTime({ + baseOpacity: element.opacity, + animations: element.animations, + localTime, + }); + + const opacity = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "opacity", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedOpacity * 100).toString(), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return clamp({ value: parsed, min: 0, max: 100 }) / 100; + }, + valueAtPlayhead: resolvedOpacity, + step: 0.01, + buildBaseUpdates: ({ value }) => ({ opacity: value }), + }); + + return ( +
+ + Blending + + +
+ + } + > + + } + value={opacity.displayValue} + min={0} + max={100} + onFocus={opacity.onFocus} + onChange={opacity.onChange} + onBlur={opacity.onBlur} + onScrub={opacity.scrubTo} + onScrubEnd={opacity.commitScrub} + onReset={() => + opacity.commitValue({ value: DEFAULTS.element.opacity }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: opacity.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedOpacity, + staticValue: element.opacity, + defaultValue: DEFAULTS.element.opacity, + })} + dragSensitivity="slow" + /> + + + + +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx index 1b94ee2d..759ced60 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx @@ -1,309 +1,309 @@ -"use client"; - -import { useState } from "react"; -import type { ParamValues } from "@/lib/params"; -import type { Effect } from "@/lib/effects/types"; -import type { EffectElement, VisualElement } from "@/lib/timeline"; -import { effectsRegistry } from "@/lib/effects"; -import { useEditor } from "@/hooks/use-editor"; -import { useElementPreview } from "@/hooks/use-element-preview"; -import { - Section, - SectionContent, - SectionHeader, - SectionTitle, - SectionFields, -} from "@/components/section"; -import { PropertyParamField } from "../components/property-param-field"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - Delete02Icon, - ViewIcon, - ViewOffSlashIcon, - MagicWand05Icon, -} from "@hugeicons/core-free-icons"; -import { cn } from "@/utils/ui"; -import { Separator } from "@/components/ui/separator"; -import { useAssetsPanelStore } from "@/stores/assets-panel-store"; - -export function StandaloneEffectTab({ - element, - trackId, -}: { - element: EffectElement; - trackId: string; -}) { - const { renderElement, previewUpdates, commit } = useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - - const effect: Effect = { - id: element.id, - type: element.effectType, - params: element.params, - enabled: true, - }; - - const previewParam = (key: string) => (value: number | string | boolean) => { - previewUpdates({ - params: { ...(renderElement as EffectElement).params, [key]: value }, - }); - }; - - return ( -
-
- Effect -
- -
- ); -} - -export function ClipEffectsTab({ - element, - trackId, -}: { - element: VisualElement; - trackId: string; -}) { - const [dragIndex, setDragIndex] = useState(null); - const [dropIndex, setDropIndex] = useState(null); - const editor = useEditor(); - const { renderElement, previewUpdates, commit } = useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - - const effects: Effect[] = element.effects ?? []; - - const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => { - return ( - (renderElement as VisualElement).effects?.find((ef) => ef.id === effectId) - ?.params ?? - effects.find((ef) => ef.id === effectId)?.params ?? - {} - ); - }; - - const buildPreviewParam = - (effectId: string) => - (key: string) => - (value: number | string | boolean) => { - const updatedEffects = ( - (renderElement as VisualElement).effects ?? [] - ).map((existing) => - existing.id !== effectId - ? existing - : { ...existing, params: { ...existing.params, [key]: value } }, - ); - previewUpdates({ effects: updatedEffects }); - }; - - const handleDragStart = ({ index }: { index: number }) => setDragIndex(index); - - const handleDragOver = ({ - event, - index, - }: { - event: React.DragEvent; - index: number; - }) => { - event.preventDefault(); - if (index !== dropIndex) setDropIndex(index); - }; - - const handleDrop = ({ toIndex }: { toIndex: number }) => { - if (dragIndex !== null && dragIndex !== toIndex) { - editor.timeline.reorderClipEffects({ - trackId, - elementId: element.id, - fromIndex: dragIndex, - toIndex, - }); - } - setDragIndex(null); - setDropIndex(null); - }; - - const handleDragEnd = () => { - setDragIndex(null); - setDropIndex(null); - }; - - return ( -
-
- Effects -
- {effects.length === 0 ? ( - - ) : ( -
    - {effects.map((effect, index) => { - const resolvedDragIndex = dragIndex ?? -1; - const isDragging = dragIndex === index; - const isDropTarget = - dropIndex === index && dragIndex !== null && dragIndex !== index; - const showTopDropIndicator = - isDropTarget && index < resolvedDragIndex; - const showBottomDropIndicator = - isDropTarget && index > resolvedDragIndex; - - return ( -
  • handleDragStart({ index })} - onDragOver={(event) => handleDragOver({ event, index })} - onDrop={() => handleDrop({ toIndex: index })} - onDragEnd={handleDragEnd} - className={cn( - "group list-none", - isDragging && "opacity-40", - showTopDropIndicator && "border-t-2 border-primary", - showBottomDropIndicator && "border-b-2 border-primary", - )} - > - - editor.timeline.toggleClipEffect({ - trackId, - elementId: element.id, - effectId: effect.id, - }) - } - onRemove={() => - editor.timeline.removeClipEffect({ - trackId, - elementId: element.id, - effectId: effect.id, - }) - } - /> -
  • - ); - })} -
- )} -
- ); -} - -function EmptyView() { - const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab); - - return ( -
- -
-

No effects

-

- Add effects to this layer from the Assets panel. -

-
- -
- ); -} - -function EffectSection({ - effect, - renderParams, - previewParam, - onCommit, - onToggle, - onRemove, -}: { - effect: Effect; - renderParams: ParamValues; - previewParam: (key: string) => (value: number | string | boolean) => void; - onCommit: () => void; - onToggle?: () => void; - onRemove?: () => void; -}) { - const definition = effectsRegistry.get(effect.type); - - return ( -
- - - -
- ) - } - > - - {definition.name} - - - - - {definition.params.map((param) => ( -
-
- -
- -
- ))} -
-
- - ); -} +"use client"; + +import { useState } from "react"; +import type { ParamValues } from "@/lib/params"; +import type { Effect } from "@/lib/effects/types"; +import type { EffectElement, VisualElement } from "@/lib/timeline"; +import { effectsRegistry } from "@/lib/effects"; +import { useEditor } from "@/hooks/use-editor"; +import { useElementPreview } from "@/hooks/use-element-preview"; +import { + Section, + SectionContent, + SectionHeader, + SectionTitle, + SectionFields, +} from "@/components/section"; +import { PropertyParamField } from "../components/property-param-field"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + Delete02Icon, + ViewIcon, + ViewOffSlashIcon, + MagicWand05Icon, +} from "@hugeicons/core-free-icons"; +import { cn } from "@/utils/ui"; +import { Separator } from "@/components/ui/separator"; +import { useAssetsPanelStore } from "@/stores/assets-panel-store"; + +export function StandaloneEffectTab({ + element, + trackId, +}: { + element: EffectElement; + trackId: string; +}) { + const { renderElement, previewUpdates, commit } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const effect: Effect = { + id: element.id, + type: element.effectType, + params: element.params, + enabled: true, + }; + + const previewParam = (key: string) => (value: number | string | boolean) => { + previewUpdates({ + params: { ...(renderElement as EffectElement).params, [key]: value }, + }); + }; + + return ( +
+
+ Effect +
+ +
+ ); +} + +export function ClipEffectsTab({ + element, + trackId, +}: { + element: VisualElement; + trackId: string; +}) { + const [dragIndex, setDragIndex] = useState(null); + const [dropIndex, setDropIndex] = useState(null); + const editor = useEditor(); + const { renderElement, previewUpdates, commit } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const effects: Effect[] = element.effects ?? []; + + const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => { + return ( + (renderElement as VisualElement).effects?.find((ef) => ef.id === effectId) + ?.params ?? + effects.find((ef) => ef.id === effectId)?.params ?? + {} + ); + }; + + const buildPreviewParam = + (effectId: string) => + (key: string) => + (value: number | string | boolean) => { + const updatedEffects = ( + (renderElement as VisualElement).effects ?? [] + ).map((existing) => + existing.id !== effectId + ? existing + : { ...existing, params: { ...existing.params, [key]: value } }, + ); + previewUpdates({ effects: updatedEffects }); + }; + + const handleDragStart = ({ index }: { index: number }) => setDragIndex(index); + + const handleDragOver = ({ + event, + index, + }: { + event: React.DragEvent; + index: number; + }) => { + event.preventDefault(); + if (index !== dropIndex) setDropIndex(index); + }; + + const handleDrop = ({ toIndex }: { toIndex: number }) => { + if (dragIndex !== null && dragIndex !== toIndex) { + editor.timeline.reorderClipEffects({ + trackId, + elementId: element.id, + fromIndex: dragIndex, + toIndex, + }); + } + setDragIndex(null); + setDropIndex(null); + }; + + const handleDragEnd = () => { + setDragIndex(null); + setDropIndex(null); + }; + + return ( +
+
+ Effects +
+ {effects.length === 0 ? ( + + ) : ( +
    + {effects.map((effect, index) => { + const resolvedDragIndex = dragIndex ?? -1; + const isDragging = dragIndex === index; + const isDropTarget = + dropIndex === index && dragIndex !== null && dragIndex !== index; + const showTopDropIndicator = + isDropTarget && index < resolvedDragIndex; + const showBottomDropIndicator = + isDropTarget && index > resolvedDragIndex; + + return ( +
  • handleDragStart({ index })} + onDragOver={(event) => handleDragOver({ event, index })} + onDrop={() => handleDrop({ toIndex: index })} + onDragEnd={handleDragEnd} + className={cn( + "group list-none", + isDragging && "opacity-40", + showTopDropIndicator && "border-t-2 border-primary", + showBottomDropIndicator && "border-b-2 border-primary", + )} + > + + editor.timeline.toggleClipEffect({ + trackId, + elementId: element.id, + effectId: effect.id, + }) + } + onRemove={() => + editor.timeline.removeClipEffect({ + trackId, + elementId: element.id, + effectId: effect.id, + }) + } + /> +
  • + ); + })} +
+ )} +
+ ); +} + +function EmptyView() { + const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab); + + return ( +
+ +
+

No effects

+

+ Add effects to this layer from the Assets panel. +

+
+ +
+ ); +} + +function EffectSection({ + effect, + renderParams, + previewParam, + onCommit, + onToggle, + onRemove, +}: { + effect: Effect; + renderParams: ParamValues; + previewParam: (key: string) => (value: number | string | boolean) => void; + onCommit: () => void; + onToggle?: () => void; + onRemove?: () => void; +}) { + const definition = effectsRegistry.get(effect.type); + + return ( +
+ + + +
+ ) + } + > + + {definition.name} + + + + + {definition.params.map((param) => ( +
+
+ +
+ +
+ ))} +
+
+ + ); +} diff --git a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx index 22dae8cc..4593050e 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx @@ -1,238 +1,238 @@ -"use client"; - -import { useRef } from "react"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { - useKeyframedParamProperty, - type KeyframedParamPropertyResult, -} from "../hooks/use-keyframed-param-property"; -import { resolveGraphicParamsAtTime } from "@/lib/animation"; -import type { ParamDefinition, ParamValues } from "@/lib/params"; -import type { GraphicElement } from "@/lib/timeline"; -import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics"; -import { useElementPreview } from "@/hooks/use-element-preview"; -import { useEditor } from "@/hooks/use-editor"; -import { - Section, - SectionContent, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { PropertyParamField } from "../components/property-param-field"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; -import { cn } from "@/utils/ui"; - -registerDefaultGraphics(); - -const DEFAULT_STROKE_WIDTH = 2; - -export function GraphicTab({ - element, - trackId, -}: { - element: GraphicElement; - trackId: string; -}) { - const definition = graphicsRegistry.get(element.definitionId); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const { renderElement } = useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - - const liveElement = renderElement as GraphicElement; - const resolvedParams = resolveGraphicParamsAtTime({ - element: liveElement, - localTime, - }); - - const shapeParams = definition.params.filter((p) => p.group !== "stroke"); - const hasStrokeParams = definition.params.some((p) => p.group === "stroke"); - - return ( -
-
- - {definition.name} - - - - {shapeParams.map((param) => ( - - ))} - - -
- {hasStrokeParams && ( - - )} -
- ); -} - -function StrokeSection({ - element, - trackId, -}: { - element: GraphicElement; - trackId: string; -}) { - const editor = useEditor(); - const definition = graphicsRegistry.get(element.definitionId); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const { renderElement } = useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - - const liveElement = renderElement as GraphicElement; - const resolvedParams = resolveGraphicParamsAtTime({ - element: liveElement, - localTime, - }); - const strokeParams = definition.params.filter((p) => p.group === "stroke"); - const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH); - const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0; - - const toggleStroke = () => { - if (isStrokeEnabled) { - lastStrokeWidth.current = Number( - element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH, - ); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { params: { ...element.params, strokeWidth: 0 } }, - }, - ], - }); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - params: { - ...element.params, - strokeWidth: lastStrokeWidth.current, - }, - }, - }, - ], - }); - } - }; - - return ( -
- { - event.stopPropagation(); - toggleStroke(); - }} - > - - - } - > - Stroke - - - - {strokeParams.map((param) => ( - - ))} - - -
- ); -} - -function AnimatedGraphicParamField({ - param, - trackId, - element, - localTime, - isPlayheadWithinElementRange, - resolvedParams, -}: { - param: ParamDefinition; - trackId: string; - element: GraphicElement; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedParams: ParamValues; -}) { - const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty({ - param, - trackId, - elementId: element.id, - animations: element.animations, - localTime, - isPlayheadWithinElementRange, - resolvedValue: resolvedParams[param.key] ?? param.default, - buildBaseUpdates: ({ value }) => ({ - params: { - ...element.params, - [param.key]: value, - }, - }), - }); - - return ( - - ); -} +"use client"; + +import { useRef } from "react"; +import { useElementPlayhead } from "../hooks/use-element-playhead"; +import { + useKeyframedParamProperty, + type KeyframedParamPropertyResult, +} from "../hooks/use-keyframed-param-property"; +import { resolveGraphicParamsAtTime } from "@/lib/animation"; +import type { ParamDefinition, ParamValues } from "@/lib/params"; +import type { GraphicElement } from "@/lib/timeline"; +import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics"; +import { useElementPreview } from "@/hooks/use-element-preview"; +import { useEditor } from "@/hooks/use-editor"; +import { + Section, + SectionContent, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { PropertyParamField } from "../components/property-param-field"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; +import { cn } from "@/utils/ui"; + +registerDefaultGraphics(); + +const DEFAULT_STROKE_WIDTH = 2; + +export function GraphicTab({ + element, + trackId, +}: { + element: GraphicElement; + trackId: string; +}) { + const definition = graphicsRegistry.get(element.definitionId); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const { renderElement } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const liveElement = renderElement as GraphicElement; + const resolvedParams = resolveGraphicParamsAtTime({ + element: liveElement, + localTime, + }); + + const shapeParams = definition.params.filter((p) => p.group !== "stroke"); + const hasStrokeParams = definition.params.some((p) => p.group === "stroke"); + + return ( +
+
+ + {definition.name} + + + + {shapeParams.map((param) => ( + + ))} + + +
+ {hasStrokeParams && ( + + )} +
+ ); +} + +function StrokeSection({ + element, + trackId, +}: { + element: GraphicElement; + trackId: string; +}) { + const editor = useEditor(); + const definition = graphicsRegistry.get(element.definitionId); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const { renderElement } = useElementPreview({ + trackId, + elementId: element.id, + fallback: element, + }); + + const liveElement = renderElement as GraphicElement; + const resolvedParams = resolveGraphicParamsAtTime({ + element: liveElement, + localTime, + }); + const strokeParams = definition.params.filter((p) => p.group === "stroke"); + const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH); + const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0; + + const toggleStroke = () => { + if (isStrokeEnabled) { + lastStrokeWidth.current = Number( + element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH, + ); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { params: { ...element.params, strokeWidth: 0 } }, + }, + ], + }); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + params: { + ...element.params, + strokeWidth: lastStrokeWidth.current, + }, + }, + }, + ], + }); + } + }; + + return ( +
+ { + event.stopPropagation(); + toggleStroke(); + }} + > + + + } + > + Stroke + + + + {strokeParams.map((param) => ( + + ))} + + +
+ ); +} + +function AnimatedGraphicParamField({ + param, + trackId, + element, + localTime, + isPlayheadWithinElementRange, + resolvedParams, +}: { + param: ParamDefinition; + trackId: string; + element: GraphicElement; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedParams: ParamValues; +}) { + const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty({ + param, + trackId, + elementId: element.id, + animations: element.animations, + localTime, + isPlayheadWithinElementRange, + resolvedValue: resolvedParams[param.key] ?? param.default, + buildBaseUpdates: ({ value }) => ({ + params: { + ...element.params, + [param.key]: value, + }, + }), + }); + + return ( + + ); +} diff --git a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx index 44f5dd74..4434890e 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx @@ -1,137 +1,137 @@ -import { useRef } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { NumberField } from "@/components/ui/number-field"; -import { Switch } from "@/components/ui/switch"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { DashboardSpeed02Icon } from "@hugeicons/core-free-icons"; -import { buildConstantRetime } from "@/lib/retime"; -import { - DEFAULT_RETIME_RATE, - MIN_RETIME_RATE, - MAX_RETIME_RATE, - clampRetimeRate, - canMaintainPitch, -} from "@/constants/retime-constants"; -import type { AudioElement, VideoElement } from "@/lib/timeline"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { usePropertyDraft } from "../hooks/use-property-draft"; - -function rateToDisplay({ rate }: { rate: number }): string { - return rate.toFixed(2); -} - -function parseSpeedInput({ input }: { input: string }): number | null { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - const rounded = Math.round(parsed * 100) / 100; - return clampRetimeRate({ rate: rounded }); -} - -function buildRetime({ - rate, - maintainPitch, -}: { - rate: number; - maintainPitch: boolean; -}) { - if (rate === DEFAULT_RETIME_RATE && !maintainPitch) return undefined; - return buildConstantRetime({ rate, maintainPitch }); -} - -export function SpeedTab({ - element, - trackId, -}: { - element: AudioElement | VideoElement; - trackId: string; -}) { - const editor = useEditor(); - const rate = clampRetimeRate({ - rate: element.retime?.rate ?? DEFAULT_RETIME_RATE, - }); - const maintainPitchAvailable = canMaintainPitch({ rate }); - const maintainPitch = element.retime?.maintainPitch ?? false; - const pendingRateRef = useRef(rate); - - const applyRetime = ({ - rate: nextRate, - maintainPitch: nextMaintainPitch, - pushHistory = true, - }: { - rate: number; - maintainPitch: boolean; - pushHistory?: boolean; - }) => { - editor.timeline.updateElementRetime({ - trackId, - elementId: element.id, - retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }), - pushHistory, - }); - }; - - const speedDraft = usePropertyDraft({ - displayValue: rateToDisplay({ rate }), - parse: (input) => parseSpeedInput({ input }), - onPreview: (nextRate) => { - pendingRateRef.current = nextRate; - applyRetime({ rate: nextRate, maintainPitch, pushHistory: false }); - }, - onCommit: () => { - applyRetime({ rate: pendingRateRef.current, maintainPitch }); - }, - }); - - return ( -
- - Speed - - - - - } - value={speedDraft.displayValue} - suffix="x" - scrubRanges={[ - { from: 0.01, to: 1, pixelsPerUnit: 160 }, - { from: 1, to: 5, pixelsPerUnit: 48 }, - ]} - scrubClamp={{ min: MIN_RETIME_RATE, max: MAX_RETIME_RATE }} - onFocus={() => { - pendingRateRef.current = rate; - speedDraft.onFocus(); - }} - onChange={speedDraft.onChange} - onBlur={speedDraft.onBlur} - onScrub={speedDraft.scrubTo} - onScrubEnd={speedDraft.commitScrub} - onReset={() => - applyRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch }) - } - isDefault={rate === DEFAULT_RETIME_RATE} - /> - -
- Change pitch - - applyRetime({ rate, maintainPitch: !checked }) - } - /> -
-
-
-
- ); -} +import { useRef } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { NumberField } from "@/components/ui/number-field"; +import { Switch } from "@/components/ui/switch"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { DashboardSpeed02Icon } from "@hugeicons/core-free-icons"; +import { buildConstantRetime } from "@/lib/retime"; +import { + DEFAULT_RETIME_RATE, + MIN_RETIME_RATE, + MAX_RETIME_RATE, + clampRetimeRate, + canMaintainPitch, +} from "@/constants/retime-constants"; +import type { AudioElement, VideoElement } from "@/lib/timeline"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { usePropertyDraft } from "../hooks/use-property-draft"; + +function rateToDisplay({ rate }: { rate: number }): string { + return rate.toFixed(2); +} + +function parseSpeedInput({ input }: { input: string }): number | null { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + const rounded = Math.round(parsed * 100) / 100; + return clampRetimeRate({ rate: rounded }); +} + +function buildRetime({ + rate, + maintainPitch, +}: { + rate: number; + maintainPitch: boolean; +}) { + if (rate === DEFAULT_RETIME_RATE && !maintainPitch) return undefined; + return buildConstantRetime({ rate, maintainPitch }); +} + +export function SpeedTab({ + element, + trackId, +}: { + element: AudioElement | VideoElement; + trackId: string; +}) { + const editor = useEditor(); + const rate = clampRetimeRate({ + rate: element.retime?.rate ?? DEFAULT_RETIME_RATE, + }); + const maintainPitchAvailable = canMaintainPitch({ rate }); + const maintainPitch = element.retime?.maintainPitch ?? false; + const pendingRateRef = useRef(rate); + + const applyRetime = ({ + rate: nextRate, + maintainPitch: nextMaintainPitch, + pushHistory = true, + }: { + rate: number; + maintainPitch: boolean; + pushHistory?: boolean; + }) => { + editor.timeline.updateElementRetime({ + trackId, + elementId: element.id, + retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }), + pushHistory, + }); + }; + + const speedDraft = usePropertyDraft({ + displayValue: rateToDisplay({ rate }), + parse: (input) => parseSpeedInput({ input }), + onPreview: (nextRate) => { + pendingRateRef.current = nextRate; + applyRetime({ rate: nextRate, maintainPitch, pushHistory: false }); + }, + onCommit: () => { + applyRetime({ rate: pendingRateRef.current, maintainPitch }); + }, + }); + + return ( +
+ + Speed + + + + + } + value={speedDraft.displayValue} + suffix="x" + scrubRanges={[ + { from: 0.01, to: 1, pixelsPerUnit: 160 }, + { from: 1, to: 5, pixelsPerUnit: 48 }, + ]} + scrubClamp={{ min: MIN_RETIME_RATE, max: MAX_RETIME_RATE }} + onFocus={() => { + pendingRateRef.current = rate; + speedDraft.onFocus(); + }} + onChange={speedDraft.onChange} + onBlur={speedDraft.onBlur} + onScrub={speedDraft.scrubTo} + onScrubEnd={speedDraft.commitScrub} + onReset={() => + applyRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch }) + } + isDefault={rate === DEFAULT_RETIME_RATE} + /> + +
+ Change pitch + + applyRetime({ rate, maintainPitch: !checked }) + } + /> +
+
+
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx index f8e2527a..45831dc0 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx @@ -1,762 +1,762 @@ -"use client"; - -import { Textarea } from "@/components/ui/textarea"; -import { FontPicker } from "@/components/ui/font-picker"; -import type { TextElement } from "@/lib/timeline"; -import { NumberField } from "@/components/ui/number-field"; -import { useRef } from "react"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { ColorPicker } from "@/components/ui/color-picker"; -import { Button } from "@/components/ui/button"; -import { uppercase } from "@/utils/string"; -import { clamp } from "@/utils/math"; -import { useEditor } from "@/hooks/use-editor"; -import { DEFAULT_COLOR } from "@/constants/project-constants"; -import { - CORNER_RADIUS_MAX, - CORNER_RADIUS_MIN, - MAX_FONT_SIZE, - MIN_FONT_SIZE, -} from "@/constants/text-constants"; -import { usePropertyDraft } from "../hooks/use-property-draft"; -import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property"; -import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { KeyframeToggle } from "../components/keyframe-toggle"; -import { isPropertyAtDefault } from "./transform-tab"; -import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - MinusSignIcon, - PlusSignIcon, - TextFontIcon, -} from "@hugeicons/core-free-icons"; -import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { cn } from "@/utils/ui"; - -export function TextTab({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - return ( -
- - - - -
- ); -} - -function ContentSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - - const content = usePropertyDraft({ - displayValue: element.content, - parse: (input) => input, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { content: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( -
- - Content - - -