From df1e80b5287ff71867bad1a1bfebb102cef7d5bf Mon Sep 17 00:00:00 2001 From: Brandon McConnell Date: Wed, 30 Jul 2025 13:15:09 -0400 Subject: Lay groundwork for Tailwind v4 upgrade --- apps/web/src/components/editor/timeline/index.tsx | 14 +++++++------- .../src/components/editor/timeline/timeline-element.tsx | 2 +- .../src/components/editor/timeline/timeline-playhead.tsx | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) (limited to 'apps/web/src/components/editor/timeline') diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index bdb4433c..7a3011d0 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -543,7 +543,7 @@ export function Timeline() { {/* Timeline Header with Ruler */}
{/* Track Labels Header */} -
+
{/* Empty space */} . @@ -652,7 +652,7 @@ export function Timeline() { {tracks.length > 0 && (
@@ -667,7 +667,7 @@ export function Timeline() {
{track.muted && ( - + Muted )} @@ -745,7 +745,7 @@ export function Timeline() { />
- + { e.stopPropagation(); @@ -775,13 +775,13 @@ function TrackIcon({ track }: { track: TimelineTrack }) { return ( <> {track.type === "media" && ( -
- + Split at playhead diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx index b33166c0..ea70419b 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx @@ -126,7 +126,7 @@ export function TimelinePlayhead({ return (
); -- cgit v1.3.1 From f255ccb818358b90edd0c1bc1f3a690dc1e729ac Mon Sep 17 00:00:00 2001 From: melad Date: Thu, 31 Jul 2025 13:50:22 +0300 Subject: feat: add hide or show for media elements, mute or unmute for audio elements (#500) * feat: create external tools section (#493) * feat: create external tools section * fix: better wording * Feature, added hide or show for media elements, mute or unmute for audio elements both effective on timeline and preview panel, with overlay icon for indicating so and state storing, * Minor UI vertical separator between magnifier and other buttons * fixed the AbortError issue in the AudioWaveform component. The error was occurring because of a race condition during cleanup when the component unmounts. * feat: implement bookmarking functionality in the timeline and project storage - Added bookmark management methods in the project store for toggling and checking bookmarks. - Updated the timeline component to display bookmark markers and integrate bookmark actions in the context menu. - Enhanced the storage service to handle bookmarks in project serialization and deserialization. - Updated project types to include bookmarks as an array of numbers. --------- Co-authored-by: Dominik K. Co-authored-by: Maze Winther --- apps/web/src/components/editor/audio-waveform.tsx | 85 ++++++++++++++----- apps/web/src/components/editor/preview-panel.tsx | 1 + apps/web/src/components/editor/timeline/index.tsx | 63 ++++++++++++++ .../editor/timeline/timeline-element.tsx | 51 +++++++++++- apps/web/src/lib/storage/storage-service.ts | 4 + apps/web/src/lib/storage/types.ts | 1 + apps/web/src/stores/project-store.ts | 97 ++++++++++++++++++++++ apps/web/src/stores/timeline-store.ts | 19 +++++ apps/web/src/types/project.ts | 1 + apps/web/src/types/timeline.ts | 1 + 10 files changed, 302 insertions(+), 21 deletions(-) (limited to 'apps/web/src/components/editor/timeline') diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx index a673f995..372a31c5 100644 --- a/apps/web/src/components/editor/audio-waveform.tsx +++ b/apps/web/src/components/editor/audio-waveform.tsx @@ -19,22 +19,21 @@ const AudioWaveform: React.FC = ({ useEffect(() => { let mounted = true; - + let ws = wavesurfer.current; + const initWaveSurfer = async () => { if (!waveformRef.current || !audioUrl) return; try { - // Clean up any existing instance - if (wavesurfer.current) { - try { - wavesurfer.current.destroy(); - } catch (e) { - // Silently ignore destroy errors - } + // Clear any existing instance safely + if (ws) { + // Instead of immediately destroying, just set to null + // We'll destroy it outside this function wavesurfer.current = null; } - wavesurfer.current = WaveSurfer.create({ + // Create a fresh instance + const newWaveSurfer = WaveSurfer.create({ container: waveformRef.current, waveColor: "rgba(255, 255, 255, 0.6)", progressColor: "rgba(255, 255, 255, 0.9)", @@ -46,15 +45,28 @@ const AudioWaveform: React.FC = ({ interact: false, }); + // Assign to ref only if component is still mounted + if (mounted) { + wavesurfer.current = newWaveSurfer; + } else { + // Component unmounted during initialization, clean up + try { + newWaveSurfer.destroy(); + } catch (e) { + // Ignore destroy errors + } + return; + } + // Event listeners - wavesurfer.current.on("ready", () => { + newWaveSurfer.on("ready", () => { if (mounted) { setIsLoading(false); setError(false); } }); - wavesurfer.current.on("error", (err) => { + newWaveSurfer.on("error", (err) => { console.error("WaveSurfer error:", err); if (mounted) { setError(true); @@ -62,7 +74,7 @@ const AudioWaveform: React.FC = ({ } }); - await wavesurfer.current.load(audioUrl); + await newWaveSurfer.load(audioUrl); } catch (err) { console.error("Failed to initialize WaveSurfer:", err); if (mounted) { @@ -72,17 +84,50 @@ const AudioWaveform: React.FC = ({ } }; - initWaveSurfer(); - - return () => { - mounted = false; - if (wavesurfer.current) { + // First safely destroy previous instance if it exists + if (ws) { + // Use this pattern to safely destroy the previous instance + const wsToDestroy = ws; + // Detach from ref immediately + wavesurfer.current = null; + + // Wait a tick to destroy so any pending operations can complete + requestAnimationFrame(() => { try { - wavesurfer.current.destroy(); + wsToDestroy.destroy(); } catch (e) { - // Silently ignore destroy errors + // Ignore errors during destroy + } + // Only initialize new instance after destroying the old one + if (mounted) { + initWaveSurfer(); } - wavesurfer.current = null; + }); + } else { + // No previous instance to clean up, initialize directly + initWaveSurfer(); + } + + return () => { + // Mark component as unmounted + mounted = false; + + // Store reference to current wavesurfer instance + const wsToDestroy = wavesurfer.current; + + // Immediately clear the ref to prevent accessing it after unmount + wavesurfer.current = null; + + // If we have an instance to clean up, do it safely + if (wsToDestroy) { + // Delay destruction to avoid race conditions + requestAnimationFrame(() => { + try { + wsToDestroy.destroy(); + } catch (e) { + // Ignore destroy errors - they're expected + } + }); } }; }, [audioUrl, height]); diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 39c6795f..55d52bed 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -234,6 +234,7 @@ export function PreviewPanel() { tracks.forEach((track) => { track.elements.forEach((element) => { + if (element.hidden) return; const elementStart = element.startTime; const elementEnd = element.startTime + diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 078f1258..9833adad 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -19,6 +19,7 @@ import { Link, ZoomIn, ZoomOut, + Bookmark, } from "lucide-react"; import { Tooltip, @@ -651,6 +652,30 @@ export function Timeline() { ); }).filter(Boolean); })()} + + {/* Bookmark markers */} + {(() => { + const { activeProject } = useProjectStore.getState(); + if (!activeProject?.bookmarks?.length) return null; + + return activeProject.bookmarks.map((bookmarkTime, i) => ( +
{ + e.stopPropagation(); + usePlaybackStore.getState().seek(bookmarkTime); + }} + > +
+ +
+
+ )); + })()} @@ -773,6 +798,23 @@ export function Timeline() { e.stopPropagation()}> Track settings (soon) + {activeProject?.bookmarks?.length && activeProject.bookmarks.length > 0 && ( + <> + Bookmarks + {activeProject.bookmarks.map((bookmarkTime, i) => ( + { + e.stopPropagation(); + seek(bookmarkTime); + }} + > + + {bookmarkTime.toFixed(1)}s + + ))} + + )}
))} @@ -828,6 +870,7 @@ function TimelineToolbar({ toggleRippleEditing, } = useTimelineStore(); const { currentTime, duration, isPlaying, toggle } = usePlaybackStore(); + const { toggleBookmark, isBookmarked } = useProjectStore(); // Action handlers const handleSplitSelected = () => { @@ -957,6 +1000,13 @@ function TimelineToolbar({ const handleZoomSliderChange = (values: number[]) => { setZoomLevel(values[0]); }; + + const handleToggleBookmark = async () => { + await toggleBookmark(currentTime); + }; + + // Check if the current time is bookmarked + const currentBookmarked = isBookmarked(currentTime); return (
@@ -1088,6 +1138,17 @@ function TimelineToolbar({ Delete element (Delete) +
+ + + + + + {currentBookmarked ? "Remove bookmark" : "Add bookmark"} + +
@@ -1121,6 +1182,8 @@ function TimelineToolbar({ + +
+ {element.hidden && ( +
+ {isAudio ? ( + + ) : ( + + )} +
+ )} + {isSelected && ( <>
Split at playhead + + {isAudio ? ( + element.hidden ? ( + + ) : ( + + ) + ) : element.hidden ? ( + + ) : ( + + )} + + {isAudio + ? element.hidden + ? "Unmute" + : "Mute" + : element.hidden + ? "Show" + : "Hide"}{" "} + {element.type === "text" ? "text" : "clip"} + + Duplicate {element.type === "text" ? "text" : "clip"} diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts index 0037dbdb..2ff63ac7 100644 --- a/apps/web/src/lib/storage/storage-service.ts +++ b/apps/web/src/lib/storage/storage-service.ts @@ -63,6 +63,8 @@ class StorageService { backgroundColor: project.backgroundColor, backgroundType: project.backgroundType, blurIntensity: project.blurIntensity, + bookmarks: project.bookmarks, + fps: project.fps, }; await this.projectsAdapter.set(project.id, serializedProject); @@ -83,6 +85,8 @@ class StorageService { backgroundColor: serializedProject.backgroundColor, backgroundType: serializedProject.backgroundType, blurIntensity: serializedProject.blurIntensity, + bookmarks: serializedProject.bookmarks, + fps: serializedProject.fps, }; } diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts index b662c164..0cfafd47 100644 --- a/apps/web/src/lib/storage/types.ts +++ b/apps/web/src/lib/storage/types.ts @@ -37,6 +37,7 @@ export interface StorageConfig { export type SerializedProject = Omit & { createdAt: string; updatedAt: string; + bookmarks?: number[]; }; // Extend FileSystemDirectoryHandle with missing async iterator methods diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts index 7bb38039..69aca3ec 100644 --- a/apps/web/src/stores/project-store.ts +++ b/apps/web/src/stores/project-store.ts @@ -27,6 +27,11 @@ interface ProjectStore { options?: { backgroundColor?: string; blurIntensity?: number } ) => Promise; updateProjectFps: (fps: number) => Promise; + + // Bookmark methods + toggleBookmark: (time: number) => Promise; + isBookmarked: (time: number) => boolean; + removeBookmark: (time: number) => Promise; getFilteredAndSortedProjects: ( searchQuery: string, @@ -39,6 +44,97 @@ export const useProjectStore = create((set, get) => ({ savedProjects: [], isLoading: true, isInitialized: false, + + // Implementation of bookmark methods + toggleBookmark: async (time: number) => { + const { activeProject } = get(); + if (!activeProject) return; + + // Round time to the nearest frame + const fps = activeProject.fps || 30; + const frameTime = Math.round(time * fps) / fps; + + const bookmarks = activeProject.bookmarks || []; + let updatedBookmarks: number[]; + + // Check if already bookmarked + const bookmarkIndex = bookmarks.findIndex( + bookmark => Math.abs(bookmark - frameTime) < 0.001 + ); + + if (bookmarkIndex !== -1) { + // Remove bookmark + updatedBookmarks = bookmarks.filter((_, i) => i !== bookmarkIndex); + } else { + // Add bookmark + updatedBookmarks = [...bookmarks, frameTime].sort((a, b) => a - b); + } + + const updatedProject = { + ...activeProject, + bookmarks: updatedBookmarks, + updatedAt: new Date(), + }; + + try { + await storageService.saveProject(updatedProject); + set({ activeProject: updatedProject }); + await get().loadAllProjects(); // Refresh the list + } catch (error) { + console.error("Failed to update project bookmarks:", error); + toast.error("Failed to update bookmarks", { + description: "Please try again", + }); + } + }, + + isBookmarked: (time: number) => { + const { activeProject } = get(); + if (!activeProject || !activeProject.bookmarks) return false; + + // Round time to the nearest frame + const fps = activeProject.fps || 30; + const frameTime = Math.round(time * fps) / fps; + + return activeProject.bookmarks.some( + bookmark => Math.abs(bookmark - frameTime) < 0.001 + ); + }, + + removeBookmark: async (time: number) => { + const { activeProject } = get(); + if (!activeProject || !activeProject.bookmarks) return; + + // Round time to the nearest frame + const fps = activeProject.fps || 30; + const frameTime = Math.round(time * fps) / fps; + + const updatedBookmarks = activeProject.bookmarks.filter( + bookmark => Math.abs(bookmark - frameTime) >= 0.001 + ); + + if (updatedBookmarks.length === activeProject.bookmarks.length) { + // No bookmark found to remove + return; + } + + const updatedProject = { + ...activeProject, + bookmarks: updatedBookmarks, + updatedAt: new Date(), + }; + + try { + await storageService.saveProject(updatedProject); + set({ activeProject: updatedProject }); + await get().loadAllProjects(); // Refresh the list + } catch (error) { + console.error("Failed to update project bookmarks:", error); + toast.error("Failed to remove bookmark", { + description: "Please try again", + }); + } + }, createNewProject: async (name: string) => { const newProject: TProject = { @@ -50,6 +146,7 @@ export const useProjectStore = create((set, get) => ({ backgroundColor: "#000000", backgroundType: "color", blurIntensity: 8, + bookmarks: [], }; set({ activeProject: newProject }); diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index f3497be9..0f441176 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -126,6 +126,7 @@ interface TimelineStore { pushHistory?: boolean ) => void; toggleTrackMute: (trackId: string) => void; + toggleElementHidden: (trackId: string, elementId: string) => void; // Split operations for elements splitElement: ( @@ -868,6 +869,24 @@ export const useTimelineStore = create((set, get) => { ); }, + toggleElementHidden: (trackId, elementId) => { + get().pushHistory(); + updateTracksAndSave( + get()._tracks.map((track) => + track.id === trackId + ? { + ...track, + elements: track.elements.map((element) => + element.id === elementId + ? { ...element, hidden: !element.hidden } + : element + ), + } + : track + ) + ); + }, + updateTextElement: (trackId, elementId, updates) => { get().pushHistory(); updateTracksAndSave( diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts index 58d37b03..f52cba73 100644 --- a/apps/web/src/types/project.ts +++ b/apps/web/src/types/project.ts @@ -9,4 +9,5 @@ export interface TProject { backgroundType?: "color" | "blur"; blurIntensity?: number; // in pixels (4, 8, 18) fps?: number; + bookmarks?: number[]; } diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index ef20c642..cd17b7dd 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -11,6 +11,7 @@ interface BaseTimelineElement { startTime: number; trimStart: number; trimEnd: number; + hidden?: boolean; } // Media element that references MediaStore -- cgit v1.3.1 From 09cb8c8d984e3e82f60e28b352508c876423fbdf Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 1 Aug 2025 09:51:54 +0200 Subject: fix: resize not matching with frames --- .../components/editor/timeline/timeline-track.tsx | 96 ++++++++++++---------- apps/web/src/hooks/use-timeline-element-resize.ts | 29 +++++-- 2 files changed, 75 insertions(+), 50 deletions(-) (limited to 'apps/web/src/components/editor/timeline') diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 097fc051..1982d03e 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -68,45 +68,51 @@ export function TimelineTrackContent({ elementDuration: number, excludeElementId?: string ) => { - if (!snappingEnabled) { - // Use frame snapping if project has FPS, otherwise use decimal snapping - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - return snapTimeToFrame(dropTime, projectFps); - } + // Always apply frame snapping first + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + let finalTime = snapTimeToFrame(dropTime, projectFps); + + // Additionally apply element snapping if enabled + if (snappingEnabled) { + // Try snapping both start and end edges for drops + const startSnapResult = snapElementEdge( + dropTime, + elementDuration, + tracks, + currentTime, + zoomLevel, + excludeElementId, + true // snap to start edge + ); - // Try snapping both start and end edges for drops - const startSnapResult = snapElementEdge( - dropTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - excludeElementId, - true // snap to start edge - ); + const endSnapResult = snapElementEdge( + dropTime, + elementDuration, + tracks, + currentTime, + zoomLevel, + excludeElementId, + false // snap to end edge + ); - const endSnapResult = snapElementEdge( - dropTime, - elementDuration, - tracks, - currentTime, - zoomLevel, - excludeElementId, - false // snap to end edge - ); + // Choose the snap result with the smaller distance (closer snap) + let bestSnapResult = startSnapResult; + if ( + endSnapResult.snapPoint && + (!startSnapResult.snapPoint || + endSnapResult.snapDistance < startSnapResult.snapDistance) + ) { + bestSnapResult = endSnapResult; + } - // Choose the snap result with the smaller distance (closer snap) - let bestSnapResult = startSnapResult; - if ( - endSnapResult.snapPoint && - (!startSnapResult.snapPoint || - endSnapResult.snapDistance < startSnapResult.snapDistance) - ) { - bestSnapResult = endSnapResult; + // Only use element snapping if it found a snap point, otherwise keep frame-snapped time + if (bestSnapResult.snapPoint) { + finalTime = bestSnapResult.snappedTime; + } } - return bestSnapResult.snappedTime; + return finalTime; }; const timelineRef = useRef(null); @@ -148,9 +154,13 @@ export function TimelineTrackContent({ ); const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - // Apply snapping if enabled - let finalTime = adjustedTime; + // Always apply frame snapping first + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + let finalTime = snapTimeToFrame(adjustedTime, projectFps); let snapPoint = null; + + // Additionally apply element snapping if enabled if (snappingEnabled) { // Find the element being dragged to get its duration let elementDuration = 5; // fallback duration @@ -196,18 +206,16 @@ export function TimelineTrackContent({ bestSnapResult = endSnapResult; } - finalTime = bestSnapResult.snappedTime; - snapPoint = bestSnapResult.snapPoint; + // Only use element snapping if it found a snap point, otherwise keep frame-snapped time + if (bestSnapResult.snapPoint) { + finalTime = bestSnapResult.snappedTime; + snapPoint = bestSnapResult.snapPoint; + } // Notify parent component about snap point change onSnapPointChange?.(snapPoint); } else { - // Use frame snapping if project has FPS, otherwise use decimal snapping - const projectStore = useProjectStore.getState(); - const projectFps = projectStore.activeProject?.fps || 30; - finalTime = snapTimeToFrame(adjustedTime, projectFps); - - // Clear snap point when not snapping + // Clear snap point when element snapping is disabled onSnapPointChange?.(null); } diff --git a/apps/web/src/hooks/use-timeline-element-resize.ts b/apps/web/src/hooks/use-timeline-element-resize.ts index d57533e0..ba13e05a 100644 --- a/apps/web/src/hooks/use-timeline-element-resize.ts +++ b/apps/web/src/hooks/use-timeline-element-resize.ts @@ -221,17 +221,34 @@ export function useTimelineElementResize({ } } else { // Normal trimming within original duration - const maxTrimEnd = element.duration - resizing.initialTrimStart - 0.1; // Leave at least 0.1s visible - const newTrimEnd = snapTimeToFrame( - Math.max(0, Math.min(maxTrimEnd, calculated)), - projectFps + // Calculate the desired end time based on mouse movement + const currentEndTime = + element.startTime + + element.duration - + element.trimStart - + element.trimEnd; + const desiredEndTime = currentEndTime + deltaTime; + + // Snap the desired end time to frame + const snappedEndTime = snapTimeToFrame(desiredEndTime, projectFps); + + // Calculate what trimEnd should be to achieve this snapped end time + const newTrimEnd = Math.max( + 0, + element.duration - + element.trimStart - + (snappedEndTime - element.startTime) ); + // Ensure we don't trim more than available content (leave at least 0.1s visible) + const maxTrimEnd = element.duration - element.trimStart - 0.1; + const finalTrimEnd = Math.min(maxTrimEnd, newTrimEnd); + updateElementTrim( track.id, element.id, - resizing.initialTrimStart, - newTrimEnd, + element.trimStart, + finalTrimEnd, false ); } -- cgit v1.3.1 From 68da487f304018b6e8d06459def8fb45514ab7d4 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 1 Aug 2025 19:57:26 +0200 Subject: style: complete re-design of whole editor --- apps/web/src/app/editor/[project_id]/page.tsx | 6 +- apps/web/src/app/globals.css | 83 ++-- apps/web/src/app/layout.tsx | 2 +- apps/web/src/components/editor-header.tsx | 36 +- .../src/components/editor/media-panel/index.tsx | 6 +- .../src/components/editor/media-panel/tabbar.tsx | 5 +- .../components/editor/media-panel/views/audio.tsx | 1 + .../components/editor/media-panel/views/media.tsx | 507 +++++++++++++++------ .../components/editor/media-panel/views/text.tsx | 2 +- apps/web/src/components/editor/preview-panel.tsx | 157 +------ apps/web/src/components/editor/timeline/index.tsx | 61 ++- .../editor/timeline/timeline-element.tsx | 142 ++---- .../editor/timeline/timeline-playhead.tsx | 6 +- .../web/src/components/keyboard-shortcuts-help.tsx | 2 +- apps/web/src/components/ui/button.tsx | 2 +- apps/web/src/components/ui/draggable-item.tsx | 85 ++-- apps/web/src/components/ui/input.tsx | 4 +- apps/web/src/constants/timeline-constants.ts | 16 +- apps/web/src/stores/panel-store.ts | 6 + 19 files changed, 625 insertions(+), 504 deletions(-) (limited to 'apps/web/src/components/editor/timeline') diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 5e294fe7..03579144 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -167,7 +167,7 @@ export default function Editor() { {/* Main content area */} {/* Tools Panel */} @@ -214,7 +214,7 @@ export default function Editor() { minSize={15} maxSize={70} onResize={setTimeline} - className="min-h-0 px-2 pb-2" + className="min-h-0 px-3 pb-3" > diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 96fef9b0..d612e27e 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -9,38 +9,40 @@ :root { /* Custom colors - light mode (default) */ - --background: hsl(0 0% 100%); - --foreground: hsl(0 0% 3.9%); - --card: hsl(0 0% 100%); - --card-foreground: hsl(0 0% 3.9%); - --popover: hsl(0 0% 100%); - --popover-foreground: hsl(0 0% 3.9%); - --primary: hsl(0 0% 9%); - --primary-foreground: hsl(0 0% 98%); - --secondary: hsl(0 0% 96.1%); - --secondary-foreground: hsl(0 0% 9%); - --muted: hsl(0 0% 96.1%); - --muted-foreground: hsl(0 0% 45.1%); - --accent: hsl(0 0% 96.1%); - --accent-foreground: hsl(0 0% 9%); - --destructive: hsl(0 84.2% 60.2%); - --destructive-foreground: hsl(0 0% 98%); - --border: hsl(0 0% 89.8%); - --input: hsl(0 0% 89.8%); - --ring: hsl(0 0% 3.9%); - --chart-1: hsl(12 76% 61%); - --chart-2: hsl(173 58% 39%); - --chart-3: hsl(197 37% 24%); - --chart-4: hsl(43 74% 66%); - --chart-5: hsl(27 87% 67%); - --sidebar-background: hsl(0 0% 100%); - --sidebar-foreground: hsl(0 0% 3.9%); - --sidebar-primary: hsl(0 0% 9%); - --sidebar-primary-foreground: hsl(0 0% 98%); - --sidebar-accent: hsl(0 0% 96.1%); - --sidebar-accent-foreground: hsl(0 0% 9%); - --sidebar-border: hsl(0 0% 89.8%); - --sidebar-ring: hsl(0 0% 3.9%); + --background: hsl(0, 0%, 100%); + --foreground: hsl(0 0% 11%); + --card: hsl(0 0% 85.1%); + --card-foreground: hsl(0 0% 2%); + --popover: hsl(0, 0%, 100%); + --popover-foreground: hsl(0 0% 2%); + --primary: hsl(205, 84%, 47%); + --primary-foreground: hsl(0 0% 91%); + --secondary: hsl(216, 13%, 92%); + --secondary-foreground: hsl(0 0% 2%); + --muted: hsl(0 0% 85.1%); + --muted-foreground: hsl(0 0% 36.1%); + --accent: hsl(216, 13%, 92%); + --accent-foreground: hsl(0 0% 2%); + --destructive: hsl(0 100% 40%); + --destructive-foreground: hsl(0, 0%, 100%); + --border: hsl(0 0% 83%); + --input: hsl(0 0% 85.1%); + --ring: hsl(0, 0%, 55%); + --chart-1: hsl(220 70% 50%); + --chart-2: hsl(160 60% 45%); + --chart-3: hsl(30 80% 55%); + --chart-4: hsl(280 65% 60%); + --chart-5: hsl(340 75% 55%); + --sidebar-background: hsl(0 0% 96.1%); + --sidebar-foreground: hsl(0 0% 2%); + --sidebar-primary: hsl(0 0% 2%); + --sidebar-primary-foreground: hsl(0 0% 91%); + --sidebar-accent: hsl(0 0% 85.1%); + --sidebar-accent-foreground: hsl(0 0% 2%); + --sidebar-border: hsl(0 0% 85.1%); + --sidebar-ring: hsl(0 0% 16.9%); + --panel-background: hsl(216 13% 92%); + --panel-accent: hsl(216, 8%, 86%); /* Radius base */ --radius: 1rem; @@ -224,3 +226,20 @@ display: none; } } + +@utility scrollbar-thin { + &::-webkit-scrollbar { + width: 8px; + height: 8px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; + } + &::-webkit-scrollbar-thumb:hover { + background: var(--muted-foreground); + } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 5c1d7520..a64c4440 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -30,7 +30,7 @@ export default function RootLayout({ - + {children} diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor-header.tsx index 99cea3e8..77202c36 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor-header.tsx @@ -1,7 +1,14 @@ "use client"; import { Button } from "./ui/button"; -import { ChevronDown, ArrowLeft, Download, SquarePen, Trash } from "lucide-react"; +import { + ChevronDown, + ArrowLeft, + Download, + SquarePen, + Trash, + Sun, +} from "lucide-react"; import { useTimelineStore } from "@/stores/timeline-store"; import { HeaderBase } from "./header-base"; import { formatTimeCode } from "@/lib/time"; @@ -19,14 +26,18 @@ import Link from "next/link"; import { RenameProjectDialog } from "./rename-project-dialog"; import { DeleteProjectDialog } from "./delete-project-dialog"; import { useRouter } from "next/navigation"; -import { FaDiscord, FaGithub } from "react-icons/fa6"; +import { FaDiscord } from "react-icons/fa6"; +import { useTheme } from "next-themes"; +import { usePlaybackStore } from "@/stores/playback-store"; export function EditorHeader() { const { getTotalDuration } = useTimelineStore(); + const { currentTime } = usePlaybackStore(); const { activeProject, renameProject, deleteProject } = useProjectStore(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const router = useRouter(); + const { theme, setTheme } = useTheme(); const handleExport = () => { // TODO: Implement export functionality @@ -120,7 +131,11 @@ export function EditorHeader() { const centerContent = (
- + + {formatTimeCode(currentTime, "HH:MM:SS:FF", activeProject?.fps || 30)} + + / + {formatTimeCode( getTotalDuration(), "HH:MM:SS:FF", @@ -135,11 +150,20 @@ export function EditorHeader() { + ); @@ -149,7 +173,7 @@ export function EditorHeader() { leftContent={leftContent} centerContent={centerContent} rightContent={rightContent} - className="bg-background h-[3.2rem] px-4 items-center" + className="bg-background h-[3.2rem] px-3 items-center mt-0.5" /> ); } diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 359612f8..a5cd6686 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -5,6 +5,7 @@ import { MediaView } from "./views/media"; import { useMediaPanelStore, Tab } from "./store"; import { TextView } from "./views/text"; import { AudioView } from "./views/audio"; +import { Separator } from "@/components/ui/separator"; export function MediaPanel() { const { activeTab } = useMediaPanelStore(); @@ -46,9 +47,10 @@ export function MediaPanel() { }; return ( -
+
-
{viewMap[activeTab]}
+ +
{viewMap[activeTab]}
); } diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index 92482559..a3733e4d 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -61,7 +61,7 @@ export function TabBar() { }, []); return ( -
+
{(Object.keys(tabs) as Tab[]).map((tabKey) => { const tab = tabs[tabKey]; @@ -83,7 +83,6 @@ export function TabBar() { key={tabKey} > - {tab.label}
); })} diff --git a/apps/web/src/components/editor/media-panel/views/audio.tsx b/apps/web/src/components/editor/media-panel/views/audio.tsx index ba64d749..0d3c3f1e 100644 --- a/apps/web/src/components/editor/media-panel/views/audio.tsx +++ b/apps/web/src/components/editor/media-panel/views/audio.tsx @@ -9,6 +9,7 @@ export function AudioView() {
setSearch(e.target.value)} /> diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index d1533736..772ef4ab 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -3,8 +3,18 @@ import { useDragDrop } from "@/hooks/use-drag-drop"; import { processMediaFiles } from "@/lib/media-processing"; import { useMediaStore, type MediaItem } from "@/stores/media-store"; -import { Image, Loader2, Music, Plus, Video } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { + ArrowDown01, + CloudUpload, + Grid2X2, + Image, + List, + Loader2, + Music, + Search, + Video, +} from "lucide-react"; +import { useEffect, useRef, useState, useMemo } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay"; @@ -14,27 +24,62 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu"; -import { Input } from "@/components/ui/input"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { DraggableMediaItem } from "@/components/ui/draggable-item"; import { useProjectStore } from "@/stores/project-store"; import { useTimelineStore } from "@/stores/timeline-store"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { usePanelStore } from "@/stores/panel-store"; + +function MediaItemWithContextMenu({ + item, + children, + onRemove, +}: { + item: MediaItem; + children: React.ReactNode; + onRemove: (e: React.MouseEvent, id: string) => Promise; +}) { + return ( + + {children} + + Export clips + onRemove(e, item.id)} + > + Delete + + + + ); +} export function MediaView() { const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); const { activeProject } = useProjectStore(); + const { mediaViewMode, setMediaViewMode } = usePanelStore(); const fileInputRef = useRef(null); const [isProcessing, setIsProcessing] = useState(false); const [progress, setProgress] = useState(0); const [searchQuery, setSearchQuery] = useState(""); const [mediaFilter, setMediaFilter] = useState("all"); + const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">( + "name" + ); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const processFiles = async (files: FileList | File[]) => { if (!files || files.length === 0) return; @@ -100,7 +145,7 @@ export function MediaView() { const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); useEffect(() => { - const filtered = mediaItems.filter((item) => { + let filtered = mediaItems.filter((item) => { if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { return false; } @@ -115,79 +160,117 @@ export function MediaView() { return true; }); + filtered.sort((a, b) => { + let valueA: any; + let valueB: any; + + switch (sortBy) { + case "name": + valueA = a.name.toLowerCase(); + valueB = b.name.toLowerCase(); + break; + case "type": + valueA = a.type; + valueB = b.type; + break; + case "duration": + valueA = a.duration || 0; + valueB = b.duration || 0; + break; + case "size": + valueA = a.file.size; + valueB = b.file.size; + break; + default: + return 0; + } + + if (valueA < valueB) return sortOrder === "asc" ? -1 : 1; + if (valueA > valueB) return sortOrder === "asc" ? 1 : -1; + return 0; + }); + setFilteredMediaItems(filtered); - }, [mediaItems, mediaFilter, searchQuery]); + }, [mediaItems, mediaFilter, searchQuery, sortBy, sortOrder]); - const renderPreview = (item: MediaItem) => { - // Render a preview for each media type (image, video, audio, unknown) - if (item.type === "image") { - return ( -
- {item.name} -
- ); - } + const previewComponents = useMemo(() => { + const previews = new Map(); - if (item.type === "video") { - if (item.thumbnailUrl) { - return ( -
+ filteredMediaItems.forEach((item) => { + let preview: React.ReactNode; + + if (item.type === "image") { + preview = ( +
{item.name} -
-
+ ); + } else if (item.type === "video") { + if (item.thumbnailUrl) { + preview = ( +
+ {item.name} +
+
+ {item.duration && ( +
+ {formatDuration(item.duration)} +
+ )} +
+ ); + } else { + preview = ( +
+
+ ); + } + } else if (item.type === "audio") { + preview = ( +
+ + Audio {item.duration && ( -
+ {formatDuration(item.duration)} -
+ )}
); + } else { + preview = ( +
+ + Unknown +
+ ); } - return ( -
-
- ); - } - if (item.type === "audio") { - return ( -
- - Audio - {item.duration && ( - - {formatDuration(item.duration)} - - )} -
- ); - } + previews.set(item.id, preview); + }); - return ( -
- - Unknown -
- ); - }; + return previews; + }, [filteredMediaItems]); + + const renderPreview = (item: MediaItem) => previewComponents.get(item.id); return ( <> @@ -207,43 +290,152 @@ export function MediaView() { >
{/* Search and filter controls */} -
- - setSearchQuery(e.target.value)} - /> +
+
+ + + + + + +

+ {mediaViewMode === "grid" + ? "Switch to list view" + : "Switch to grid view"} +

+
+ + + + + + + + + { + if (sortBy === "name") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("name"); + setSortOrder("asc"); + } + }} + > + Name{" "} + {sortBy === "name" && + (sortOrder === "asc" ? "↑" : "↓")} + + { + if (sortBy === "type") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("type"); + setSortOrder("asc"); + } + }} + > + Type{" "} + {sortBy === "type" && + (sortOrder === "asc" ? "↑" : "↓")} + + { + if (sortBy === "duration") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("duration"); + setSortOrder("asc"); + } + }} + > + Duration{" "} + {sortBy === "duration" && + (sortOrder === "asc" ? "↑" : "↓")} + + { + if (sortBy === "size") { + setSortOrder( + sortOrder === "asc" ? "desc" : "asc" + ); + } else { + setSortBy("size"); + setSortOrder("asc"); + } + }} + > + File Size{" "} + {sortBy === "size" && + (sortOrder === "asc" ? "↑" : "↓")} + + + + +

+ Sort by {sortBy} ( + {sortOrder === "asc" ? "ascending" : "descending"}) +

+
+
+
+
+
- -
+
+
{isDragOver || filteredMediaItems.length === 0 ? ( + ) : mediaViewMode === "grid" ? ( + ) : ( -
- {/* Render each media item as a draggable button */} - {filteredMediaItems.map((item) => ( - - - - useTimelineStore - .getState() - .addMediaAtTime(item, currentTime) - } - rounded={false} - /> - - - Export clips - handleRemove(e, item.id)} - > - Delete - - - - ))} -
+ )}
- +
); } + +function GridView({ + filteredMediaItems, + renderPreview, + handleRemove, +}: { + filteredMediaItems: MediaItem[]; + renderPreview: (item: MediaItem) => React.ReactNode; + handleRemove: (e: React.MouseEvent, id: string) => Promise; +}) { + return ( +
+ {filteredMediaItems.map((item) => ( + + + useTimelineStore.getState().addMediaAtTime(item, currentTime) + } + rounded={false} + variant="card" + /> + + ))} +
+ ); +} + +function ListView({ + filteredMediaItems, + renderPreview, + handleRemove, + formatDuration, +}: { + filteredMediaItems: MediaItem[]; + renderPreview: (item: MediaItem) => React.ReactNode; + handleRemove: (e: React.MouseEvent, id: string) => Promise; + formatDuration: (duration: number) => string; +}) { + return ( +
+ {filteredMediaItems.map((item) => ( + + + useTimelineStore.getState().addMediaAtTime(item, currentTime) + } + variant="compact" + /> + + ))} +
+ ); +} diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index 0686190e..ca8e88c9 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -32,7 +32,7 @@ export function TextView() { +
Default text
} diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index c6f4e159..3a962025 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -5,24 +5,15 @@ import { TimelineElement, TimelineTrack } from "@/types/timeline"; import { useMediaStore, type MediaItem } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; import { useEditorStore } from "@/stores/editor-store"; -import { useAspectRatio } from "@/hooks/use-aspect-ratio"; import { VideoPlayer } from "@/components/ui/video-player"; import { AudioPlayer } from "@/components/ui/audio-player"; import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuSeparator, -} from "@/components/ui/dropdown-menu"; import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react"; import { useState, useRef, useEffect, useCallback } from "react"; import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; import { EditableTimecode } from "@/components/ui/editable-timecode"; import { FONT_CLASS_MAP } from "@/lib/font-config"; -import { BackgroundSettings } from "../background-settings"; import { useProjectStore } from "@/stores/project-store"; import { TextElementDragState } from "@/types/editor"; @@ -412,7 +403,7 @@ export function PreviewPanel() { >
🎬
-

