From 191c83c5477272cdfee5677d0f3fb31fc0661c44 Mon Sep 17 00:00:00 2001 From: Kha Nguyen Date: Sat, 26 Jul 2025 14:56:50 -0500 Subject: [PATCH 1/7] feat: add size prop to DraggableItem to control width and height --- apps/web/src/components/ui/draggable-item.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index dfb4dce72..443223a15 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -24,6 +24,10 @@ export interface DraggableMediaItemProps { showPlusOnDrag?: boolean; showLabel?: boolean; rounded?: boolean; + /** + * media item size + */ + size?: number; } export function DraggableMediaItem({ @@ -37,6 +41,7 @@ export function DraggableMediaItem({ showPlusOnDrag = true, showLabel = true, rounded = true, + size, }: DraggableMediaItemProps) { const [isDragging, setIsDragging] = useState(false); const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); @@ -88,7 +93,14 @@ export function DraggableMediaItem({ return ( <> -
+
From 750f8ecd03e56fc9e754adf2718aeee4d00cc88e Mon Sep 17 00:00:00 2001 From: Kha Nguyen Date: Sat, 26 Jul 2025 14:58:49 -0500 Subject: [PATCH 2/7] feat: add size control to media items - the size is persistent --- .../editor/media-panel/views/media.tsx | 69 +++++++++++++++++-- 1 file changed, 65 insertions(+), 4 deletions(-) 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 f218dc146..e15064ff9 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -3,7 +3,15 @@ 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 { + Image, + Loader2, + Music, + Plus, + Video, + ZoomIn, + ZoomOut, +} from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -26,6 +34,7 @@ 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 { Slider } from "@/components/ui/slider"; export function MediaView() { const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); @@ -35,6 +44,12 @@ export function MediaView() { const [progress, setProgress] = useState(0); const [searchQuery, setSearchQuery] = useState(""); const [mediaFilter, setMediaFilter] = useState("all"); + // Get media size from localStorage or default to 3 + const [mediaSize, setMediaSize] = useState( + localStorage.getItem("mediaSize") + ? parseInt(localStorage.getItem("mediaSize")!, 10) + : 3 + ); const processFiles = async (files: FileList | File[]) => { if (!files || files.length === 0) return; @@ -64,6 +79,23 @@ export function MediaView() { } }; + const handleMediaSizeChange = (size: number) => { + setMediaSize(size); + localStorage.setItem("mediaSize", size.toString()); + }; + + const handleSizeIncrease = () => { + handleMediaSizeChange(Math.min(5, mediaSize + 1)); + }; + + const handleSizeDecrease = () => { + handleMediaSizeChange(Math.max(1, mediaSize - 1)); + }; + + const handleSizeSliderChange = (values: number[]) => { + handleMediaSizeChange(values[0]); + }; + const { isDragOver, dragProps } = useDragDrop({ // When files are dropped, process them onDrop: processFiles, @@ -97,6 +129,18 @@ export function MediaView() { return `${min}:${sec.toString().padStart(2, "0")}`; }; + // Map size levels (1-5) to appropriate grid item widths + const getGridItemWidth = (size: number) => { + const sizeMap = { + 1: 90, + 2: 120, + 3: 160, + 4: 190, + 5: 220, + }; + return sizeMap[size as keyof typeof sizeMap] || 120; + }; + const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); useEffect(() => { @@ -205,7 +249,7 @@ export function MediaView() { className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`} {...dragProps} > -
+
{/* Search and filter controls */}
setNewName(e.target.value)} - onBlur={handleNameSave} - onKeyDown={handleInputKeyDown} - onFocus={(e) => e.target.select()} - maxLength={64} - aria-label="Project name" - autoFocus - /> - ) : ( - e.key === "Enter" && handleNameClick()} + + +
+ + {activeProject?.name} + + + + + + + Projects + + + setIsRenameDialogOpen(true)} + > + + Rename project + + setIsDeleteDialogOpen(true)} + > + + Delete Project + + + + + + Discord + + + + + +
); const centerContent = (
- + + {formatTimeCode(currentTime, "HH:MM:SS:FF", activeProject?.fps || 30)} + + / + {formatTimeCode( getTotalDuration(), "HH:MM:SS:FF", @@ -106,12 +150,20 @@ export function EditorHeader() { + ); @@ -121,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/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx index a673f9955..372a31c50 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/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 359612f8c..4d80b642f 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -5,6 +5,8 @@ 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"; +import { SettingsView } from "./views/settings"; export function MediaPanel() { const { activeTab } = useMediaPanelStore(); @@ -43,12 +45,14 @@ export function MediaPanel() { Adjustment view coming soon...
), + settings: , }; return ( -
+
-
{viewMap[activeTab]}
+ +
{viewMap[activeTab]}
); } diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts index c661f51e5..577417a99 100644 --- a/apps/web/src/components/editor/media-panel/store.ts +++ b/apps/web/src/components/editor/media-panel/store.ts @@ -9,6 +9,7 @@ import { SlidersHorizontalIcon, LucideIcon, TypeIcon, + SettingsIcon, } from "lucide-react"; import { create } from "zustand"; @@ -21,7 +22,8 @@ export type Tab = | "transitions" | "captions" | "filters" - | "adjustment"; + | "adjustment" + | "settings"; export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = { media: { @@ -60,6 +62,10 @@ export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = { icon: SlidersHorizontalIcon, label: "Adjustment", }, + settings: { + icon: SettingsIcon, + label: "Settings", + }, }; interface MediaPanelStore { diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx index bce84a46d..fc4f677dd 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -69,21 +69,20 @@ export function TabBar() { />
{(Object.keys(tabs) as Tab[]).map((tabKey) => { const tab = tabs[tabKey]; return (
setActiveTab(tabKey)} key={tabKey} > - - {tab.label} +
); })} @@ -114,10 +113,10 @@ function ScrollButton({
); 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 ba64d7494..0d3c3f1ec 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 9f752f3be..d06aac84a 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -4,15 +4,19 @@ import { useDragDrop } from "@/hooks/use-drag-drop"; import { processMediaFiles } from "@/lib/media-processing"; import { useMediaStore, type MediaItem } from "@/stores/media-store"; import { + ArrowDown01, + CloudUpload, + Grid2X2, Image, + List, Loader2, Music, - Plus, + Search, Video, ZoomIn, ZoomOut, } from "lucide-react"; -import { useEffect, useRef, useState } from "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"; @@ -22,23 +26,54 @@ 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 { Slider } from "@/components/ui/slider"; +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); @@ -50,6 +85,10 @@ export function MediaView() { const parsed = parseInt(stored ?? "", 10); return !isNaN(parsed) && parsed >= 1 && parsed <= 5 ? parsed : 3; }); + 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; @@ -129,18 +168,6 @@ export function MediaView() { return `${min}:${sec.toString().padStart(2, "0")}`; }; - // Map size levels (1-5) to appropriate grid item widths - const getGridItemWidth = (size: number) => { - const sizeMap = { - 1: 90, - 2: 120, - 3: 160, - 4: 190, - 5: 220, - }; - return sizeMap[size as keyof typeof sizeMap] || 120; - }; - const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); useEffect(() => { @@ -159,79 +186,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]); - - const renderPreview = (item: MediaItem) => { - // Render a preview for each media type (image, video, audio, unknown) - if (item.type === "image") { - return ( -
- {item.name} -
- ); - } + }, [mediaItems, mediaFilter, searchQuery, sortBy, sortOrder]); + + const previewComponents = useMemo(() => { + const previews = new Map(); + + filteredMediaItems.forEach((item) => { + let preview: React.ReactNode; - if (item.type === "video") { - if (item.thumbnailUrl) { - return ( -
+ 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 ( <> @@ -249,61 +314,180 @@ export function MediaView() { className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`} {...dragProps} > -
+
{/* Search and filter controls */} -
- - setSearchQuery(e.target.value)} - /> +
-
-
- - - +
+ {mediaViewMode === "grid" && ( +
+ + + +
+ )} + + + + + + +

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

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

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

+
+
+
+
+
- -
+
+
{isDragOver || filteredMediaItems.length === 0 ? ( + ) : mediaViewMode === "grid" ? ( + ) : ( -
- {/* 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, + mediaSize, +}: { + filteredMediaItems: MediaItem[]; + renderPreview: (item: MediaItem) => React.ReactNode; + handleRemove: (e: React.MouseEvent, id: string) => Promise; + mediaSize: number; +}) { + // Map size levels (1-5) to appropriate grid item widths + const getGridItemWidth = (size: number) => { + const sizeMap = { + 1: 90, + 2: 120, + 3: 160, + 4: 190, + 5: 220, + }; + return sizeMap[size as keyof typeof sizeMap] || 120; + }; + 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/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx new file mode 100644 index 000000000..3bce17537 --- /dev/null +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -0,0 +1,305 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + PropertyItem, + PropertyItemLabel, + PropertyItemValue, + PropertyGroup, +} from "../../properties-panel/property-item"; +import { FPS_PRESETS } from "@/constants/timeline-constants"; +import { useProjectStore } from "@/stores/project-store"; +import { useEditorStore } from "@/stores/editor-store"; +import { useAspectRatio } from "@/hooks/use-aspect-ratio"; +import Image from "next/image"; +import { cn } from "@/lib/utils"; +import { colors } from "@/data/colors/solid"; +import { patternCraftGradients } from "@/data/colors/pattern-craft"; +import { PipetteIcon } from "lucide-react"; +import { useMemo, memo, useCallback } from "react"; +import { syntaxUIGradients } from "@/data/colors/syntax-ui"; + +export function SettingsView() { + return ; +} + +function ProjectSettingsTabs() { + return ( +
+ +
+ + Project info + Background + +
+ + + + + + + + + +
+
+ ); +} + +function ProjectInfoView() { + const { activeProject, updateProjectFps } = useProjectStore(); + const { canvasPresets, setCanvasSize } = useEditorStore(); + const { getDisplayName } = useAspectRatio(); + + const handleAspectRatioChange = (value: string) => { + const preset = canvasPresets.find((p) => p.name === value); + if (preset) { + setCanvasSize({ width: preset.width, height: preset.height }); + } + }; + + const handleFpsChange = (value: string) => { + const fps = parseFloat(value); + updateProjectFps(fps); + }; + + return ( +
+ + Name + + {activeProject?.name || "Untitled project"} + + + + + Aspect ratio + + + + + + + Frame rate + + + + +
+ ); +} + +const BlurPreview = memo( + ({ + blur, + isSelected, + onSelect, + }: { + blur: { label: string; value: number }; + isSelected: boolean; + onSelect: () => void; + }) => ( +
+ {`Blur +
+ + {blur.label} + +
+
+ ) +); + +BlurPreview.displayName = "BlurPreview"; + +const BackgroundPreviews = memo( + ({ + backgrounds, + currentBackgroundColor, + isColorBackground, + handleColorSelect, + useBackgroundColor = false, + }: { + backgrounds: string[]; + currentBackgroundColor: string; + isColorBackground: boolean; + handleColorSelect: (bg: string) => void; + useBackgroundColor?: boolean; + }) => { + return useMemo( + () => + backgrounds.map((bg) => ( +
handleColorSelect(bg)} + /> + )), + [ + backgrounds, + isColorBackground, + currentBackgroundColor, + handleColorSelect, + useBackgroundColor, + ] + ); + } +); + +BackgroundPreviews.displayName = "BackgroundPreviews"; + +function BackgroundView() { + const { activeProject, updateBackgroundType } = useProjectStore(); + + const blurLevels = useMemo( + () => [ + { label: "Light", value: 4 }, + { label: "Medium", value: 8 }, + { label: "Heavy", value: 18 }, + ], + [] + ); + + const handleBlurSelect = useCallback( + async (blurIntensity: number) => { + await updateBackgroundType("blur", { blurIntensity }); + }, + [updateBackgroundType] + ); + + const handleColorSelect = useCallback( + async (color: string) => { + await updateBackgroundType("color", { backgroundColor: color }); + }, + [updateBackgroundType] + ); + + const currentBlurIntensity = activeProject?.blurIntensity || 8; + const isBlurBackground = activeProject?.backgroundType === "blur"; + const currentBackgroundColor = activeProject?.backgroundColor || "#000000"; + const isColorBackground = activeProject?.backgroundType === "color"; + + const blurPreviews = useMemo( + () => + blurLevels.map((blur) => ( + handleBlurSelect(blur.value)} + /> + )), + [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect] + ); + + return ( +
+ +
{blurPreviews}
+
+ + +
+
+ +
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index 0686190e4..ca8e88c9e 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 639858d09..58cc5d5bc 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -5,23 +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"; @@ -211,7 +203,7 @@ export function PreviewPanel() { setDragState({ isDragging: true, elementId: element.id, - trackId: trackId, + trackId, startX: e.clientX, startY: e.clientY, initialElementX: element.x, @@ -233,6 +225,7 @@ export function PreviewPanel() { tracks.forEach((track) => { track.elements.forEach((element) => { + if (element.hidden) return; const elementStart = element.startTime; const elementEnd = element.startTime + @@ -355,8 +348,22 @@ export function PreviewPanel() { handleTextMouseDown(e, element, elementData.track.id) } style={{ - left: `${50 + ((dragState.isDragging && dragState.elementId === element.id ? dragState.currentX : element.x) / canvasSize.width) * 100}%`, - top: `${50 + ((dragState.isDragging && dragState.elementId === element.id ? dragState.currentY : element.y) / canvasSize.height) * 100}%`, + left: `${ + 50 + + ((dragState.isDragging && dragState.elementId === element.id + ? dragState.currentX + : element.x) / + canvasSize.width) * + 100 + }%`, + top: `${ + 50 + + ((dragState.isDragging && dragState.elementId === element.id + ? dragState.currentY + : element.y) / + canvasSize.height) * + 100 + }%`, transform: `translate(-50%, -50%) rotate(${element.rotation}deg) scale(${scaleRatio})`, opacity: element.opacity, zIndex: 100 + index, // Text elements on top @@ -392,11 +399,11 @@ export function PreviewPanel() { return (
🎬
-

{element.name}

+

{element.name}

); @@ -460,10 +467,10 @@ export function PreviewPanel() { return ( <> -
+
{hasAnyElements ? ( @@ -473,7 +480,7 @@ export function PreviewPanel() { style={{ width: previewDimensions.width, height: previewDimensions.height, - backgroundColor: + background: activeProject?.backgroundType === "blur" ? "transparent" : activeProject?.backgroundColor || "#000000", @@ -548,7 +555,7 @@ function FullscreenToolbar({ toggle: () => void; getTotalDuration: () => number; }) { - const { isPlaying } = usePlaybackStore(); + const { isPlaying, seek } = usePlaybackStore(); const { activeProject } = useProjectStore(); const [isDragging, setIsDragging] = useState(false); @@ -605,12 +612,18 @@ function FullscreenToolbar({ return (
-
- - {formatTimeCode(currentTime, "HH:MM:SS:FF", activeProject?.fps || 30)} - +
+ / {formatTimeCode( @@ -627,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" > @@ -637,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 ? ( @@ -650,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" > @@ -660,7 +673,7 @@ function FullscreenToolbar({
@@ -684,11 +697,11 @@ function FullscreenToolbar({
); @@ -722,14 +735,14 @@ function FullscreenPreview({ getTotalDuration: () => number; }) { return ( -
+
-
+
number; }) { const { isPlaying } = 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); - }; if (isExpanded) { return ( @@ -823,87 +818,30 @@ function PreviewToolbar({ return (
-
-

+

- -
- - - - - - - - Original - - - {canvasPresets.map((preset) => ( - handlePresetSelect(preset)} - className={cn( - "text-xs", - currentPreset?.name === preset.name && "font-semibold" - )} - > - {preset.name} - - ))} - - + {isPlaying ? ( + + ) : ( + + )} +
diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index 720c4a269..f1b3f6a35 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -67,7 +67,7 @@ export function PropertiesPanel() {