From 03b3c952c75b7642b537fa2499d081edd1a0a1e4 Mon Sep 17 00:00:00 2001 From: ahmedfahim21 Date: Sun, 13 Jul 2025 00:59:54 +0530 Subject: [PATCH 01/10] feat: click to add to timeline --- .../editor/media-panel/views/media.tsx | 2 + .../editor/media-panel/views/text.tsx | 35 +++++- apps/web/src/components/editor/timeline.tsx | 64 ++-------- apps/web/src/components/ui/draggable-item.tsx | 22 +++- apps/web/src/constants/timeline-constants.ts | 1 + apps/web/src/lib/timeline-utils.ts | 117 ++++++++++++++++++ 6 files changed, 179 insertions(+), 62 deletions(-) create mode 100644 apps/web/src/lib/timeline-utils.ts 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 48e54961..36478667 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -24,6 +24,7 @@ import { } from "@/components/ui/select"; import { DraggableMediaItem } from "@/components/ui/draggable-item"; import { useProjectStore } from "@/stores/project-store"; +import { addMediaToTimeline } from "@/lib/timeline-utils"; export function MediaView() { const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); @@ -288,6 +289,7 @@ export function MediaView() { name: item.name, }} showPlusOnDrag={false} + onAddToTimeline={(currentTime) => addMediaToTimeline(item, currentTime)} rounded={false} /> 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 bec32c85..fad79343 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -1,4 +1,30 @@ import { DraggableMediaItem } from "@/components/ui/draggable-item"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { addTextToTimeline } from "@/lib/timeline-utils"; +import { type TextElement } from "@/types/timeline"; + +let textData: TextElement = { + id: "default-text", + type: "text", + name: "Default text", + content: "Default text", + fontSize: 48, + fontFamily: "Arial", + color: "#ffffff", + backgroundColor: "transparent", + textAlign: "center" as const, + fontWeight: "normal" as const, + fontStyle: "normal" as const, + textDecoration: "none" as const, + x: 0, + y: 0, + rotation: 0, + opacity: 1, + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, +}; export function TextView() { return ( @@ -11,12 +37,13 @@ export function TextView() { } dragData={{ - id: "default-text", - type: "text", - name: "Default text", - content: "Default text", + id: textData.id, + type: textData.type, + name: textData.name, + content: textData.content, }} aspectRatio={1} + onAddToTimeline={(currentTime) => addTextToTimeline(textData, currentTime)} showLabel={false} /> diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 71e2997b..0926e7ba 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -51,6 +51,7 @@ import { import { SelectionBox } from "./selection-box"; import { useSelectionBox } from "@/hooks/use-selection-box"; import type { DragData, TimelineTrack } from "@/types/timeline"; +import { addTextToNewTrack, addMediaToNewTrack } from "@/lib/timeline-utils"; import { getTrackHeight, getCumulativeHeightBefore, @@ -216,7 +217,7 @@ export function Timeline() { Math.min( duration, (mouseX + scrollLeft) / - (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) + (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) ) ); @@ -364,29 +365,7 @@ export function Timeline() { if (dragData.type === "text") { // Always create new text track to avoid overlaps - const newTrackId = addTrack("text"); - - addElementToTrack(newTrackId, { - type: "text", - name: dragData.name || "Text", - content: dragData.content || "Default Text", - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, - fontSize: 48, - fontFamily: "Arial", - color: "#ffffff", - backgroundColor: "transparent", - textAlign: "center", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - x: 0, - y: 0, - rotation: 0, - opacity: 1, - }); + addTextToNewTrack(dragData); } else { // Handle media items const mediaItem = mediaItems.find((item) => item.id === dragData.id); @@ -395,19 +374,7 @@ export function Timeline() { return; } - const trackType = dragData.type === "audio" ? "audio" : "media"; - let targetTrack = tracks.find((t) => t.type === trackType); - const newTrackId = targetTrack ? targetTrack.id : addTrack(trackType); - - addElementToTrack(newTrackId, { - type: "media", - mediaId: mediaItem.id, - name: mediaItem.name, - duration: mediaItem.duration || 5, - startTime: 0, - trimStart: 0, - trimEnd: 0, - }); + addMediaToNewTrack(mediaItem); } } catch (error) { console.error("Error parsing dropped item data:", error); @@ -435,18 +402,7 @@ export function Timeline() { item.name === processedItem.name && item.url === processedItem.url ); if (addedItem) { - const trackType = - processedItem.type === "audio" ? "audio" : "media"; - const newTrackId = addTrack(trackType); - addElementToTrack(newTrackId, { - type: "media", - mediaId: addedItem.id, - name: addedItem.name, - duration: addedItem.duration || 5, - startTime: 0, - trimStart: 0, - trimEnd: 0, - }); + addMediaToNewTrack(addedItem); } } } catch (error) { @@ -891,21 +847,19 @@ export function Timeline() { return (
{(() => { const formatTime = (seconds: number) => { diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index 16518dde..c1f81e16 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -6,12 +6,14 @@ import { ReactNode, useState, useRef, useEffect } from "react"; import { createPortal } from "react-dom"; import { Plus } from "lucide-react"; import { cn } from "@/lib/utils"; +import { usePlaybackStore } from "@/stores/playback-store"; export interface DraggableMediaItemProps { name: string; preview: ReactNode; dragData: Record; onDragStart?: (e: React.DragEvent) => void; + onAddToTimeline?: (currentTime: number) => void; aspectRatio?: number; className?: string; showPlusOnDrag?: boolean; @@ -24,6 +26,7 @@ export function DraggableMediaItem({ preview, dragData, onDragStart, + onAddToTimeline, aspectRatio = 16 / 9, className = "", showPlusOnDrag = true, @@ -33,6 +36,11 @@ export function DraggableMediaItem({ const [isDragging, setIsDragging] = useState(false); const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); const dragRef = useRef(null); + const currentTime = usePlaybackStore((state) => state.currentTime); + + const handleAddToTimeline = () => { + onAddToTimeline?.(currentTime); + }; const emptyImg = new window.Image(); emptyImg.src = @@ -92,7 +100,10 @@ export function DraggableMediaItem({ > {preview} {!isDragging && ( - + )} {showLabel && ( @@ -128,7 +139,7 @@ export function DraggableMediaItem({
{preview}
- {showPlusOnDrag && } + {showPlusOnDrag && }
, @@ -138,11 +149,16 @@ export function DraggableMediaItem({ ); } -function PlusButton({ className }: { className?: string }) { +function PlusButton({ className, onClick }: { className?: string; onClick?: () => void }) { return ( diff --git a/apps/web/src/constants/timeline-constants.ts b/apps/web/src/constants/timeline-constants.ts index 92045c5e..8c76a2e8 100644 --- a/apps/web/src/constants/timeline-constants.ts +++ b/apps/web/src/constants/timeline-constants.ts @@ -74,6 +74,7 @@ export const TIMELINE_CONSTANTS = { PIXELS_PER_SECOND: 50, TRACK_HEIGHT: 60, // Default fallback DEFAULT_TEXT_DURATION: 5, + DEFAULT_IMAGE_DURATION: 5, ZOOM_LEVELS: [0.25, 0.5, 1, 1.5, 2, 3, 4], } as const; diff --git a/apps/web/src/lib/timeline-utils.ts b/apps/web/src/lib/timeline-utils.ts new file mode 100644 index 00000000..994cb314 --- /dev/null +++ b/apps/web/src/lib/timeline-utils.ts @@ -0,0 +1,117 @@ +import { useTimelineStore } from "@/stores/timeline-store"; +import { type MediaItem } from "@/stores/media-store"; +import { toast } from "sonner"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { DragData, TextElement } from "@/types/timeline"; + + +const findOrCreateTrack = (trackType: "media" | "audio" | "text") => { + const timelineStore = useTimelineStore.getState(); + + // Always create new text track to allow multiple text elements + if (trackType === "text") { + return timelineStore.addTrack(trackType); + } + + const existingTrack = timelineStore.tracks.find(track => track.type === trackType); + return existingTrack ? existingTrack.id : timelineStore.addTrack(trackType); +}; + + +const checkOverlap = (trackId: string, startTime: number, duration: number, excludeElementId?: string) => { + const timelineStore = useTimelineStore.getState(); + const targetTrack = timelineStore.tracks.find(track => track.id === trackId); + + if (!targetTrack) { + return true; + } + + const elementEnd = startTime + duration; + + return targetTrack.elements.some((existingElement) => { + if (excludeElementId && existingElement.id === excludeElementId) { + return false; + } + + const existingStart = existingElement.startTime; + const existingEnd = existingElement.startTime + + (existingElement.duration - existingElement.trimStart - existingElement.trimEnd); + + return startTime < existingEnd && elementEnd > existingStart; + }); +}; + +const addMediaElement = (trackId: string, item: MediaItem, startTime: number) => { + const timelineStore = useTimelineStore.getState(); + + timelineStore.addElementToTrack(trackId, { + type: "media", + mediaId: item.id, + name: item.name, + duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, + startTime, + trimStart: 0, + trimEnd: 0, + }); +}; + +const addTextElement = (trackId: string, item: TextElement | DragData, startTime: number) => { + const timelineStore = useTimelineStore.getState(); + + timelineStore.addElementToTrack(trackId, { + type: "text", + name: item.name, + content: ("content" in item ? item.content : "Default Text"), + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime, + trimStart: ("trimStart" in item ? item.trimStart : 0), + trimEnd: ("trimEnd" in item ? item.trimEnd : 0), + fontSize: ("fontSize" in item ? item.fontSize : 48), + fontFamily: ("fontFamily" in item ? item.fontFamily : "Arial"), + color: ("color" in item ? item.color : "#ffffff"), + backgroundColor: ("backgroundColor" in item ? item.backgroundColor : "transparent"), + textAlign: ("textAlign" in item ? item.textAlign : "center"), + fontWeight: ("fontWeight" in item ? item.fontWeight : "normal"), + fontStyle: ("fontStyle" in item ? item.fontStyle : "normal"), + textDecoration: ("textDecoration" in item ? item.textDecoration : "none"), + x: ("x" in item ? item.x : 0), + y: ("y" in item ? item.y : 0), + rotation: ("rotation" in item ? item.rotation : 0), + opacity: ("opacity" in item && item.opacity !== undefined ? item.opacity : 1), + }); +}; + + +// Adds a media item to the timeline at the specified time +export const addMediaToTimeline = (item: MediaItem, currentTime: number = 0) => { + const trackType = item.type === "audio" ? "audio" : "media"; + const targetTrackId = findOrCreateTrack(trackType); + + const duration = item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION; + + if (checkOverlap(targetTrackId, currentTime, duration)) { + toast.error("Cannot place element here - it would overlap with existing elements"); + return; + } + + addMediaElement(targetTrackId, item, currentTime); +}; + +// Adds a text item to the timeline at the specified time +export const addTextToTimeline = (item: TextElement, currentTime: number = 0) => { + const targetTrackId = findOrCreateTrack("text"); + addTextElement(targetTrackId, item, currentTime); +}; + +// Adds a media item to timeline +export const addMediaToNewTrack = (item: MediaItem) => { + const trackType = item.type === "audio" ? "audio" : "media"; + const targetTrackId = findOrCreateTrack(trackType); + addMediaElement(targetTrackId, item, 0); +}; + +// Adds a text item to timeline +export const addTextToNewTrack = (item: TextElement | DragData) => { + const targetTrackId = findOrCreateTrack("text"); + addTextElement(targetTrackId, item, 0); +}; From 1e64bc4abd8464ae733f884de46ffa13760035cc Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Mon, 14 Jul 2025 23:49:48 +0600 Subject: [PATCH 02/10] fix improve timeline element visual display --- apps/web/src/components/editor/timeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 71e2997b..6c3d731d 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -192,7 +192,7 @@ export function Timeline() { let scrollLeft = 0; if (isRulerClick) { - // Calculate based on ruler position + // Calculate based on ruler position const rulerContent = rulerScrollRef.current?.querySelector( "[data-radix-scroll-area-viewport]" ) as HTMLElement; From c628b5a8ee6650b223cdc7aa5b6b000bc1e2466d Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Mon, 14 Jul 2025 23:53:11 +0600 Subject: [PATCH 03/10] fix improve timeline element visual display by tiling repeating media thumbnails --- .../components/editor/timeline-element.tsx | 81 ++++++++++++++----- apps/web/src/components/editor/timeline.tsx | 2 +- apps/web/src/constants/timeline-constants.ts | 51 ++++++++++++ 3 files changed, 114 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx index ee9445f3..2a704227 100644 --- a/apps/web/src/components/editor/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline-element.tsx @@ -24,6 +24,8 @@ import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; import { getTrackElementClasses, TIMELINE_CONSTANTS, + getTrackHeight, + calculateMediaTiling, } from "@/constants/timeline-constants"; import { DropdownMenu, @@ -268,34 +270,75 @@ export function TimelineElement({ } if (mediaItem.type === "image") { + // Use utility function to calculate optimal tiling + const tiling = calculateMediaTiling(elementWidth, track.type, "image"); + return ( -
-
- {mediaItem.name} +
+
+ {Array.from({ length: tiling.totalTiles }, (_, index) => { + const isPartialTile = + index === tiling.numCompleteTiles && tiling.showPartialTile; + const tileWidthToUse = isPartialTile + ? tiling.remainingWidth + : tiling.tileWidth; + + return ( +
+ {mediaItem.name} +
+ ); + })}
); } if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { + // Use utility function to calculate optimal tiling + const tiling = calculateMediaTiling(elementWidth, track.type, "video"); + return ( -
-
- {mediaItem.name} +
+
+ {Array.from({ length: tiling.totalTiles }, (_, index) => { + const isPartialTile = + index === tiling.numCompleteTiles && tiling.showPartialTile; + const tileWidthToUse = isPartialTile + ? tiling.remainingWidth + : tiling.tileWidth; + + return ( +
+ {mediaItem.name} +
+ ); + })}
- - {element.name} - + {/* Show name overlay on the right if there's sufficient space */} + {tiling.canShowOverlay && ( +
+ {element.name} +
+ )}
); } diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 6c3d731d..71e2997b 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -192,7 +192,7 @@ export function Timeline() { let scrollLeft = 0; if (isRulerClick) { - // Calculate based on ruler position + // Calculate based on ruler position const rulerContent = rulerScrollRef.current?.querySelector( "[data-radix-scroll-area-viewport]" ) as HTMLElement; diff --git a/apps/web/src/constants/timeline-constants.ts b/apps/web/src/constants/timeline-constants.ts index 92045c5e..44773b84 100644 --- a/apps/web/src/constants/timeline-constants.ts +++ b/apps/web/src/constants/timeline-constants.ts @@ -104,3 +104,54 @@ export function snapTimeToFrame(time: number, fps: number): number { export function getFrameDuration(fps: number): number { return 1 / fps; } + +// Media thumbnail tiling constants +export const MEDIA_THUMBNAIL_CONSTANTS = { + DEFAULT_ASPECT_RATIO: 16 / 9, + IMAGE_PADDING: 8, + VIDEO_PADDING: 16, + PARTIAL_TILE_THRESHOLD_IMAGE: 0.2, // Show partial tile if >20% visible for images + PARTIAL_TILE_THRESHOLD_VIDEO: 0.3, // Show partial tile if >30% visible for videos + MIN_OVERLAY_WIDTH_RATIO: 1.5, // Minimum width ratio to show name overlay +} as const; + +// Utility function to calculate media thumbnail tiling +export function calculateMediaTiling( + elementWidth: number, + trackType: TrackType, + mediaType: "image" | "video" +) { + const trackHeight = getTrackHeight(trackType); + const aspectRatio = MEDIA_THUMBNAIL_CONSTANTS.DEFAULT_ASPECT_RATIO; + const padding = + mediaType === "image" + ? MEDIA_THUMBNAIL_CONSTANTS.IMAGE_PADDING + : MEDIA_THUMBNAIL_CONSTANTS.VIDEO_PADDING; + + const tileHeight = trackHeight - padding; + const tileWidth = tileHeight * aspectRatio; + + // Calculate how many complete tiles we can fit + const numCompleteTiles = Math.floor(elementWidth / tileWidth); + const remainingWidth = elementWidth - numCompleteTiles * tileWidth; + + const threshold = + mediaType === "image" + ? MEDIA_THUMBNAIL_CONSTANTS.PARTIAL_TILE_THRESHOLD_IMAGE + : MEDIA_THUMBNAIL_CONSTANTS.PARTIAL_TILE_THRESHOLD_VIDEO; + + const showPartialTile = remainingWidth > tileWidth * threshold; + const totalTiles = numCompleteTiles + (showPartialTile ? 1 : 0); + + return { + tileWidth, + tileHeight, + numCompleteTiles, + remainingWidth, + showPartialTile, + totalTiles: Math.max(1, totalTiles), // Always show at least one tile + canShowOverlay: + elementWidth > + tileWidth * MEDIA_THUMBNAIL_CONSTANTS.MIN_OVERLAY_WIDTH_RATIO, + }; +} From 2c941b1585d9fea0207fd9eae68775475dc462c4 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Tue, 15 Jul 2025 00:12:01 +0600 Subject: [PATCH 04/10] fix: optimize media thumbnail display by removing tiling utility and using CSS background instead --- .../components/editor/timeline-element.tsx | 81 +++++++------------ apps/web/src/constants/timeline-constants.ts | 51 ------------ 2 files changed, 27 insertions(+), 105 deletions(-) diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx index 2a704227..5f5a2c6d 100644 --- a/apps/web/src/components/editor/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline-element.tsx @@ -25,7 +25,6 @@ import { getTrackElementClasses, TIMELINE_CONSTANTS, getTrackHeight, - calculateMediaTiling, } from "@/constants/timeline-constants"; import { DropdownMenu, @@ -270,71 +269,45 @@ export function TimelineElement({ } if (mediaItem.type === "image") { - // Use utility function to calculate optimal tiling - const tiling = calculateMediaTiling(elementWidth, track.type, "image"); + // Calculate tile size based on 16:9 aspect ratio + const trackHeight = getTrackHeight(track.type); + const tileHeight = trackHeight - 8; // Account for padding + const tileWidth = tileHeight * (16 / 9); return (
-
- {Array.from({ length: tiling.totalTiles }, (_, index) => { - const isPartialTile = - index === tiling.numCompleteTiles && tiling.showPartialTile; - const tileWidthToUse = isPartialTile - ? tiling.remainingWidth - : tiling.tileWidth; - - return ( -
- {mediaItem.name} -
- ); - })} -
+
); } if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { - // Use utility function to calculate optimal tiling - const tiling = calculateMediaTiling(elementWidth, track.type, "video"); + // Calculate tile size based on 16:9 aspect ratio + const trackHeight = getTrackHeight(track.type); + const tileHeight = trackHeight - 16; // Account for padding + const tileWidth = tileHeight * (16 / 9); return (
-
- {Array.from({ length: tiling.totalTiles }, (_, index) => { - const isPartialTile = - index === tiling.numCompleteTiles && tiling.showPartialTile; - const tileWidthToUse = isPartialTile - ? tiling.remainingWidth - : tiling.tileWidth; - - return ( -
- {mediaItem.name} -
- ); - })} -
+
{/* Show name overlay on the right if there's sufficient space */} - {tiling.canShowOverlay && ( + {elementWidth > tileWidth * 1.5 && (
{element.name}
diff --git a/apps/web/src/constants/timeline-constants.ts b/apps/web/src/constants/timeline-constants.ts index 44773b84..92045c5e 100644 --- a/apps/web/src/constants/timeline-constants.ts +++ b/apps/web/src/constants/timeline-constants.ts @@ -104,54 +104,3 @@ export function snapTimeToFrame(time: number, fps: number): number { export function getFrameDuration(fps: number): number { return 1 / fps; } - -// Media thumbnail tiling constants -export const MEDIA_THUMBNAIL_CONSTANTS = { - DEFAULT_ASPECT_RATIO: 16 / 9, - IMAGE_PADDING: 8, - VIDEO_PADDING: 16, - PARTIAL_TILE_THRESHOLD_IMAGE: 0.2, // Show partial tile if >20% visible for images - PARTIAL_TILE_THRESHOLD_VIDEO: 0.3, // Show partial tile if >30% visible for videos - MIN_OVERLAY_WIDTH_RATIO: 1.5, // Minimum width ratio to show name overlay -} as const; - -// Utility function to calculate media thumbnail tiling -export function calculateMediaTiling( - elementWidth: number, - trackType: TrackType, - mediaType: "image" | "video" -) { - const trackHeight = getTrackHeight(trackType); - const aspectRatio = MEDIA_THUMBNAIL_CONSTANTS.DEFAULT_ASPECT_RATIO; - const padding = - mediaType === "image" - ? MEDIA_THUMBNAIL_CONSTANTS.IMAGE_PADDING - : MEDIA_THUMBNAIL_CONSTANTS.VIDEO_PADDING; - - const tileHeight = trackHeight - padding; - const tileWidth = tileHeight * aspectRatio; - - // Calculate how many complete tiles we can fit - const numCompleteTiles = Math.floor(elementWidth / tileWidth); - const remainingWidth = elementWidth - numCompleteTiles * tileWidth; - - const threshold = - mediaType === "image" - ? MEDIA_THUMBNAIL_CONSTANTS.PARTIAL_TILE_THRESHOLD_IMAGE - : MEDIA_THUMBNAIL_CONSTANTS.PARTIAL_TILE_THRESHOLD_VIDEO; - - const showPartialTile = remainingWidth > tileWidth * threshold; - const totalTiles = numCompleteTiles + (showPartialTile ? 1 : 0); - - return { - tileWidth, - tileHeight, - numCompleteTiles, - remainingWidth, - showPartialTile, - totalTiles: Math.max(1, totalTiles), // Always show at least one tile - canShowOverlay: - elementWidth > - tileWidth * MEDIA_THUMBNAIL_CONSTANTS.MIN_OVERLAY_WIDTH_RATIO, - }; -} From d53321ec366979512d5e5a8cccc56b00cd84c1cf Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Tue, 15 Jul 2025 00:42:19 +0600 Subject: [PATCH 05/10] fix: enhance media tile display with aspect ratio adjustments and overlay borders --- .../components/editor/timeline-element.tsx | 88 ++++++++++++++----- 1 file changed, 65 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx index 5f5a2c6d..ca229818 100644 --- a/apps/web/src/components/editor/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline-element.tsx @@ -268,46 +268,88 @@ export function TimelineElement({ ); } + const TILE_ASPECT_RATIO = 16 / 9; + if (mediaItem.type === "image") { // Calculate tile size based on 16:9 aspect ratio const trackHeight = getTrackHeight(track.type); const tileHeight = trackHeight - 8; // Account for padding - const tileWidth = tileHeight * (16 / 9); + 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) { - // Calculate tile size based on 16:9 aspect ratio const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight - 16; // Account for padding - const tileWidth = tileHeight * (16 / 9); + const tileHeight = trackHeight - VIDEO_TILE_PADDING; + const tileWidth = tileHeight * TILE_ASPECT_RATIO; return (
-
+
+ {/* Background with tiled thumbnails */} +
+ {/* Overlay with vertical borders */} +
+
{/* Show name overlay on the right if there's sufficient space */} - {elementWidth > tileWidth * 1.5 && ( + {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER && (
{element.name}
From dc0e4783fbacad5c7eb3cf0d1141b0166960f809 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 15 Jul 2025 16:55:04 +0200 Subject: [PATCH 06/10] cleanup --- .../editor/media-panel/views/media.tsx | 8 +- .../editor/media-panel/views/text.tsx | 6 +- apps/web/src/components/editor/timeline.tsx | 19 +- apps/web/src/lib/timeline-utils.ts | 117 ------------- apps/web/src/stores/timeline-store.ts | 164 +++++++++++++++++- 5 files changed, 183 insertions(+), 131 deletions(-) delete mode 100644 apps/web/src/lib/timeline-utils.ts 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 17893156..393d72db 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -24,7 +24,7 @@ import { } from "@/components/ui/select"; import { DraggableMediaItem } from "@/components/ui/draggable-item"; import { useProjectStore } from "@/stores/project-store"; -import { addMediaToTimeline } from "@/lib/timeline-utils"; +import { useTimelineStore } from "@/stores/timeline-store"; export function MediaView() { const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); @@ -289,7 +289,11 @@ export function MediaView() { name: item.name, }} showPlusOnDrag={false} - onAddToTimeline={(currentTime) => addMediaToTimeline(item, currentTime)} + onAddToTimeline={(currentTime) => + useTimelineStore + .getState() + .addMediaAtTime(item, currentTime) + } rounded={false} /> 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 fad79343..6d0d310b 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -1,6 +1,6 @@ import { DraggableMediaItem } from "@/components/ui/draggable-item"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { addTextToTimeline } from "@/lib/timeline-utils"; +import { useTimelineStore } from "@/stores/timeline-store"; import { type TextElement } from "@/types/timeline"; let textData: TextElement = { @@ -43,7 +43,9 @@ export function TextView() { content: textData.content, }} aspectRatio={1} - onAddToTimeline={(currentTime) => addTextToTimeline(textData, currentTime)} + onAddToTimeline={(currentTime) => + useTimelineStore.getState().addTextAtTime(textData, currentTime) + } showLabel={false} />
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index ff9e0694..c1603542 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -48,7 +48,6 @@ import { useSelectionBox } from "@/hooks/use-selection-box"; import { SnapIndicator } from "./snap-indicator"; import { SnapPoint } from "@/hooks/use-timeline-snapping"; import type { DragData, TimelineTrack } from "@/types/timeline"; -import { addTextToNewTrack, addMediaToNewTrack } from "@/lib/timeline-utils"; import { getTrackHeight, getCumulativeHeightBefore, @@ -229,7 +228,7 @@ export function Timeline() { Math.min( duration, (mouseX + scrollLeft) / - (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) + (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel) ) ); @@ -377,7 +376,7 @@ export function Timeline() { if (dragData.type === "text") { // Always create new text track to avoid overlaps - addTextToNewTrack(dragData); + useTimelineStore.getState().addTextToNewTrack(dragData); } else { // Handle media items const mediaItem = mediaItems.find((item) => item.id === dragData.id); @@ -386,7 +385,7 @@ export function Timeline() { return; } - addMediaToNewTrack(mediaItem); + useTimelineStore.getState().addMediaToNewTrack(mediaItem); } } catch (error) { console.error("Error parsing dropped item data:", error); @@ -414,7 +413,7 @@ export function Timeline() { item.name === processedItem.name && item.url === processedItem.url ); if (addedItem) { - addMediaToNewTrack(addedItem); + useTimelineStore.getState().addMediaToNewTrack(addedItem); } } } catch (error) { @@ -902,19 +901,21 @@ export function Timeline() { return (
{(() => { const formatTime = (seconds: number) => { diff --git a/apps/web/src/lib/timeline-utils.ts b/apps/web/src/lib/timeline-utils.ts deleted file mode 100644 index 994cb314..00000000 --- a/apps/web/src/lib/timeline-utils.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { useTimelineStore } from "@/stores/timeline-store"; -import { type MediaItem } from "@/stores/media-store"; -import { toast } from "sonner"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; -import { DragData, TextElement } from "@/types/timeline"; - - -const findOrCreateTrack = (trackType: "media" | "audio" | "text") => { - const timelineStore = useTimelineStore.getState(); - - // Always create new text track to allow multiple text elements - if (trackType === "text") { - return timelineStore.addTrack(trackType); - } - - const existingTrack = timelineStore.tracks.find(track => track.type === trackType); - return existingTrack ? existingTrack.id : timelineStore.addTrack(trackType); -}; - - -const checkOverlap = (trackId: string, startTime: number, duration: number, excludeElementId?: string) => { - const timelineStore = useTimelineStore.getState(); - const targetTrack = timelineStore.tracks.find(track => track.id === trackId); - - if (!targetTrack) { - return true; - } - - const elementEnd = startTime + duration; - - return targetTrack.elements.some((existingElement) => { - if (excludeElementId && existingElement.id === excludeElementId) { - return false; - } - - const existingStart = existingElement.startTime; - const existingEnd = existingElement.startTime + - (existingElement.duration - existingElement.trimStart - existingElement.trimEnd); - - return startTime < existingEnd && elementEnd > existingStart; - }); -}; - -const addMediaElement = (trackId: string, item: MediaItem, startTime: number) => { - const timelineStore = useTimelineStore.getState(); - - timelineStore.addElementToTrack(trackId, { - type: "media", - mediaId: item.id, - name: item.name, - duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, - startTime, - trimStart: 0, - trimEnd: 0, - }); -}; - -const addTextElement = (trackId: string, item: TextElement | DragData, startTime: number) => { - const timelineStore = useTimelineStore.getState(); - - timelineStore.addElementToTrack(trackId, { - type: "text", - name: item.name, - content: ("content" in item ? item.content : "Default Text"), - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime, - trimStart: ("trimStart" in item ? item.trimStart : 0), - trimEnd: ("trimEnd" in item ? item.trimEnd : 0), - fontSize: ("fontSize" in item ? item.fontSize : 48), - fontFamily: ("fontFamily" in item ? item.fontFamily : "Arial"), - color: ("color" in item ? item.color : "#ffffff"), - backgroundColor: ("backgroundColor" in item ? item.backgroundColor : "transparent"), - textAlign: ("textAlign" in item ? item.textAlign : "center"), - fontWeight: ("fontWeight" in item ? item.fontWeight : "normal"), - fontStyle: ("fontStyle" in item ? item.fontStyle : "normal"), - textDecoration: ("textDecoration" in item ? item.textDecoration : "none"), - x: ("x" in item ? item.x : 0), - y: ("y" in item ? item.y : 0), - rotation: ("rotation" in item ? item.rotation : 0), - opacity: ("opacity" in item && item.opacity !== undefined ? item.opacity : 1), - }); -}; - - -// Adds a media item to the timeline at the specified time -export const addMediaToTimeline = (item: MediaItem, currentTime: number = 0) => { - const trackType = item.type === "audio" ? "audio" : "media"; - const targetTrackId = findOrCreateTrack(trackType); - - const duration = item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION; - - if (checkOverlap(targetTrackId, currentTime, duration)) { - toast.error("Cannot place element here - it would overlap with existing elements"); - return; - } - - addMediaElement(targetTrackId, item, currentTime); -}; - -// Adds a text item to the timeline at the specified time -export const addTextToTimeline = (item: TextElement, currentTime: number = 0) => { - const targetTrackId = findOrCreateTrack("text"); - addTextElement(targetTrackId, item, currentTime); -}; - -// Adds a media item to timeline -export const addMediaToNewTrack = (item: MediaItem) => { - const trackType = item.type === "audio" ? "audio" : "media"; - const targetTrackId = findOrCreateTrack(trackType); - addMediaElement(targetTrackId, item, 0); -}; - -// Adds a text item to timeline -export const addTextToNewTrack = (item: TextElement | DragData) => { - const targetTrackId = findOrCreateTrack("text"); - addTextElement(targetTrackId, item, 0); -}; diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index f6b9bfd8..9a2c7361 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -5,15 +5,22 @@ import { CreateTimelineElement, TimelineTrack, TextElement, + DragData, sortTracksByOrder, ensureMainTrack, validateElementTrackCompatibility, } from "@/types/timeline"; import { useEditorStore } from "./editor-store"; -import { useMediaStore, getMediaAspectRatio } from "./media-store"; +import { + useMediaStore, + getMediaAspectRatio, + type MediaItem, +} from "./media-store"; import { storageService } from "@/lib/storage/storage-service"; import { useProjectStore } from "./project-store"; import { generateUUID } from "@/lib/utils"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { toast } from "sonner"; // Helper function to manage element naming with suffixes const getElementNameWithSuffix = ( @@ -166,6 +173,17 @@ interface TimelineStore { > > ) => void; + checkElementOverlap: ( + trackId: string, + startTime: number, + duration: number, + excludeElementId?: string + ) => boolean; + findOrCreateTrack: (trackType: TrackType) => string; + addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean; + addTextAtTime: (item: TextElement, currentTime?: number) => boolean; + addMediaToNewTrack: (item: MediaItem) => boolean; + addTextToNewTrack: (item: TextElement | DragData) => boolean; } export const useTimelineStore = create((set, get) => { @@ -963,5 +981,149 @@ export const useTimelineStore = create((set, get) => { toggleSnapping: () => { set((state) => ({ snappingEnabled: !state.snappingEnabled })); }, + + checkElementOverlap: (trackId, startTime, duration, excludeElementId) => { + const track = get()._tracks.find((t) => t.id === trackId); + if (!track) return false; + + const overlap = track.elements.some((element) => { + const elementEnd = + element.startTime + + element.duration - + element.trimStart - + element.trimEnd; + + if (element.id === excludeElementId) { + return false; + } + + return ( + (startTime >= element.startTime && startTime < elementEnd) || + (startTime + duration > element.startTime && + startTime + duration <= elementEnd) || + (startTime < element.startTime && startTime + duration > elementEnd) + ); + }); + return overlap; + }, + + findOrCreateTrack: (trackType) => { + // Always create new text track to allow multiple text elements + if (trackType === "text") { + return get().addTrack(trackType); + } + + const existingTrack = get()._tracks.find((t) => t.type === trackType); + if (existingTrack) { + return existingTrack.id; + } + + return get().addTrack(trackType); + }, + + addMediaAtTime: (item, currentTime = 0) => { + const trackType = item.type === "audio" ? "audio" : "media"; + const targetTrackId = get().findOrCreateTrack(trackType); + + const duration = + item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION; + + if (get().checkElementOverlap(targetTrackId, currentTime, duration)) { + toast.error( + "Cannot place element here - it would overlap with existing elements" + ); + return false; + } + + get().addElementToTrack(targetTrackId, { + type: "media", + mediaId: item.id, + name: item.name, + duration, + startTime: currentTime, + trimStart: 0, + trimEnd: 0, + }); + return true; + }, + + addTextAtTime: (item, currentTime = 0) => { + const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements + + get().addElementToTrack(targetTrackId, { + type: "text", + name: item.name || "Text", + content: item.content || "Default Text", + duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: currentTime, + trimStart: 0, + trimEnd: 0, + fontSize: item.fontSize || 48, + fontFamily: item.fontFamily || "Arial", + color: item.color || "#ffffff", + backgroundColor: item.backgroundColor || "transparent", + textAlign: item.textAlign || "center", + fontWeight: item.fontWeight || "normal", + fontStyle: item.fontStyle || "normal", + textDecoration: item.textDecoration || "none", + x: item.x || 0, + y: item.y || 0, + rotation: item.rotation || 0, + opacity: item.opacity !== undefined ? item.opacity : 1, + }); + return true; + }, + + addMediaToNewTrack: (item) => { + const trackType = item.type === "audio" ? "audio" : "media"; + const targetTrackId = get().findOrCreateTrack(trackType); + + get().addElementToTrack(targetTrackId, { + type: "media", + mediaId: item.id, + name: item.name, + duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, + }); + return true; + }, + + addTextToNewTrack: (item) => { + const targetTrackId = get().addTrack("text"); // Always create new text track to allow multiple text elements + + get().addElementToTrack(targetTrackId, { + type: "text", + name: item.name || "Text", + content: + ("content" in item ? item.content : "Default Text") || "Default Text", + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, + fontSize: ("fontSize" in item ? item.fontSize : 48) || 48, + fontFamily: + ("fontFamily" in item ? item.fontFamily : "Arial") || "Arial", + color: ("color" in item ? item.color : "#ffffff") || "#ffffff", + backgroundColor: + ("backgroundColor" in item ? item.backgroundColor : "transparent") || + "transparent", + textAlign: + ("textAlign" in item ? item.textAlign : "center") || "center", + fontWeight: + ("fontWeight" in item ? item.fontWeight : "normal") || "normal", + fontStyle: + ("fontStyle" in item ? item.fontStyle : "normal") || "normal", + textDecoration: + ("textDecoration" in item ? item.textDecoration : "none") || "none", + x: ("x" in item ? item.x : 0) || 0, + y: ("y" in item ? item.y : 0) || 0, + rotation: ("rotation" in item ? item.rotation : 0) || 0, + opacity: + "opacity" in item && item.opacity !== undefined ? item.opacity : 1, + }); + return true; + }, }; }); From 7a725ecd0962fa0c37c78dc22827eb9e08c903f3 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 15 Jul 2025 17:29:28 +0200 Subject: [PATCH 07/10] cleanup --- .../components/editor/timeline-element.tsx | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx index ca229818..8cccd0e4 100644 --- a/apps/web/src/components/editor/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline-element.tsx @@ -277,11 +277,11 @@ export function TimelineElement({ const tileWidth = tileHeight * TILE_ASPECT_RATIO; return ( -
-
+
+
{/* Background with tiled images */}
{/* Overlay with vertical borders */}
-
+
+
{/* Background with tiled thumbnails */}
{/* Overlay with vertical borders */}
- {/* Show name overlay on the right if there's sufficient space */} - {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER && ( + {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? (
{element.name}
+ ) : ( + + {element.name} + )}
); From fd2aa00074db0fd157bd41c0b6ddf061452f3aaa Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 15 Jul 2025 17:40:19 +0200 Subject: [PATCH 08/10] style: tooltip --- apps/web/src/components/ui/tooltip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 0da023e6..4971e262 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -20,7 +20,7 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 overflow-hidden rounded-md bg-foreground/10 px-3 py-1.5 text-xs text-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 overflow-hidden rounded-md bg-foreground px-3 py-1.5 text-xs text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} {...props} From 3a8d57921e48b9ca1055644482ad39753c077ff4 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 15 Jul 2025 17:41:10 +0200 Subject: [PATCH 09/10] feat: add lock icon toggle for snapping functionality --- apps/web/src/components/editor/timeline.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index c1603542..4a770627 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -17,6 +17,7 @@ import { TypeIcon, Magnet, Lock, + LockOpen, } from "lucide-react"; import { Tooltip, @@ -808,7 +809,11 @@ export function Timeline() { Auto snapping From 83d65bdd15946c4307a38af9f5c43b4c51e906fb Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 15 Jul 2025 23:26:04 +0200 Subject: [PATCH 10/10] feat: remove waitlist count due to bots --- apps/web/src/app/page.tsx | 8 +------- apps/web/src/components/landing/hero.tsx | 18 +----------------- 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 74308470..74961549 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,18 +1,12 @@ import { Hero } from "@/components/landing/hero"; import { Header } from "@/components/header"; import { Footer } from "@/components/footer"; -import { getWaitlistCount } from "@/lib/waitlist"; - -// Force dynamic rendering so waitlist count updates in real-time -export const dynamic = "force-dynamic"; export default async function Home() { - const signupCount = await getWaitlistCount(); - return (
- +
); diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index 01ad7e7e..462e3ba7 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -10,11 +10,7 @@ import { toast } from "sonner"; import Image from "next/image"; import { Handlebars } from "./handlebars"; -interface HeroProps { - signupCount: number; -} - -export function Hero({ signupCount }: HeroProps) { +export function Hero() { const [email, setEmail] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); @@ -131,18 +127,6 @@ export function Hero({ signupCount }: HeroProps) { - - {signupCount > 0 && ( - -
- {signupCount.toLocaleString()} people already joined - - )}
);