{element.name}

+

{element.name}

); @@ -476,10 +467,10 @@ export function PreviewPanel() { return ( <> -
+
{hasAnyElements ? ( @@ -621,9 +612,9 @@ function FullscreenToolbar({ return (
-
+
/ @@ -649,7 +640,7 @@ function FullscreenToolbar({ size="icon" onClick={skipBackward} disabled={!hasAnyElements} - className="h-auto p-0 text-white hover:text-white/80" + className="h-auto p-0 text-foreground" title="Skip backward 1s" > @@ -659,7 +650,7 @@ function FullscreenToolbar({ size="icon" onClick={toggle} disabled={!hasAnyElements} - className="h-auto p-0 text-white hover:text-white/80" + className="h-auto p-0 text-foreground hover:text-foreground/80" > {isPlaying ? ( @@ -672,7 +663,7 @@ function FullscreenToolbar({ size="icon" onClick={skipForward} disabled={!hasAnyElements} - className="h-auto p-0 text-white hover:text-white/80" + className="h-auto p-0 text-foreground hover:text-foreground/80" title="Skip forward 1s" > @@ -682,7 +673,7 @@ function FullscreenToolbar({
@@ -706,7 +697,7 @@ function FullscreenToolbar({
-
+
void; getTotalDuration: () => number; }) { - const { isPlaying, seek } = usePlaybackStore(); - const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore(); - const { activeProject } = useProjectStore(); - const { - currentPreset, - isOriginal, - getOriginalAspectRatio, - getDisplayName, - canvasPresets, - } = useAspectRatio(); - - const handlePresetSelect = (preset: { width: number; height: number }) => { - setCanvasSize({ width: preset.width, height: preset.height }); - }; - - const handleOriginalSelect = () => { - const aspectRatio = getOriginalAspectRatio(); - setCanvasSizeToOriginal(aspectRatio); - }; - - const totalDuration = getTotalDuration(); - - const skipBackward = () => { - const newTime = Math.max(0, currentTime - 1); - setCurrentTime(newTime); - }; - - const skipForward = () => { - const newTime = Math.min(totalDuration, currentTime + 1); - setCurrentTime(newTime); - }; + const { isPlaying } = usePlaybackStore(); if (isExpanded) { return ( @@ -857,50 +818,15 @@ function PreviewToolbar({ return (
-
-

- - / - - {formatTimeCode( - getTotalDuration(), - "HH:MM:SS:FF", - activeProject?.fps || 30 - )} - -

-
-
- +
-
-
- - - - - - - - Original - - - {canvasPresets.map((preset) => ( - handlePresetSelect(preset)} - className={cn( - "text-xs", - currentPreset?.name === preset.name && "font-semibold" - )} - > - {preset.name} - - ))} - - - diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index c7ed833e..b26a14bb 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -1,16 +1,8 @@ "use client"; -import { useState } from "react"; -import { Button } from "../../ui/button"; import { - MoreVertical, Scissors, Trash2, - SplitSquareHorizontal, - Music, - ChevronRight, - ChevronLeft, - Type, Copy, RefreshCw, EyeOff, @@ -23,23 +15,13 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { usePlaybackStore } from "@/stores/playback-store"; import AudioWaveform from "../audio-waveform"; import { toast } from "sonner"; -import { TimelineElementProps, TrackType } from "@/types/timeline"; +import { TimelineElementProps } from "@/types/timeline"; import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; import { getTrackElementClasses, TIMELINE_CONSTANTS, getTrackHeight, } from "@/constants/timeline-constants"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, -} from "../../ui/dropdown-menu"; import { ContextMenu, ContextMenuContent, @@ -64,9 +46,6 @@ export function TimelineElement({ removeElementFromTrackWithRipple, dragState, splitElement, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, addElementToTrack, replaceElementMedia, rippleEditingEnabled, @@ -74,27 +53,20 @@ export function TimelineElement({ } = useTimelineStore(); const { currentTime } = usePlaybackStore(); - const [elementMenuOpen, setElementMenuOpen] = useState(false); - const mediaItem = element.type === "media" ? mediaItems.find((item) => item.id === element.mediaId) : null; const isAudio = mediaItem?.type === "audio"; - const { - resizing, - isResizing, - handleResizeStart, - handleResizeMove, - handleResizeEnd, - } = useTimelineElementResize({ - element, - track, - zoomLevel, - onUpdateTrim: updateElementTrim, - onUpdateDuration: updateElementDuration, - }); + const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } = + useTimelineElementResize({ + element, + track, + zoomLevel, + onUpdateTrim: updateElementTrim, + onUpdateDuration: updateElementDuration, + }); const effectiveDuration = element.duration - element.trimStart - element.trimEnd; @@ -193,9 +165,7 @@ export function TimelineElement({ if (element.type === "text") { return (
- - {element.content} - + {element.content}
); } @@ -212,86 +182,36 @@ export function TimelineElement({ const TILE_ASPECT_RATIO = 16 / 9; - if (mediaItem.type === "image") { + if ( + mediaItem.type === "image" || + (mediaItem.type === "video" && mediaItem.thumbnailUrl) + ) { // Calculate tile size based on 16:9 aspect ratio const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight - 8; // Account for padding + const tileHeight = trackHeight; const tileWidth = tileHeight * TILE_ASPECT_RATIO; - return ( -
-
- {/* Background with tiled images */} -
- {/* Overlay with vertical borders */} -
-
-
- ); - } - - const VIDEO_TILE_PADDING = 16; - const OVERLAY_SPACE_MULTIPLIER = 1.5; - - if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { - const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight - 8; // Match image padding - const tileWidth = tileHeight * TILE_ASPECT_RATIO; + const imageUrl = + mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl; + const isImage = mediaItem.type === "image"; return (
-
- {/* Background with tiled thumbnails */} +
- {/* Overlay with vertical borders */} -
@@ -346,7 +266,7 @@ export function TimelineElement({
onElementClick && onElementClick(e, element)} @@ -372,11 +292,11 @@ export function TimelineElement({ {isSelected && ( <>
handleResizeStart(e, element.id, "left")} />
handleResizeStart(e, element.id, "right")} /> @@ -407,8 +327,8 @@ export function TimelineElement({ ? "Unmute" : "Mute" : element.hidden - ? "Show" - : "Hide"}{" "} + ? "Show" + : "Hide"}{" "} {element.type === "text" ? "text" : "clip"} diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx index ea70419b..695843a4 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx @@ -70,7 +70,7 @@ export function TimelinePlayhead({ // Use timeline container height minus a few pixels for breathing room const timelineContainerHeight = timelineRef.current?.offsetHeight || 400; - const totalHeight = timelineContainerHeight - 8; // 8px padding from edges + const totalHeight = timelineContainerHeight - 4; // Get dynamic track labels width, fallback to 0 if no tracks or no ref const trackLabelsWidth = @@ -137,12 +137,12 @@ export function TimelinePlayhead({ > {/* The playhead line spanning full height */}
{/* Playhead dot indicator at the top (in ruler area) */}
); diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx index 15b1e1d8..d4d121d9 100644 --- a/apps/web/src/components/keyboard-shortcuts-help.tsx +++ b/apps/web/src/components/keyboard-shortcuts-help.tsx @@ -96,7 +96,7 @@ const EditableShortcutKey = ({ className={`inline-flex font-sans text-xs rounded px-2 min-w-6 min-h-6 leading-none items-center justify-center shadow-xs border mr-1 cursor-pointer hover:bg-opacity-80 ${ isRecording ? "border-primary bg-primary/10" - : "border-white/10 bg-black/20" + : "border bg-accent" }`} onClick={handleClick} title={ diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index e5c5d4cc..d3656790 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -19,7 +19,7 @@ const buttonVariants = cva( "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", - text: "bg-transparent p-0 rounded-none hover:text-muted-foreground", // Instead of ghost (matches app better) + text: "bg-transparent p-0 rounded-none opacity-100 hover:opacity-50 transition-opacity", // Instead of ghost (matches app better) link: "text-primary underline-offset-4 hover:underline", }, size: { diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index 4c71e4b7..ff6766fd 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -24,6 +24,7 @@ export interface DraggableMediaItemProps { showPlusOnDrag?: boolean; showLabel?: boolean; rounded?: boolean; + variant?: "card" | "compact"; } export function DraggableMediaItem({ @@ -37,6 +38,7 @@ export function DraggableMediaItem({ showPlusOnDrag = true, showLabel = true, rounded = true, + variant = "card", }: DraggableMediaItemProps) { const [isDragging, setIsDragging] = useState(false); const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); @@ -88,42 +90,62 @@ export function DraggableMediaItem({ return ( <> -
-
- +
+ + {preview} + {!isDragging && ( + + )} + + {showLabel && ( + + {name.length > 8 + ? `${name.slice(0, 16)}...${name.slice(-3)}` + : name} + + )} +
+
+ ) : ( +
+
- {preview} - {!isDragging && ( - - )} - - {showLabel && ( - - {name.length > 8 - ? `${name.slice(0, 16)}...${name.slice(-3)}` - : name} - - )} +
+ {preview} +
+ {name} +
-
+ )} {/* Custom drag preview */} {isDragging && @@ -171,7 +193,10 @@ function PlusButton({ const button = ( + + + + Clear all saved sounds? + + This will permanently remove all {savedSounds.length} saved + sounds from your collection. This action cannot be undone. + + + + + + + + +
+ +
+ +
+ {savedSounds.map((sound) => ( + playSound(sound)} + isSaved={isSoundSaved(sound.id)} + onToggleSaved={() => + toggleSavedSound(convertToSoundEffect(sound)) + } + /> + ))} +
+
+
+
+ ); +} + +function SongsView() { + return
Songs
; +} + +interface AudioItemProps { + sound: SoundEffect; + isPlaying: boolean; + onPlay: () => void; + isSaved: boolean; + onToggleSaved: () => void; +} + +function AudioItem({ + sound, + isPlaying, + onPlay, + isSaved, + onToggleSaved, +}: AudioItemProps) { + const { addSoundToTimeline } = useSoundsStore(); + + const handleClick = () => { + onPlay(); + }; + + const handleSaveClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onToggleSaved(); + }; + + const handleAddToTimeline = async (e: React.MouseEvent) => { + e.stopPropagation(); + await addSoundToTimeline(sound); + }; + + return ( +
+
+
+ {isPlaying ? ( + + ) : ( + + )} +
+ +
+

{sound.name}

+ + {sound.username} + +
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 8f41fbf3..7a2cc7f2 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { ScrollArea } from "../../ui/scroll-area"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Button } from "../../ui/button"; import { Scissors, @@ -746,12 +746,7 @@ export function Timeline() { containerRef={tracksContainerRef} isActive={selectionBox?.isActive || false} /> - +
{ + if (hasLoaded) return; + + let ignore = false; + + const prefetchTopSounds = async () => { + try { + if (!ignore) { + setLoading(true); + setError(null); + } + + const response = await fetch( + "/api/sounds/search?page_size=50&sort=downloads" + ); + + if (!ignore) { + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status}`); + } + + const data = await response.json(); + setTopSoundEffects(data.results); + setHasLoaded(true); + + // Set pagination state for top sounds + setCurrentPage(1); + setHasNextPage(!!data.next); + setTotalCount(data.count); + } + } catch (error) { + if (!ignore) { + console.error("Failed to prefetch top sounds:", error); + setError( + error instanceof Error ? error.message : "Failed to load sounds" + ); + } + } finally { + if (!ignore) { + setLoading(false); + } + } + }; + + const timeoutId = setTimeout(prefetchTopSounds, 100); + + return () => { + clearTimeout(timeoutId); + ignore = true; + }; + }, [ + hasLoaded, + setTopSoundEffects, + setLoading, + setError, + setHasLoaded, + setCurrentPage, + setHasNextPage, + setTotalCount, + ]); +} diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index d4cd57a1..4fb0f5b4 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Eye, EyeOff } from "lucide-react"; +import { Eye, EyeOff, X } from "lucide-react"; import { cn } from "../../lib/utils"; import { Button } from "./button"; @@ -7,39 +7,91 @@ import { Button } from "./button"; interface InputProps extends React.ComponentProps<"input"> { showPassword?: boolean; onShowPasswordChange?: (show: boolean) => void; + showClearIcon?: boolean; + onClear?: () => void; } const Input = React.forwardRef( ( - { className, type, showPassword, onShowPasswordChange, value, ...props }, + { + className, + type, + showPassword, + onShowPasswordChange, + showClearIcon, + onClear, + value, + onFocus, + onBlur, + ...props + }, ref ) => { + const [isFocused, setIsFocused] = React.useState(false); + const isPassword = type === "password"; const showPasswordToggle = isPassword && onShowPasswordChange; + const showClear = + showClearIcon && + onClear && + value && + String(value).length > 0 && + isFocused; const inputType = isPassword && showPassword ? "text" : type; + const hasIcons = showPasswordToggle || showClear; + const iconCount = Number(showPasswordToggle) + Number(showClear); + const paddingRight = + iconCount === 2 ? "pr-20" : iconCount === 1 ? "pr-10" : ""; + return ( -
+
{ + setIsFocused(true); + onFocus?.(e); + }} + onBlur={(e) => { + setIsFocused(false); + onBlur?.(e); + }} {...props} /> + {showClear && ( + + )} {showPasswordToggle && (