From 3db766646759bf59f11ef3f8640d1aca6d394c15 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 13:53:13 +0200 Subject: more styling --- apps/web/src/components/ui/button.tsx | 2 +- apps/web/src/components/ui/popover.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'apps/web/src/components/ui') diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 0e07ccb8..3d17c042 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -18,7 +18,7 @@ const buttonVariants = cva( destructive: "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90", outline: - "border border-input bg-background shadow-xs hover:opacity-75 transition-opacity hover:text-accent-foreground", + "border border-input bg-transparent shadow-xs hover:opacity-75 transition-opacity hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-foreground/15 border border-input", text: "bg-transparent p-0 rounded-none opacity-100 hover:opacity-50 transition-opacity", // Instead of ghost (matches app better) diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index 47782a7f..406ff8eb 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} {...props} -- cgit v1.3.1 From 2b700b7474ffc728f5318c178244e035c93716b5 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 23:25:26 +0200 Subject: feat: scenes button to timeline toolbar --- .../editor/timeline/timeline-toolbar.tsx | 830 +++++++++++---------- apps/web/src/components/ui/split-button.tsx | 90 +++ 2 files changed, 509 insertions(+), 411 deletions(-) create mode 100644 apps/web/src/components/ui/split-button.tsx (limited to 'apps/web/src/components/ui') diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx index 1723dc11..cc9190e2 100644 --- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx @@ -1,411 +1,419 @@ -import { usePlaybackStore } from "@/stores/playback-store"; -import { useProjectStore } from "@/stores/project-store"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { toast } from "sonner"; -import { - TooltipProvider, - Tooltip, - TooltipTrigger, - TooltipContent, -} from "@/components/ui/tooltip"; -import { Button } from "@/components/ui/button"; -import { - Pause, - Play, - SkipBack, - Bookmark, - Magnet, - Link, - ZoomOut, - ZoomIn, - Copy, - Trash2, - Snowflake, - ArrowLeftToLine, - ArrowRightToLine, - SplitSquareHorizontal, - Scissors, -} from "lucide-react"; -import { Slider } from "@/components/ui/slider"; -import { DEFAULT_FPS } from "@/stores/project-store"; -import { formatTimeCode } from "@/lib/time"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { EditableTimecode } from "@/components/ui/editable-timecode"; - -export function TimelineToolbar({ - zoomLevel, - setZoomLevel, -}: { - zoomLevel: number; - setZoomLevel: (zoom: number) => void; -}) { - const { - tracks, - addTrack, - addElementToTrack, - removeElementFromTrack, - removeElementFromTrackWithRipple, - selectedElements, - clearSelectedElements, - splitElement, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, - snappingEnabled, - toggleSnapping, - rippleEditingEnabled, - toggleRippleEditing, - } = useTimelineStore(); - const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); - const { toggleBookmark, isBookmarked, activeProject } = useProjectStore(); - - const handleSplitSelected = () => { - if (selectedElements.length === 0) return; - let splitCount = 0; - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (element && track) { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const newElementId = splitElement(trackId, elementId, currentTime); - if (newElementId) splitCount++; - } - } - }); - if (splitCount === 0) { - toast.error("Playhead must be within selected elements to split"); - } - }; - - const handleDuplicateSelected = () => { - if (selectedElements.length === 0) return; - const canDuplicate = selectedElements.length === 1; - if (!canDuplicate) return; - - selectedElements.forEach(({ trackId, elementId }) => { - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((el) => el.id === elementId); - if (element) { - const newStartTime = - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1; - const { id, ...elementWithoutId } = element; - addElementToTrack(trackId, { - ...elementWithoutId, - startTime: newStartTime, - }); - } - }); - clearSelectedElements(); - }; - - const handleFreezeSelected = () => { - toast.info("Freeze frame functionality coming soon!"); - }; - - const handleSplitAndKeepLeft = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepLeft(trackId, elementId, currentTime); - }; - - const handleSplitAndKeepRight = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - const element = track?.elements.find((c) => c.id === elementId); - if (!element) return; - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within selected element"); - return; - } - splitAndKeepRight(trackId, elementId, currentTime); - }; - - const handleSeparateAudio = () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one media element to separate audio"); - return; - } - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t) => t.id === trackId); - if (!track || track.type !== "media") { - toast.error("Select a media element to separate audio"); - return; - } - separateAudio(trackId, elementId); - }; - - const handleDeleteSelected = () => { - if (selectedElements.length === 0) return; - selectedElements.forEach(({ trackId, elementId }) => { - if (rippleEditingEnabled) { - removeElementFromTrackWithRipple(trackId, elementId); - } else { - removeElementFromTrack(trackId, elementId); - } - }); - clearSelectedElements(); - }; - - const handleZoomIn = () => { - setZoomLevel(Math.min(4, zoomLevel + 0.25)); - }; - - const handleZoomOut = () => { - setZoomLevel(Math.max(0.25, zoomLevel - 0.25)); - }; - - const handleZoomSliderChange = (values: number[]) => { - setZoomLevel(values[0]); - }; - - const handleToggleBookmark = async () => { - await toggleBookmark(currentTime); - }; - - const currentBookmarked = isBookmarked(currentTime); - return ( -
-
- - - - - - - {isPlaying ? "Pause (Space)" : "Play (Space)"} - - - - - - - Return to Start (Home / Enter) - -
- {/* Time Display */} -
- -
- / -
-
- {formatTimeCode(duration, "HH:MM:SS:FF")} -
-
- {tracks.length === 0 && ( - <> -
- - - - - Add a test clip to try playback - - - )} -
- - - - - Split element (Ctrl+S) - - - - - - Split and keep left (Ctrl+Q) - - - - - - Split and keep right (Ctrl+W) - - - - - - Separate audio (Ctrl+D) - - - - - - Duplicate element (Ctrl+D) - - - - - - Freeze frame (F) - - - - - - Delete element (Delete) - -
- - - - - - {currentBookmarked ? "Remove bookmark" : "Add bookmark"} - - - -
-
- - - - - - Auto snapping - - - - - - - {rippleEditingEnabled - ? "Disable Ripple Editing" - : "Enable Ripple Editing"} - - - - -
-
- - - -
-
-
- ); -} +import { usePlaybackStore } from "@/stores/playback-store"; +import { useProjectStore } from "@/stores/project-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { toast } from "sonner"; +import { + TooltipProvider, + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; +import { + Pause, + Play, + SkipBack, + Bookmark, + Magnet, + Link, + ZoomOut, + ZoomIn, + Copy, + Trash2, + Snowflake, + ArrowLeftToLine, + ArrowRightToLine, + SplitSquareHorizontal, + Scissors, + LayersIcon, +} from "lucide-react"; +import { + SplitButton, + SplitButtonLeft, + SplitButtonRight, + SplitButtonSeparator, +} from "@/components/ui/split-button"; +import { Slider } from "@/components/ui/slider"; +import { DEFAULT_FPS } from "@/stores/project-store"; +import { formatTimeCode } from "@/lib/time"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { EditableTimecode } from "@/components/ui/editable-timecode"; + +export function TimelineToolbar({ + zoomLevel, + setZoomLevel, +}: { + zoomLevel: number; + setZoomLevel: (zoom: number) => void; +}) { + const { + tracks, + addTrack, + addElementToTrack, + removeElementFromTrack, + removeElementFromTrackWithRipple, + selectedElements, + clearSelectedElements, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + snappingEnabled, + toggleSnapping, + rippleEditingEnabled, + toggleRippleEditing, + } = useTimelineStore(); + const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); + const { toggleBookmark, isBookmarked, activeProject } = useProjectStore(); + + const handleSplitSelected = () => { + if (selectedElements.length === 0) return; + let splitCount = 0; + selectedElements.forEach(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (element && track) { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + const newElementId = splitElement(trackId, elementId, currentTime); + if (newElementId) splitCount++; + } + } + }); + if (splitCount === 0) { + toast.error("Playhead must be within selected elements to split"); + } + }; + + const handleDuplicateSelected = () => { + if (selectedElements.length === 0) return; + const canDuplicate = selectedElements.length === 1; + if (!canDuplicate) return; + + selectedElements.forEach(({ trackId, elementId }) => { + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((el) => el.id === elementId); + if (element) { + const newStartTime = + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1; + const { id, ...elementWithoutId } = element; + addElementToTrack(trackId, { + ...elementWithoutId, + startTime: newStartTime, + }); + } + }); + clearSelectedElements(); + }; + + const handleFreezeSelected = () => { + toast.info("Freeze frame functionality coming soon!"); + }; + + const handleSplitAndKeepLeft = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } + splitAndKeepLeft(trackId, elementId, currentTime); + }; + + const handleSplitAndKeepRight = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; + } + splitAndKeepRight(trackId, elementId, currentTime); + }; + + const handleSeparateAudio = () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one media element to separate audio"); + return; + } + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + if (!track || track.type !== "media") { + toast.error("Select a media element to separate audio"); + return; + } + separateAudio(trackId, elementId); + }; + + const handleDeleteSelected = () => { + if (selectedElements.length === 0) return; + selectedElements.forEach(({ trackId, elementId }) => { + if (rippleEditingEnabled) { + removeElementFromTrackWithRipple(trackId, elementId); + } else { + removeElementFromTrack(trackId, elementId); + } + }); + clearSelectedElements(); + }; + + const handleZoomIn = () => { + setZoomLevel(Math.min(4, zoomLevel + 0.25)); + }; + + const handleZoomOut = () => { + setZoomLevel(Math.max(0.25, zoomLevel - 0.25)); + }; + + const handleZoomSliderChange = (values: number[]) => { + setZoomLevel(values[0]); + }; + + const handleToggleBookmark = async () => { + await toggleBookmark(currentTime); + }; + + const currentBookmarked = isBookmarked(currentTime); + return ( +
+
+ + + + + + + {isPlaying ? "Pause (Space)" : "Play (Space)"} + + + + + + + Return to Start (Home / Enter) + +
+ {/* Time Display */} +
+ +
+ / +
+
+ {formatTimeCode(duration, "HH:MM:SS:FF")} +
+
+ {tracks.length === 0 && ( + <> +
+ + + + + Add a test clip to try playback + + + )} +
+ + + + + Split element (Ctrl+S) + + + + + + Split and keep left (Ctrl+Q) + + + + + + Split and keep right (Ctrl+W) + + + + + + Separate audio (Ctrl+D) + + + + + + Duplicate element (Ctrl+D) + + + + + + Freeze frame (F) + + + + + + Delete element (Delete) + +
+ + + + + + {currentBookmarked ? "Remove bookmark" : "Add bookmark"} + + + +
+
+ + Main scene + + {}}> + + + +
+
+ + + + + + Auto snapping + + + + + + + {rippleEditingEnabled + ? "Disable Ripple Editing" + : "Enable Ripple Editing"} + + + + +
+
+ + + +
+
+
+ ); +} diff --git a/apps/web/src/components/ui/split-button.tsx b/apps/web/src/components/ui/split-button.tsx new file mode 100644 index 00000000..1ef29500 --- /dev/null +++ b/apps/web/src/components/ui/split-button.tsx @@ -0,0 +1,90 @@ +import { Button, ButtonProps } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { ReactNode, forwardRef } from "react"; +import { cn } from "@/lib/utils"; + +interface SplitButtonProps { + children: ReactNode; + className?: string; +} + +interface SplitButtonSideProps extends Omit { + children: ReactNode; +} + +const SplitButton = forwardRef( + ({ children, className, ...props }, ref) => { + return ( +
+ {children} +
+ ); + } +); +SplitButton.displayName = "SplitButton"; + +const SplitButtonSide = forwardRef< + HTMLButtonElement, + SplitButtonSideProps & { paddingClass: string } +>(({ children, className, paddingClass, onClick, ...props }, ref) => { + return ( + + ); +}); +SplitButtonSide.displayName = "SplitButtonSide"; + +const SplitButtonLeft = forwardRef( + ({ ...props }, ref) => { + return ; + } +); +SplitButtonLeft.displayName = "SplitButtonLeft"; + +const SplitButtonRight = forwardRef( + ({ ...props }, ref) => { + return ; + } +); +SplitButtonRight.displayName = "SplitButtonRight"; + +const SplitButtonSeparator = forwardRef( + ({ className, ...props }, ref) => { + return ( + + ); + } +); +SplitButtonSeparator.displayName = "SplitButtonSeparator"; + +export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator }; -- cgit v1.3.1 From 350b55081cde3a4332c982ea5c53b7d359029f6b Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 1 Sep 2025 16:17:06 +0200 Subject: refactor(core): switch to scene-based architecture --- apps/web/src/app/layout.tsx | 5 +- apps/web/src/components/editor/scenes-view.tsx | 64 ++++++ .../editor/timeline/timeline-toolbar.tsx | 13 +- apps/web/src/components/footer.tsx | 17 -- .../providers/migrators/scenes-migrator.tsx | 176 +++++++++++++++++ apps/web/src/components/ui/sheet.tsx | 12 +- apps/web/src/lib/fetch-github-stars.ts | 29 --- apps/web/src/lib/storage/storage-service.ts | 159 +++++++++++---- apps/web/src/lib/storage/types.ts | 20 +- apps/web/src/stores/media-store.ts | 8 +- apps/web/src/stores/project-store.ts | 145 +++++++++----- apps/web/src/stores/scene-store.ts | 219 +++++++++++++++++++++ apps/web/src/stores/sounds-store.ts | 4 +- apps/web/src/stores/timeline-store.ts | 76 ++++++- apps/web/src/types/project.ts | 10 + 15 files changed, 793 insertions(+), 164 deletions(-) create mode 100644 apps/web/src/components/editor/scenes-view.tsx create mode 100644 apps/web/src/components/providers/migrators/scenes-migrator.tsx delete mode 100644 apps/web/src/lib/fetch-github-stars.ts create mode 100644 apps/web/src/stores/scene-store.ts (limited to 'apps/web/src/components/ui') diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index ee0bdbd2..7e32ea22 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -5,6 +5,7 @@ import "./globals.css"; import { Toaster } from "../components/ui/sonner"; import { TooltipProvider } from "../components/ui/tooltip"; import { StorageProvider } from "../components/storage-provider"; +import { ScenesMigrator } from "../components/providers/migrators/scenes-migrator"; import { baseMetaData } from "./metadata"; import { defaultFont } from "../lib/font-config"; import { BotIdClient } from "botid/client"; @@ -36,7 +37,9 @@ export default function RootLayout({ - {children} + + {children} +