diff options
Diffstat (limited to 'apps/web/src/components')
30 files changed, 1336 insertions, 956 deletions
diff --git a/apps/web/src/components/background-settings.tsx b/apps/web/src/components/background-settings.tsx deleted file mode 100644 index 0457cb05..00000000 --- a/apps/web/src/components/background-settings.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; -import { Button } from "./ui/button"; -import { BackgroundIcon } from "./icons"; -import { cn } from "@/lib/utils"; -import Image from "next/image"; -import { colors } from "@/data/colors/solid"; -import { useProjectStore } from "@/stores/project-store"; -import { PipetteIcon } from "lucide-react"; - -type BackgroundTab = "color" | "blur"; - -export function BackgroundSettings() { - const { activeProject, updateBackgroundType } = useProjectStore(); - - // ✅ Good: derive activeTab from activeProject during rendering - const activeTab = activeProject?.backgroundType || "color"; - - const handleColorSelect = (color: string) => { - updateBackgroundType("color", { backgroundColor: color }); - }; - - const handleBlurSelect = (blurIntensity: number) => { - updateBackgroundType("blur", { blurIntensity }); - }; - - const tabs = [ - { - label: "Color", - value: "color", - }, - { - label: "Blur", - value: "blur", - }, - ]; - - return ( - <Popover> - <PopoverTrigger asChild> - <Button - variant="text" - size="icon" - className="size-4! border border-muted-foreground" - > - <BackgroundIcon className="size-3!" /> - </Button> - </PopoverTrigger> - <PopoverContent className="flex flex-col items-start w-[20rem] h-64 overflow-hidden p-0"> - <div className="flex items-center justify-between w-full gap-2 z-10 bg-popover p-3"> - <h2 className="text-sm">Background</h2> - <div className="flex items-center gap-2 text-sm"> - {tabs.map((tab) => ( - <span - key={tab.value} - onClick={() => { - // Switch to the background type when clicking tabs - if (tab.value === "color") { - updateBackgroundType("color", { - backgroundColor: - activeProject?.backgroundColor || "#000000", - }); - } else { - updateBackgroundType("blur", { - blurIntensity: activeProject?.blurIntensity || 8, - }); - } - }} - className={cn( - "text-muted-foreground cursor-pointer", - activeTab === tab.value && "text-foreground" - )} - > - {tab.label} - </span> - ))} - </div> - </div> - {activeTab === "color" ? ( - <ColorView - selectedColor={activeProject?.backgroundColor || "#000000"} - onColorSelect={handleColorSelect} - /> - ) : ( - <BlurView - selectedBlur={activeProject?.blurIntensity || 8} - onBlurSelect={handleBlurSelect} - /> - )} - </PopoverContent> - </Popover> - ); -} - -function ColorView({ - selectedColor, - onColorSelect, -}: { - selectedColor: string; - onColorSelect: (color: string) => void; -}) { - return ( - <div className="w-full h-full"> - <div className="absolute top-8 left-0 w-[calc(100%-1rem)] h-12 bg-linear-to-b from-popover to-transparent pointer-events-none" /> - <div className="grid grid-cols-4 gap-2 w-full h-full p-3 pt-0 overflow-auto"> - <div className="w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary flex items-center justify-center"> - <PipetteIcon className="size-4" /> - </div> - {colors.map((color) => ( - <ColorItem - key={color} - color={color} - isSelected={color === selectedColor} - onClick={() => onColorSelect(color)} - /> - ))} - </div> - </div> - ); -} - -function ColorItem({ - color, - isSelected, - onClick, -}: { - color: string; - isSelected: boolean; - onClick: () => void; -}) { - return ( - <div - className={cn( - "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary", - isSelected && "border-2 border-primary" - )} - style={{ backgroundColor: color }} - onClick={onClick} - /> - ); -} - -function BlurView({ - selectedBlur, - onBlurSelect, -}: { - selectedBlur: number; - onBlurSelect: (blurIntensity: number) => void; -}) { - const blurLevels = [ - { label: "Light", value: 4 }, - { label: "Medium", value: 8 }, - { label: "Heavy", value: 18 }, - ]; - const blurImage = - "https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"; - - return ( - <div className="grid grid-cols-3 gap-2 w-full p-3 pt-0"> - {blurLevels.map((blur) => ( - <div - key={blur.value} - className={cn( - "w-full aspect-square rounded-sm cursor-pointer hover:border-2 hover:border-primary relative overflow-hidden", - selectedBlur === blur.value && "border-2 border-primary" - )} - onClick={() => onBlurSelect(blur.value)} - > - <Image - src={blurImage} - alt={`Blur preview ${blur.label}`} - fill - className="object-cover" - style={{ filter: `blur(${blur.value}px)` }} - /> - <div className="absolute bottom-1 left-1 right-1 text-center"> - <span className="text-xs text-white bg-black/50 px-1 rounded"> - {blur.label} - </span> - </div> - </div> - ))} - </div> - ); -} diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx index 79bf401d..60406f61 100644 --- a/apps/web/src/components/editor/audio-waveform.tsx +++ b/apps/web/src/components/editor/audio-waveform.tsx @@ -67,8 +67,8 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ }); newWaveSurfer.on("error", (err) => { - console.error("WaveSurfer error:", err); if (mounted) { + console.error("WaveSurfer error:", err); setError(true); setIsLoading(false); } @@ -76,8 +76,8 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({ await newWaveSurfer.load(audioUrl); } catch (err) { - console.error("Failed to initialize WaveSurfer:", err); if (mounted) { + console.error("Failed to initialize WaveSurfer:", err); setError(true); setIsLoading(false); } diff --git a/apps/web/src/components/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx index ee5dfc17..ad2a5b5a 100644 --- a/apps/web/src/components/editor-header.tsx +++ b/apps/web/src/components/editor/editor-header.tsx @@ -1,10 +1,10 @@ "use client"; -import { Button } from "./ui/button"; +import { Button } from "../ui/button"; import { ChevronDown, ArrowLeft, SquarePen, Trash, Sun } from "lucide-react"; -import { HeaderBase } from "./header-base"; +import { HeaderBase } from "../header-base"; import { useProjectStore } from "@/stores/project-store"; -import { KeyboardShortcutsHelp } from "./keyboard-shortcuts-help"; +import { KeyboardShortcutsHelp } from "../keyboard-shortcuts-help"; import { useState } from "react"; import { DropdownMenu, @@ -12,10 +12,10 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from "./ui/dropdown-menu"; +} from "../ui/dropdown-menu"; import Link from "next/link"; -import { RenameProjectDialog } from "./rename-project-dialog"; -import { DeleteProjectDialog } from "./delete-project-dialog"; +import { RenameProjectDialog } from "../rename-project-dialog"; +import { DeleteProjectDialog } from "../delete-project-dialog"; import { useRouter } from "next/navigation"; import { FaDiscord } from "react-icons/fa6"; import { useTheme } from "next-themes"; diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx new file mode 100644 index 00000000..c9738e81 --- /dev/null +++ b/apps/web/src/components/editor/export-button.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { useState } from "react"; +import { TransitionUpIcon } from "../icons"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { Button } from "../ui/button"; +import { Label } from "../ui/label"; +import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; +import { Progress } from "../ui/progress"; +import { Checkbox } from "../ui/checkbox"; +import { cn } from "@/lib/utils"; +import { + exportProject, + getExportMimeType, + getExportFileExtension, + DEFAULT_EXPORT_OPTIONS, +} from "@/lib/export"; +import { useProjectStore } from "@/stores/project-store"; +import { Download, X } from "lucide-react"; +import { ExportFormat, ExportQuality, ExportResult } from "@/types/export"; +import { PropertyGroup } from "./properties-panel/property-item"; + +export function ExportButton() { + const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false); + const { activeProject } = useProjectStore(); + + const handleExport = () => { + setIsExportPopoverOpen(true); + }; + + const hasProject = !!activeProject; + + return ( + <Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}> + <PopoverTrigger asChild> + <button + type="button" + className={cn( + "flex items-center gap-1.5 bg-[#38BDF8] text-white rounded-md px-[0.12rem] py-[0.12rem] transition-all duration-200", + hasProject + ? "cursor-pointer hover:brightness-95" + : "cursor-not-allowed opacity-50" + )} + onClick={hasProject ? handleExport : undefined} + disabled={!hasProject} + onKeyDown={(event) => { + if (hasProject && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + handleExport(); + } + }} + > + <div className="flex items-center gap-1.5 bg-linear-270 from-[#2567EC] to-[#37B6F7] rounded-[0.8rem] px-4 py-1 relative shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]"> + <TransitionUpIcon className="z-50" /> + <span className="text-[0.875rem] z-50">Export</span> + <div className="absolute w-full h-full left-0 top-0 bg-linear-to-t from-white/0 to-white/50 z-10 rounded-[0.8rem] flex items-center justify-center"> + <div className="absolute w-[calc(100%-2px)] h-[calc(100%-2px)] top-[0.08rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] z-50 rounded-[0.8rem]"></div> + </div> + </div> + </button> + </PopoverTrigger> + {hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />} + </Popover> + ); +} + +function ExportPopover({ + onOpenChange, +}: { + onOpenChange: (open: boolean) => void; +}) { + const { activeProject } = useProjectStore(); + const [format, setFormat] = useState<ExportFormat>( + DEFAULT_EXPORT_OPTIONS.format + ); + const [quality, setQuality] = useState<ExportQuality>( + DEFAULT_EXPORT_OPTIONS.quality + ); + const [includeAudio, setIncludeAudio] = useState<boolean>( + DEFAULT_EXPORT_OPTIONS.includeAudio || true + ); + const [isExporting, setIsExporting] = useState(false); + const [progress, setProgress] = useState(0); + const [exportResult, setExportResult] = useState<ExportResult | null>(null); + + const handleExport = async () => { + if (!activeProject) return; + + setIsExporting(true); + setProgress(0); + setExportResult(null); + + const result = await exportProject({ + format, + quality, + fps: activeProject.fps, + includeAudio, + onProgress: setProgress, + onCancel: () => false, // TODO: Add cancel functionality + }); + + setIsExporting(false); + setExportResult(result); + + if (result.success && result.buffer) { + // Download the file + const mimeType = getExportMimeType(format); + const extension = getExportFileExtension(format); + const blob = new Blob([result.buffer], { type: mimeType }); + const url = URL.createObjectURL(blob); + + const a = document.createElement("a"); + a.href = url; + a.download = `${activeProject.name}${extension}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + onOpenChange(false); + setExportResult(null); + setProgress(0); + } + }; + + const handleClose = () => { + if (!isExporting) { + onOpenChange(false); + setExportResult(null); + setProgress(0); + } + }; + + return ( + <PopoverContent className="w-80 mr-4 flex flex-col gap-3"> + <> + <div className="flex items-center justify-between"> + <h3 className=" font-medium"> + {isExporting ? "Exporting project" : "Export project"} + </h3> + <Button variant="text" size="icon" onClick={handleClose}> + <X className="!size-5 text-foreground/85" /> + </Button> + </div> + + <div className="flex flex-col gap-4"> + {!isExporting && ( + <> + <div className="flex flex-col gap-3"> + <PropertyGroup + title="Format" + titleClassName="text-sm" + defaultExpanded={false} + > + <RadioGroup + value={format} + onValueChange={(value) => setFormat(value as ExportFormat)} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="mp4" id="mp4" /> + <Label htmlFor="mp4"> + MP4 (H.264) - Better compatibility + </Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="webm" id="webm" /> + <Label htmlFor="webm"> + WebM (VP9) - Smaller file size + </Label> + </div> + </RadioGroup> + </PropertyGroup> + + <PropertyGroup + title="Quality" + titleClassName="text-sm" + defaultExpanded={false} + > + <RadioGroup + value={quality} + onValueChange={(value) => + setQuality(value as ExportQuality) + } + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="low" id="low" /> + <Label htmlFor="low">Low - Smallest file size</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="medium" id="medium" /> + <Label htmlFor="medium">Medium - Balanced</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="high" id="high" /> + <Label htmlFor="high">High - Recommended</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="very_high" id="very_high" /> + <Label htmlFor="very_high"> + Very High - Largest file size + </Label> + </div> + </RadioGroup> + </PropertyGroup> + + <PropertyGroup + title="Audio" + titleClassName="text-sm" + defaultExpanded={false} + > + <div className="flex items-center space-x-2"> + <Checkbox + id="include-audio" + checked={includeAudio} + onCheckedChange={(checked) => setIncludeAudio(!!checked)} + /> + <Label htmlFor="include-audio"> + Include audio in export + </Label> + </div> + </PropertyGroup> + </div> + + <Button onClick={handleExport} className="w-full gap-2"> + <Download className="w-4 h-4" /> + Export + </Button> + </> + )} + + {isExporting && ( + <div className="space-y-4"> + <div className="flex flex-col"> + <div className="text-center flex items-center justify-between"> + <p className="text-sm text-muted-foreground mb-2"> + {Math.round(progress * 100)}% + </p> + <p className="text-sm text-muted-foreground mb-2">100%</p> + </div> + <Progress value={progress * 100} className="w-full" /> + </div> + + <Button + variant="outline" + className="rounded-md w-full" + onClick={() => {}} + > + Cancel + </Button> + </div> + )} + + {exportResult && !exportResult.success && ( + <div className="text-center space-y-3"> + <div className="text-red-600 font-medium">Export failed</div> + <p className="text-sm text-muted-foreground"> + {exportResult.error || "Unknown error occurred"} + </p> + <Button variant="outline" onClick={() => setExportResult(null)}> + Try Again + </Button> + </div> + )} + </div> + </> + </PopoverContent> + ); +} diff --git a/apps/web/src/components/editor/media-panel/views/captions.tsx b/apps/web/src/components/editor/media-panel/views/captions.tsx index 4fe2cbd7..59403a8d 100644 --- a/apps/web/src/components/editor/media-panel/views/captions.tsx +++ b/apps/web/src/components/editor/media-panel/views/captions.tsx @@ -1,11 +1,12 @@ import { Button } from "@/components/ui/button"; import { PropertyGroup } from "../../properties-panel/property-item"; -import { BaseView } from "./base-view"; +import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view"; import { Language, LanguageSelect } from "@/components/language-select"; import { useState, useRef, useEffect } from "react"; -import { extractTimelineAudio } from "@/lib/ffmpeg-utils"; +import { extractTimelineAudio } from "@/lib/mediabunny-utils"; import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption"; import { useTimelineStore } from "@/stores/timeline-store"; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; import { Loader2, Shield, Trash2, Upload } from "lucide-react"; import { Dialog, @@ -162,24 +163,13 @@ export function Captions() { // Add all caption elements to the same track shortCaptions.forEach((caption, index) => { addElementToTrack(captionTrackId, { - type: "text", + ...DEFAULT_TEXT_ELEMENT, name: `Caption ${index + 1}`, content: caption.text, duration: caption.duration, startTime: caption.startTime, - trimStart: 0, - trimEnd: 0, - fontSize: 65, - fontFamily: "Arial", - color: "#ffffff", - textAlign: "center", - fontWeight: "bold", - fontStyle: "normal", - textDecoration: "none", - x: 0, - y: 0, - rotation: 0, - opacity: 1, + fontSize: 65, // Larger for captions + fontWeight: "bold", // Bold for captions } as TextElement); }); 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 c84dde70..07a44a7c 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -2,7 +2,8 @@ import { useDragDrop } from "@/hooks/use-drag-drop"; import { processMediaFiles } from "@/lib/media-processing"; -import { useMediaStore, type MediaItem } from "@/stores/media-store"; +import { useMediaStore } from "@/stores/media-store"; +import { MediaFile } from "@/types/media"; import { ArrowDown01, CloudUpload, @@ -13,7 +14,7 @@ import { Music, Video, } from "lucide-react"; -import { useRef, useState, useMemo, useEffect } from "react"; +import { useRef, useState, useMemo } from "react"; import { useHighlightScroll } from "@/hooks/use-highlight-scroll"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -47,7 +48,7 @@ function MediaItemWithContextMenu({ children, onRemove, }: { - item: MediaItem; + item: MediaFile; children: React.ReactNode; onRemove: (e: React.MouseEvent, id: string) => Promise<void>; }) { @@ -68,14 +69,12 @@ function MediaItemWithContextMenu({ } export function MediaView() { - const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); + const { mediaFiles, addMediaFile, removeMediaFile } = useMediaStore(); const { activeProject } = useProjectStore(); const { mediaViewMode, setMediaViewMode } = usePanelStore(); const fileInputRef = useRef<HTMLInputElement>(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" ); @@ -96,16 +95,13 @@ export function MediaView() { setIsProcessing(true); setProgress(0); try { - // Process files (extract metadata, generate thumbnails, etc.) const processedItems = await processMediaFiles(files, (p) => setProgress(p) ); - // Add each processed media item to the store for (const item of processedItems) { - await addMediaItem(activeProject.id, item); + await addMediaFile(activeProject.id, item); } } catch (error) { - // Show error toast if processing fails console.error("Error processing files:", error); toast.error("Failed to process files"); } finally { @@ -122,13 +118,11 @@ export function MediaView() { const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { - // When files are selected via file picker, process them if (e.target.files) processFiles(e.target.files); e.target.value = ""; // Reset input }; const handleRemove = async (e: React.MouseEvent, id: string) => { - // Remove a media item from the store e.stopPropagation(); if (!activeProject) { @@ -136,8 +130,7 @@ export function MediaView() { return; } - // Media store now handles cascade deletion automatically - await removeMediaItem(activeProject.id, id); + await removeMediaFile(activeProject.id, id); }; const formatDuration = (duration: number) => { @@ -147,28 +140,15 @@ export function MediaView() { return `${min}:${sec.toString().padStart(2, "0")}`; }; - const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); - - useEffect(() => { - let filtered = mediaItems.filter((item) => { + const filteredMediaItems = useMemo(() => { + let filtered = mediaFiles.filter((item) => { if (item.ephemeral) return false; - if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { - return false; - } - - if ( - searchQuery && - !item.name.toLowerCase().includes(searchQuery.toLowerCase()) - ) { - return false; - } - return true; }); filtered.sort((a, b) => { - let valueA: any; - let valueB: any; + let valueA: string | number; + let valueB: string | number; switch (sortBy) { case "name": @@ -196,8 +176,8 @@ export function MediaView() { return 0; }); - setFilteredMediaItems(filtered); - }, [mediaItems, mediaFilter, searchQuery, sortBy, sortOrder]); + return filtered; + }, [mediaFiles, sortBy, sortOrder]); const previewComponents = useMemo(() => { const previews = new Map<string, React.ReactNode>(); @@ -276,7 +256,7 @@ export function MediaView() { return previews; }, [filteredMediaItems]); - const renderPreview = (item: MediaItem) => previewComponents.get(item.id); + const renderPreview = (item: MediaFile) => previewComponents.get(item.id); return ( <> @@ -481,12 +461,14 @@ function GridView({ highlightedId, registerElement, }: { - filteredMediaItems: MediaItem[]; - renderPreview: (item: MediaItem) => React.ReactNode; + filteredMediaItems: MediaFile[]; + renderPreview: (item: MediaFile) => React.ReactNode; handleRemove: (e: React.MouseEvent, id: string) => Promise<void>; highlightedId: string | null; registerElement: (id: string, element: HTMLElement | null) => void; }) { + const { addElementAtTime } = useTimelineStore(); + return ( <div className="grid gap-2" @@ -507,7 +489,7 @@ function GridView({ }} showPlusOnDrag={false} onAddToTimeline={(currentTime) => - useTimelineStore.getState().addMediaAtTime(item, currentTime) + addElementAtTime(item, currentTime) } rounded={false} variant="card" @@ -527,12 +509,14 @@ function ListView({ highlightedId, registerElement, }: { - filteredMediaItems: MediaItem[]; - renderPreview: (item: MediaItem) => React.ReactNode; + filteredMediaItems: MediaFile[]; + renderPreview: (item: MediaFile) => React.ReactNode; handleRemove: (e: React.MouseEvent, id: string) => Promise<void>; highlightedId: string | null; registerElement: (id: string, element: HTMLElement | null) => void; }) { + const { addElementAtTime } = useTimelineStore(); + return ( <div className="space-y-1"> {filteredMediaItems.map((item) => ( @@ -548,7 +532,7 @@ function ListView({ }} showPlusOnDrag={false} onAddToTimeline={(currentTime) => - useTimelineStore.getState().addMediaAtTime(item, currentTime) + addElementAtTime(item, currentTime) } variant="compact" isHighlighted={highlightedId === item.id} diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx index d33b4cce..df1ec54f 100644 --- a/apps/web/src/components/editor/media-panel/views/settings.tsx +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -7,7 +7,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { BaseView } from "./base-view"; +import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view"; import { PropertyItem, PropertyItemLabel, diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx index 7d3e073e..41afe285 100644 --- a/apps/web/src/components/editor/media-panel/views/stickers.tsx +++ b/apps/web/src/components/editor/media-panel/views/stickers.tsx @@ -35,9 +35,10 @@ import { ICONIFY_HOSTS, POPULAR_COLLECTIONS, } from "@/lib/iconify-api"; -import { cn, generateUUID } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import Image from "next/image"; +import type { MediaFile } from "@/types/media"; import { DraggableMediaItem } from "@/components/ui/draggable-item"; import { InputWithBack } from "@/components/ui/input-with-back"; import { StickerCategory } from "@/stores/stickers-store"; @@ -185,9 +186,9 @@ function EmptyView({ message }: { message: string }) { function StickersContentView({ category }: { category: StickerCategory }) { const { activeProject } = useProjectStore(); - const { addMediaAtTime } = useTimelineStore(); + const { addElementAtTime } = useTimelineStore(); const { currentTime } = usePlaybackStore(); - const { addMediaItem } = useMediaStore(); + const { addMediaFile } = useMediaStore(); const { searchQuery, selectedCollection, @@ -286,9 +287,9 @@ function StickersContentView({ category }: { category: StickerCategory }) { throw new Error("Failed to download sticker"); } - const mediaItem = { + const mediaItem: Omit<MediaFile, "id"> = { name: iconName.replace(":", "-"), - type: "image" as const, + type: "image", file, url: URL.createObjectURL(file), width: 200, @@ -297,16 +298,16 @@ function StickersContentView({ category }: { category: StickerCategory }) { ephemeral: false, }; - await addMediaItem(activeProject.id, mediaItem); + await addMediaFile(activeProject.id, mediaItem); const added = useMediaStore .getState() - .mediaItems.find( + .mediaFiles.find( (m) => m.url === mediaItem.url && m.name === mediaItem.name ); if (!added) throw new Error("Sticker not in media store"); - addMediaAtTime(added, currentTime); + addElementAtTime(added, currentTime); toast.success(`Added "${iconName}" to timeline`); } catch (error) { @@ -565,7 +566,12 @@ interface StickerItemProps { capSize?: boolean; } -function StickerItem({ iconName, onAdd, isAdding, capSize = false }: StickerItemProps) { +function StickerItem({ + iconName, + onAdd, + isAdding, + capSize = false, +}: StickerItemProps) { const [imageError, setImageError] = useState(false); const [hostIndex, setHostIndex] = useState(0); @@ -601,7 +607,10 @@ function StickerItem({ iconName, onAdd, isAdding, capSize = false }: StickerItem className="w-full h-full object-contain" style={ capSize - ? { maxWidth: "var(--sticker-max, 160px)", maxHeight: "var(--sticker-max, 160px)" } + ? { + maxWidth: "var(--sticker-max, 160px)", + maxHeight: "var(--sticker-max, 160px)", + } : undefined } onError={() => { @@ -631,8 +640,8 @@ function StickerItem({ iconName, onAdd, isAdding, capSize = false }: StickerItem name={displayName} preview={preview} dragData={{ - type: "sticker", - iconName: iconName, + id: "sticker-placeholder", + type: "image", name: displayName, }} onAddToTimeline={() => onAdd(iconName)} 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 49eacbc7..22fc2413 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -1,31 +1,7 @@ import { DraggableMediaItem } from "@/components/ui/draggable-item"; -import { BaseView } from "./base-view"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view"; import { useTimelineStore } from "@/stores/timeline-store"; -import { type TextElement } from "@/types/timeline"; - -const 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, -}; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; export function TextView() { return ( @@ -38,14 +14,20 @@ export function TextView() { </div> } dragData={{ - id: textData.id, - type: textData.type, - name: textData.name, - content: textData.content, + id: "temp-text-id", + type: DEFAULT_TEXT_ELEMENT.type, + name: DEFAULT_TEXT_ELEMENT.name, + content: DEFAULT_TEXT_ELEMENT.content, }} aspectRatio={1} onAddToTimeline={(currentTime) => - useTimelineStore.getState().addTextAtTime(textData, currentTime) + useTimelineStore.getState().addElementAtTime( + { + ...DEFAULT_TEXT_ELEMENT, + id: "temp-text-id", + }, + currentTime + ) } showLabel={false} /> diff --git a/apps/web/src/components/onboarding.tsx b/apps/web/src/components/editor/onboarding.tsx index ac4b0f04..8ee60690 100644 --- a/apps/web/src/components/onboarding.tsx +++ b/apps/web/src/components/editor/onboarding.tsx @@ -1,7 +1,7 @@ "use client"; -import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"; -import { Button } from "./ui/button"; +import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"; +import { Button } from "../ui/button"; import { ArrowRightIcon } from "lucide-react"; import { useState, useEffect } from "react"; import ReactMarkdown from "react-markdown"; diff --git a/apps/web/src/components/editor/media-panel/views/base-view.tsx b/apps/web/src/components/editor/panel-base-view.tsx index 2cd33535..ec01028c 100644 --- a/apps/web/src/components/editor/media-panel/views/base-view.tsx +++ b/apps/web/src/components/editor/panel-base-view.tsx @@ -2,9 +2,11 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-interface BaseViewProps {
+interface PanelBaseViewProps {
children?: React.ReactNode;
defaultTab?: string;
+ value?: string;
+ onValueChange?: (value: string) => void;
tabs?: {
value: string;
label: string;
@@ -28,29 +30,38 @@ function ViewContent({ );
}
-export function BaseView({
+export function PanelBaseView({
children,
defaultTab,
+ value,
+ onValueChange,
tabs,
className = "",
ref,
-}: BaseViewProps) {
+}: PanelBaseViewProps) {
return (
<div className={`h-full flex flex-col ${className}`} ref={ref}>
{!tabs || tabs.length === 0 ? (
<ViewContent className={className}>{children}</ViewContent>
) : (
- <Tabs defaultValue={defaultTab} className="flex flex-col h-full">
- <div className="px-3 pt-4 pb-0">
- <TabsList>
- {tabs.map((tab) => (
- <TabsTrigger key={tab.value} value={tab.value}>
- {tab.label}
- </TabsTrigger>
- ))}
- </TabsList>
+ <Tabs
+ defaultValue={defaultTab}
+ value={value}
+ onValueChange={onValueChange}
+ className="flex flex-col h-full"
+ >
+ <div className="sticky top-0 z-10 bg-panel">
+ <div className="px-3 pt-3.5 pb-0">
+ <TabsList>
+ {tabs.map((tab) => (
+ <TabsTrigger key={tab.value} value={tab.value}>
+ {tab.label}
+ </TabsTrigger>
+ ))}
+ </TabsList>
+ </div>
+ <Separator className="mt-3.5" />
</div>
- <Separator className="mt-4" />
{tabs.map((tab) => (
<TabsContent
key={tab.value}
diff --git a/apps/web/src/components/panel-preset-selector.tsx b/apps/web/src/components/editor/panel-preset-selector.tsx index c5351f76..af22a171 100644 --- a/apps/web/src/components/panel-preset-selector.tsx +++ b/apps/web/src/components/editor/panel-preset-selector.tsx @@ -1,13 +1,13 @@ "use client";
-import { Button } from "./ui/button";
+import { Button } from "../ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
-} from "./ui/dropdown-menu";
+} from "../ui/dropdown-menu";
import { ChevronDown, RotateCcw, LayoutPanelTop } from "lucide-react";
import { usePanelStore, type PanelPreset } from "@/stores/panel-store";
@@ -51,7 +51,7 @@ export function PanelPresetSelector() { </Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
- <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
+ <div className="px-3 py-2 text-xs font-medium text-muted-foreground">
Panel Presets
</div>
<DropdownMenuSeparator />
@@ -59,7 +59,7 @@ export function PanelPresetSelector() { <DropdownMenuItem
key={preset}
onClick={() => handlePresetChange(preset)}
- className="flex items-start justify-between gap-2 py-2 cursor-pointer"
+ className="flex items-start justify-between gap-2 py-2 px-3 cursor-pointer"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 42c4becc..1654624c 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -2,19 +2,22 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { TimelineElement, TimelineTrack } from "@/types/timeline"; -import { useMediaStore, type MediaItem } from "@/stores/media-store"; +import { useMediaStore } from "@/stores/media-store"; +import { MediaFile } from "@/types/media"; import { usePlaybackStore } from "@/stores/playback-store"; import { useEditorStore } from "@/stores/editor-store"; -import { VideoPlayer } from "@/components/ui/video-player"; -import { AudioPlayer } from "@/components/ui/audio-player"; import { Button } from "@/components/ui/button"; import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react"; import { useState, useRef, useEffect, useCallback } from "react"; +import { renderTimelineFrame } from "@/lib/timeline-renderer"; 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 { DEFAULT_CANVAS_SIZE, DEFAULT_FPS, useProjectStore } from "@/stores/project-store"; +import { + DEFAULT_CANVAS_SIZE, + DEFAULT_FPS, + useProjectStore, +} from "@/stores/project-store"; import { TextElementDragState } from "@/types/editor"; import { Popover, @@ -30,15 +33,27 @@ import { PLATFORM_LAYOUTS, type PlatformLayout } from "@/stores/editor-store"; interface ActiveElement { element: TimelineElement; track: TimelineTrack; - mediaItem: MediaItem | null; + mediaItem: MediaFile | null; } export function PreviewPanel() { const { tracks, getTotalDuration, updateTextElement } = useTimelineStore(); - const { mediaItems } = useMediaStore(); + const { mediaFiles } = useMediaStore(); const { currentTime, toggle, setCurrentTime } = usePlaybackStore(); + const { isPlaying, volume, muted } = usePlaybackStore(); const { activeProject } = useProjectStore(); const previewRef = useRef<HTMLDivElement>(null); + const canvasRef = useRef<HTMLCanvasElement>(null); + const lastFrameTimeRef = useRef(0); + const renderSeqRef = useRef(0); + const offscreenCanvasRef = useRef<OffscreenCanvas | HTMLCanvasElement | null>( + null + ); + + const audioContextRef = useRef<AudioContext | null>(null); + const audioGainRef = useRef<GainNode | null>(null); + const audioBuffersRef = useRef<Map<string, AudioBuffer>>(new Map()); + const playingSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set()); const containerRef = useRef<HTMLDivElement>(null); const [previewDimensions, setPreviewDimensions] = useState({ width: 0, @@ -249,7 +264,7 @@ export function PreviewPanel() { mediaItem = element.mediaId === "test" ? null - : mediaItems.find((item) => item.id === element.mediaId) || + : mediaFiles.find((item) => item.id === element.mediaId) || null; } activeElements.push({ element, track, mediaItem }); @@ -262,227 +277,314 @@ export function PreviewPanel() { const activeElements = getActiveElements(); - // Get media elements for blur background (video/image only) - const getBlurBackgroundElements = (): ActiveElement[] => { - return activeElements.filter( - ({ element, mediaItem }) => - element.type === "media" && - mediaItem && - (mediaItem.type === "video" || mediaItem.type === "image") && - element.mediaId !== "test" // Exclude test elements - ); - }; - - const blurBackgroundElements = getBlurBackgroundElements(); + // Ensure first frame after mount/seek renders immediately + useEffect(() => { + const onSeek = () => { + lastFrameTimeRef.current = -Infinity; + renderSeqRef.current++; + }; + window.addEventListener("playback-seek", onSeek as EventListener); + lastFrameTimeRef.current = -Infinity; + return () => { + window.removeEventListener("playback-seek", onSeek as EventListener); + }; + }, []); - // Render blur background layer - const renderBlurBackground = () => { - if ( - !activeProject?.backgroundType || - activeProject.backgroundType !== "blur" || - blurBackgroundElements.length === 0 - ) { - return null; - } + // Web Audio: schedule only on play/pause/seek/volume/mute changes + useEffect(() => { + const stopAll = () => { + for (const src of playingSourcesRef.current) { + try { + src.stop(); + } catch {} + } + playingSourcesRef.current.clear(); + }; - // Use the first media element for background (could be enhanced to use primary/focused element) - const backgroundElement = blurBackgroundElements[0]; - const { element, mediaItem } = backgroundElement; + type WebAudioWindow = Window & { + AudioContext?: typeof AudioContext; + webkitAudioContext?: typeof AudioContext; + }; + const ensureAudioGraph = async () => { + if (!audioContextRef.current) { + const win = window as WebAudioWindow; + const Ctx = win.AudioContext ?? win.webkitAudioContext; + if (!Ctx) return; + audioContextRef.current = new Ctx(); + } + if (!audioGainRef.current) { + audioGainRef.current = audioContextRef.current!.createGain(); + audioGainRef.current.connect(audioContextRef.current!.destination); + } + if (audioContextRef.current!.state === "suspended") { + try { + await audioContextRef.current!.resume(); + } catch {} + } + const gainValue = muted ? 0 : Math.max(0, Math.min(1, volume)); + audioGainRef.current!.gain.setValueAtTime( + gainValue, + audioContextRef.current!.currentTime + ); + }; - if (!mediaItem) return null; + const scheduleNow = async () => { + await ensureAudioGraph(); + const audioCtx = audioContextRef.current!; + const gain = audioGainRef.current!; - const blurIntensity = activeProject.blurIntensity || 8; + const tracksSnapshot = useTimelineStore.getState().tracks; + const mediaList = mediaFiles; + const idToMedia = new Map(mediaList.map((m) => [m.id, m] as const)); + const playbackNow = usePlaybackStore.getState().currentTime; - if (mediaItem.type === "video") { - return ( - <div - key={`blur-${element.id}`} - className="absolute inset-0 overflow-hidden" - style={{ - filter: `blur(${blurIntensity}px)`, - transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts - transformOrigin: "center", - }} - > - <VideoPlayer - src={mediaItem.url!} - poster={mediaItem.thumbnailUrl} - clipStartTime={element.startTime} - trimStart={element.trimStart} - trimEnd={element.trimEnd} - clipDuration={element.duration} - className="w-full h-full object-cover" - trackMuted={true} - /> - </div> - ); - } + const audible: Array<{ + id: string; + elementStart: number; + trimStart: number; + trimEnd: number; + duration: number; + muted: boolean; + trackMuted: boolean; + }> = []; + const uniqueIds = new Set<string>(); + for (const track of tracksSnapshot) { + for (const element of track.elements) { + if (element.type !== "media") continue; + const media = idToMedia.get(element.mediaId); + if (!media || media.type !== "audio") continue; + const visibleDuration = + element.duration - element.trimStart - element.trimEnd; + if (visibleDuration <= 0) continue; + const localTime = playbackNow - element.startTime + element.trimStart; + if (localTime < 0 || localTime >= visibleDuration) continue; + audible.push({ + id: media.id, + elementStart: element.startTime, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + duration: element.duration, + muted: !!element.muted, + trackMuted: !!track.muted, + }); + uniqueIds.add(media.id); + } + } - if (mediaItem.type === "image") { - return ( - <div - key={`blur-${element.id}`} - className="absolute inset-0 overflow-hidden" - style={{ - filter: `blur(${blurIntensity}px)`, - transform: "scale(1.1)", // Slightly zoom to avoid blur edge artifacts - transformOrigin: "center", - }} - > - <img - src={mediaItem.url!} - alt={mediaItem.name} - className="w-full h-full object-cover" - draggable={false} - /> - </div> - ); - } + if (audible.length === 0) return; - return null; - }; + // Decode buffers as needed + const decodePromises: Array<Promise<void>> = []; + for (const id of uniqueIds) { + if (!audioBuffersRef.current.has(id)) { + const mediaItem = idToMedia.get(id); + if (!mediaItem) continue; + const p = (async () => { + const arr = await mediaItem.file.arrayBuffer(); + const buf = await audioCtx.decodeAudioData(arr.slice(0)); + audioBuffersRef.current.set(id, buf); + })(); + decodePromises.push(p); + } + } + await Promise.all(decodePromises); - // Render an element - const renderElement = (elementData: ActiveElement, index: number) => { - const { element, mediaItem } = elementData; + const startAt = audioCtx.currentTime + 0.02; + for (const entry of audible) { + if (entry.muted || entry.trackMuted) continue; + const buffer = audioBuffersRef.current.get(entry.id); + if (!buffer) continue; + const visibleDuration = + entry.duration - entry.trimStart - entry.trimEnd; + const localTime = Math.max( + 0, + playbackNow - entry.elementStart + entry.trimStart + ); + const playDuration = Math.max(0, visibleDuration - localTime); + if (playDuration <= 0) continue; + const src = audioCtx.createBufferSource(); + src.buffer = buffer; + src.connect(gain); + try { + src.start(startAt, localTime, playDuration); + playingSourcesRef.current.add(src); + } catch {} + } + }; - // Text elements - if (element.type === "text") { - const fontClassName = - FONT_CLASS_MAP[element.fontFamily as keyof typeof FONT_CLASS_MAP] || ""; + const onSeek = () => { + if (!isPlaying) return; + for (const src of playingSourcesRef.current) { + try { + src.stop(); + } catch {} + } + playingSourcesRef.current.clear(); + void scheduleNow(); + }; - const scaleRatio = previewDimensions.width / canvasSize.width; + // Apply volume/mute changes immediately + void ensureAudioGraph(); - return ( - <div - key={element.id} - className="absolute cursor-grab" - onMouseDown={(e) => - 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 - }%`, - transform: `translate(-50%, -50%) rotate(${element.rotation}deg)`, - opacity: element.opacity, - zIndex: 100 + index, // Text elements on top - }} - > - <div - className={fontClassName} - style={{ - fontSize: `${element.fontSize * scaleRatio}px`, - color: element.color, - backgroundColor: element.backgroundColor, - textAlign: element.textAlign, - fontWeight: element.fontWeight, - fontStyle: element.fontStyle, - textDecoration: element.textDecoration, - padding: `${4 * scaleRatio}px ${8 * scaleRatio}px`, - borderRadius: `${2 * scaleRatio}px`, - whiteSpace: "nowrap", - // Fallback for system fonts that don't have classes - ...(fontClassName === "" && { fontFamily: element.fontFamily }), - }} - > - {element.content} - </div> - </div> - ); + // Start/stop on play state changes + for (const src of playingSourcesRef.current) { + try { + src.stop(); + } catch {} + } + playingSourcesRef.current.clear(); + if (isPlaying) { + void scheduleNow(); } - // Media elements - if (element.type === "media") { - // Test elements - if (!mediaItem || element.mediaId === "test") { - return ( - <div - key={element.id} - className="absolute inset-0 bg-linear-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center" - > - <div className="text-center"> - <div className="text-2xl mb-2">🎬</div> - <p className="text-xs text-foreground">{element.name}</p> - </div> - </div> - ); + window.addEventListener("playback-seek", onSeek as EventListener); + return () => { + window.removeEventListener("playback-seek", onSeek as EventListener); + for (const src of playingSourcesRef.current) { + try { + src.stop(); + } catch {} } + playingSourcesRef.current.clear(); + }; + }, [isPlaying, volume, muted, mediaFiles]); - // Video elements - if (mediaItem.type === "video") { - return ( - <div - key={element.id} - className="absolute inset-0 flex items-center justify-center" - > - <VideoPlayer - src={mediaItem.url!} - poster={mediaItem.thumbnailUrl} - clipStartTime={element.startTime} - trimStart={element.trimStart} - trimEnd={element.trimEnd} - clipDuration={element.duration} - trackMuted={element.muted || elementData.track.muted} - /> - </div> - ); + // Canvas: draw current frame for visible elements using offscreen compositing + useEffect(() => { + const draw = async () => { + const canvas = canvasRef.current; + if (!canvas) return; + const mainCtx = canvas.getContext("2d"); + if (!mainCtx) return; + + // Set canvas internal resolution to avoid blurry scaling + const displayWidth = Math.max(1, Math.floor(previewDimensions.width)); + const displayHeight = Math.max(1, Math.floor(previewDimensions.height)); + if (canvas.width !== displayWidth || canvas.height !== displayHeight) { + canvas.width = displayWidth; + canvas.height = displayHeight; } - // Image elements - if (mediaItem.type === "image") { - return ( - <div - key={element.id} - className="absolute inset-0 flex items-center justify-center" - > - <img - src={mediaItem.url!} - alt={mediaItem.name} - className="max-w-full max-h-full object-contain" - draggable={false} - /> - </div> - ); + // Throttle rendering to project FPS during playback only + const fps = activeProject?.fps || DEFAULT_FPS; + const minDelta = 1 / fps; + if (isPlaying) { + if (currentTime - lastFrameTimeRef.current < minDelta) { + return; + } + lastFrameTimeRef.current = currentTime; } - // Audio elements (no visual representation) - if (mediaItem.type === "audio") { - return ( - <div - key={element.id} - className="absolute inset-0" - style={{ pointerEvents: "none" }} - > - <AudioPlayer - src={mediaItem.url!} - clipStartTime={element.startTime} - trimStart={element.trimStart} - trimEnd={element.trimEnd} - clipDuration={element.duration} - trackMuted={element.muted || elementData.track.muted} - /> - </div> + // Invalidate older async renders when user scrubs rapidly + const mySeq = (renderSeqRef.current += 1); + + // Offscreen buffer to avoid flicker (reuse canvas) + if (!offscreenCanvasRef.current) { + const hasOffscreen = + typeof (globalThis as unknown as { OffscreenCanvas?: unknown }) + .OffscreenCanvas !== "undefined"; + if (hasOffscreen) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + offscreenCanvasRef.current = new (globalThis as any).OffscreenCanvas( + displayWidth, + displayHeight + ) as OffscreenCanvas; + } else { + const c = document.createElement("canvas"); + c.width = displayWidth; + c.height = displayHeight; + offscreenCanvasRef.current = c; + } + } + // Ensure size matches + if ( + offscreenCanvasRef.current && + (offscreenCanvasRef.current as HTMLCanvasElement).getContext + ) { + const c = offscreenCanvasRef.current as HTMLCanvasElement; + if (c.width !== displayWidth || c.height !== displayHeight) { + c.width = displayWidth; + c.height = displayHeight; + } + } else { + const c = offscreenCanvasRef.current as OffscreenCanvas; + // @ts-ignore width/height exist on OffscreenCanvas in modern browsers + if ( + (c as unknown as { width: number }).width !== displayWidth || + (c as unknown as { height: number }).height !== displayHeight + ) { + // @ts-ignore + (c as unknown as { width: number }).width = displayWidth; + // @ts-ignore + (c as unknown as { height: number }).height = displayHeight; + } + } + const offscreenCanvas = offscreenCanvasRef.current as + | HTMLCanvasElement + | OffscreenCanvas; + const offCtx = (offscreenCanvas as HTMLCanvasElement).getContext + ? (offscreenCanvas as HTMLCanvasElement).getContext("2d") + : (offscreenCanvas as OffscreenCanvas).getContext("2d"); + if (!offCtx) return; + + await renderTimelineFrame({ + ctx: offCtx as CanvasRenderingContext2D, + time: currentTime, + canvasWidth: displayWidth, + canvasHeight: displayHeight, + tracks, + mediaFiles, + backgroundColor: + activeProject?.backgroundType === "blur" + ? "transparent" + : activeProject?.backgroundColor || "#000000", + projectCanvasSize: canvasSize, + }); + + // Blit offscreen to visible canvas + mainCtx.clearRect(0, 0, displayWidth, displayHeight); + if ((offscreenCanvas as HTMLCanvasElement).getContext) { + mainCtx.drawImage(offscreenCanvas as HTMLCanvasElement, 0, 0); + } else { + mainCtx.drawImage( + offscreenCanvas as unknown as CanvasImageSource, + 0, + 0 ); } - } + }; + + void draw(); + }, [ + activeElements, + currentTime, + previewDimensions.width, + previewDimensions.height, + canvasSize.width, + canvasSize.height, + activeProject?.backgroundType, + activeProject?.backgroundColor, + ]); - return null; + // Get media elements for blur background (video/image only) + const getBlurBackgroundElements = (): ActiveElement[] => { + return activeElements.filter( + ({ element, mediaItem }) => + element.type === "media" && + mediaItem && + (mediaItem.type === "video" || mediaItem.type === "image") && + element.mediaId !== "test" // Exclude test elements + ); }; + const blurBackgroundElements = getBlurBackgroundElements(); + + // Render blur background layer (handled by canvas now) + const renderBlurBackground = () => null; + + // Render an element (canvas handles visuals now). Audio playback to be implemented via Web Audio. + const renderElement = (_elementData: ActiveElement) => null; + return ( <> <div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm relative"> @@ -505,14 +607,23 @@ export function PreviewPanel() { }} > {renderBlurBackground()} + <canvas + ref={canvasRef} + style={{ + position: "absolute", + left: 0, + top: 0, + width: previewDimensions.width, + height: previewDimensions.height, + }} + aria-label="Video preview canvas" + /> {activeElements.length === 0 ? ( <div className="absolute inset-0 flex items-center justify-center text-muted-foreground"> No elements at current time </div> ) : ( - activeElements.map((elementData, index) => - renderElement(elementData, index) - ) + activeElements.map((elementData) => renderElement(elementData)) )} <LayoutGuideOverlay /> </div> diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index 6c79327e..150f9512 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -10,7 +10,7 @@ import { SquareSlashIcon } from "lucide-react"; export function PropertiesPanel() { const { selectedElements, tracks } = useTimelineStore(); - const { mediaItems } = useMediaStore(); + const { mediaFiles } = useMediaStore(); return ( <> @@ -28,11 +28,11 @@ export function PropertiesPanel() { ); } if (element?.type === "media") { - const mediaItem = mediaItems.find( - (item) => item.id === element.mediaId + const mediaFile = mediaFiles.find( + (file) => file.id === element.mediaId ); - if (mediaItem?.type === "audio") { + if (mediaFile?.type === "audio") { return <AudioProperties key={elementId} element={element} />; } diff --git a/apps/web/src/components/editor/properties-panel/property-item.tsx b/apps/web/src/components/editor/properties-panel/property-item.tsx index dc6b6d74..f7d5dbda 100644 --- a/apps/web/src/components/editor/properties-panel/property-item.tsx +++ b/apps/web/src/components/editor/properties-panel/property-item.tsx @@ -57,6 +57,7 @@ interface PropertyGroupProps { children: React.ReactNode; defaultExpanded?: boolean; className?: string; + titleClassName?: string; } export function PropertyGroup({ @@ -64,6 +65,7 @@ export function PropertyGroup({ children, defaultExpanded = true, className, + titleClassName, }: PropertyGroupProps) { const [isExpanded, setIsExpanded] = useState(defaultExpanded); @@ -73,7 +75,7 @@ export function PropertyGroup({ className="flex items-center gap-1.5 cursor-pointer" onClick={() => setIsExpanded(!isExpanded)} > - <PropertyItemLabel className="cursor-pointer"> + <PropertyItemLabel className={cn("cursor-pointer", titleClassName)}> {title} </PropertyItemLabel> <ChevronDown className={cn("size-3", !isExpanded && "-rotate-90")} /> diff --git a/apps/web/src/components/editor/properties-panel/text-properties.tsx b/apps/web/src/components/editor/properties-panel/text-properties.tsx index 744c2be5..deaee157 100644 --- a/apps/web/src/components/editor/properties-panel/text-properties.tsx +++ b/apps/web/src/components/editor/properties-panel/text-properties.tsx @@ -6,13 +6,26 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { Slider } from "@/components/ui/slider"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; // Add Switch import import { useState, useRef } from "react"; +import { PanelBaseView } from "@/components/editor/panel-base-view"; +import { + TEXT_PROPERTIES_TABS, + isTextPropertiesTab, + useTextPropertiesStore, +} from "@/stores/text-properties-store"; import { PropertyItem, PropertyItemLabel, PropertyItemValue, } from "./property-item"; +import { ColorPicker } from "@/components/ui/color-picker"; +import { cn, uppercase } from "@/lib/utils"; +import { Grid2x2 } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; export function TextProperties({ element, @@ -22,7 +35,8 @@ export function TextProperties({ trackId: string; }) { const { updateTextElement } = useTimelineStore(); - + const { activeTab, setActiveTab } = useTextPropertiesStore(); + const containerRef = useRef<HTMLDivElement>(null); // Local state for input values to allow temporary empty/invalid states const [fontSizeInput, setFontSizeInput] = useState( element.fontSize.toString() @@ -105,199 +119,252 @@ export function TextProperties({ }; return ( - <div className="space-y-6 p-5"> - <Textarea - placeholder="Name" - defaultValue={element.content} - className="min-h-18 resize-none bg-background/50" - onChange={(e) => - updateTextElement(trackId, element.id, { content: e.target.value }) - } - /> - <PropertyItem direction="row"> - <PropertyItemLabel>Font</PropertyItemLabel> - <PropertyItemValue> - <FontPicker - defaultValue={element.fontFamily} - onValueChange={(value: FontFamily) => - updateTextElement(trackId, element.id, { fontFamily: value }) - } - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItem direction="row"> - <PropertyItemLabel>Style</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Button - variant={element.fontWeight === "bold" ? "default" : "outline"} - size="sm" - onClick={() => + <PanelBaseView + defaultTab="transform" + value={activeTab} + onValueChange={(v) => { + if (isTextPropertiesTab(v)) setActiveTab(v); + }} + ref={containerRef} + tabs={TEXT_PROPERTIES_TABS.map((t) => ({ + value: t.value, + label: t.label, + content: + t.value === "transform" ? ( + <div className="space-y-6"></div> + ) : ( + <div className="space-y-6"> + <Textarea + placeholder="Name" + defaultValue={element.content} + className="min-h-18 resize-none bg-panel-accent" + onChange={(e) => updateTextElement(trackId, element.id, { - fontWeight: - element.fontWeight === "bold" ? "normal" : "bold", + content: e.target.value, }) } - className="h-8 px-3 font-bold" - > - B - </Button> - <Button - variant={element.fontStyle === "italic" ? "default" : "outline"} - size="sm" - onClick={() => - updateTextElement(trackId, element.id, { - fontStyle: - element.fontStyle === "italic" ? "normal" : "italic", - }) - } - className="h-8 px-3 italic" - > - I - </Button> - <Button - variant={ - element.textDecoration === "underline" ? "default" : "outline" - } - size="sm" - onClick={() => - updateTextElement(trackId, element.id, { - textDecoration: - element.textDecoration === "underline" - ? "none" - : "underline", - }) - } - className="h-8 px-3 underline" - > - U - </Button> - <Button - variant={ - element.textDecoration === "line-through" - ? "default" - : "outline" - } - size="sm" - onClick={() => - updateTextElement(trackId, element.id, { - textDecoration: - element.textDecoration === "line-through" - ? "none" - : "line-through", - }) - } - className="h-8 px-3 line-through" - > - S - </Button> - </div> - </PropertyItemValue> - </PropertyItem> - <PropertyItemLabel>Font size</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Slider - value={[element.fontSize]} - min={8} - max={300} - step={1} - onValueChange={([value]) => { - updateTextElement(trackId, element.id, { fontSize: value }); - setFontSizeInput(value.toString()); - }} - className="w-full" - /> - <Input - type="number" - value={fontSizeInput} - min={8} - max={300} - onChange={(e) => handleFontSizeChange(e.target.value)} - onBlur={handleFontSizeBlur} - className="w-12 !text-xs h-7 rounded-sm text-center + /> + <PropertyItem direction="column"> + <PropertyItemLabel>Font</PropertyItemLabel> + <PropertyItemValue> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value: FontFamily) => + updateTextElement(trackId, element.id, { + fontFamily: value, + }) + } + /> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Style</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Button + variant={ + element.fontWeight === "bold" ? "default" : "outline" + } + size="sm" + onClick={() => + updateTextElement(trackId, element.id, { + fontWeight: + element.fontWeight === "bold" ? "normal" : "bold", + }) + } + className="h-8 px-3 font-bold" + > + B + </Button> + <Button + variant={ + element.fontStyle === "italic" ? "default" : "outline" + } + size="sm" + onClick={() => + updateTextElement(trackId, element.id, { + fontStyle: + element.fontStyle === "italic" + ? "normal" + : "italic", + }) + } + className="h-8 px-3 italic" + > + I + </Button> + <Button + variant={ + element.textDecoration === "underline" + ? "default" + : "outline" + } + size="sm" + onClick={() => + updateTextElement(trackId, element.id, { + textDecoration: + element.textDecoration === "underline" + ? "none" + : "underline", + }) + } + className="h-8 px-3 underline" + > + U + </Button> + <Button + variant={ + element.textDecoration === "line-through" + ? "default" + : "outline" + } + size="sm" + onClick={() => + updateTextElement(trackId, element.id, { + textDecoration: + element.textDecoration === "line-through" + ? "none" + : "line-through", + }) + } + className="h-8 px-3 line-through" + > + S + </Button> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Font size</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Slider + value={[element.fontSize]} + min={8} + max={300} + step={1} + onValueChange={([value]) => { + updateTextElement(trackId, element.id, { + fontSize: value, + }); + setFontSizeInput(value.toString()); + }} + className="w-full" + /> + <Input + type="number" + value={fontSizeInput} + min={8} + max={300} + onChange={(e) => handleFontSizeChange(e.target.value)} + onBlur={handleFontSizeBlur} + className="w-12 px-2 !text-xs h-7 rounded-sm text-center bg-panel-accent [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> - </div> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="row"> - <PropertyItemLabel>Color</PropertyItemLabel> - <PropertyItemValue> - <Input - type="color" - value={element.color || "#ffffff"} - onChange={(e) => { - const color = e.target.value; - updateTextElement(trackId, element.id, { color }); - }} - className="w-full cursor-pointer rounded-full" - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <div className="flex items-center justify-between"> - <PropertyItemLabel>Background</PropertyItemLabel> - <div className="flex items-center space-x-2"> - <Switch - id="transparent-bg-toggle" - checked={element.backgroundColor === "transparent"} - onCheckedChange={handleTransparentToggle} - /> - <label htmlFor="transparent-bg-toggle" className="text-sm font-medium"> - Transparent - </label> - </div> - </div> - <PropertyItemValue> - <Input - type="color" - value={ - element.backgroundColor === "transparent" - ? lastSelectedColor.current - : element.backgroundColor || "#000000" - } - onChange={(e) => handleColorChange(e.target.value)} - className="w-full cursor-pointer rounded-full" - disabled={element.backgroundColor === "transparent"} - /> - </PropertyItemValue> - </PropertyItem> - <PropertyItem direction="column"> - <PropertyItemLabel>Opacity</PropertyItemLabel> - <PropertyItemValue> - <div className="flex items-center gap-2"> - <Slider - value={[element.opacity * 100]} - min={0} - max={100} - step={1} - onValueChange={([value]) => { - updateTextElement(trackId, element.id, { - opacity: value / 100, - }); - setOpacityInput(value.toString()); - }} - className="w-full" - /> - <Input - type="number" - value={opacityInput} - min={0} - max={100} - onChange={(e) => handleOpacityChange(e.target.value)} - onBlur={handleOpacityBlur} - className="w-12 !text-xs h-7 rounded-sm text-center + /> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Color</PropertyItemLabel> + <PropertyItemValue> + <ColorPicker + value={uppercase( + (element.color || "FFFFFF").replace("#", "") + )} + onChange={(color) => { + updateTextElement(trackId, element.id, { + color: `#${color}`, + }); + }} + containerRef={containerRef} + /> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Opacity</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <Slider + value={[element.opacity * 100]} + min={0} + max={100} + step={1} + onValueChange={([value]) => { + updateTextElement(trackId, element.id, { + opacity: value / 100, + }); + setOpacityInput(value.toString()); + }} + className="w-full" + /> + <Input + type="number" + value={opacityInput} + min={0} + max={100} + onChange={(e) => handleOpacityChange(e.target.value)} + onBlur={handleOpacityBlur} + className="w-12 !text-xs h-7 rounded-sm text-center bg-panel-accent [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> - </div> - </PropertyItemValue> - </PropertyItem> - </div> + /> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Background</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <ColorPicker + value={uppercase( + element.backgroundColor === "transparent" + ? lastSelectedColor.current.replace("#", "") + : (element.backgroundColor || "#000000").replace( + "#", + "" + ) + )} + onChange={(color) => handleColorChange(`#${color}`)} + containerRef={containerRef} + className={ + element.backgroundColor === "transparent" + ? "opacity-50 pointer-events-none" + : "" + } + /> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="icon" + onClick={() => + handleTransparentToggle( + element.backgroundColor !== "transparent" + ) + } + className="size-9 rounded-full bg-panel-accent p-0 overflow-hidden" + > + <Grid2x2 + className={cn( + "text-foreground", + element.backgroundColor === "transparent" && + "text-primary" + )} + /> + </Button> + </TooltipTrigger> + <TooltipContent>Transparent background</TooltipContent> + </Tooltip> + </div> + </PropertyItemValue> + </PropertyItem> + </div> + ), + }))} + /> ); } diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 284384eb..49769989 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -82,13 +82,11 @@ export function Timeline() { toggleTrackMute, dragState, } = useTimelineStore(); - const { mediaItems, addMediaItem } = useMediaStore(); + const { mediaFiles, addMediaFile } = useMediaStore(); const { activeProject } = useProjectStore(); - const { currentTime, duration, seek, setDuration, isPlaying, toggle } = - usePlaybackStore(); + const { currentTime, duration, seek, setDuration } = usePlaybackStore(); const [isDragOver, setIsDragOver] = useState(false); - const [isProcessing, setIsProcessing] = useState(false); - const [progress, setProgress] = useState(0); + const { addElementToNewTrack } = useTimelineStore(); const dragCounterRef = useRef(0); const timelineRef = useRef<HTMLDivElement>(null); const rulerRef = useRef<HTMLDivElement>(null); @@ -456,10 +454,10 @@ export function Timeline() { if (dragData.type === "text") { // Always create new text track to avoid overlaps - useTimelineStore.getState().addTextToNewTrack(dragData); + addElementToNewTrack(dragData); } else { // Handle media items - const mediaItem = mediaItems.find( + const mediaItem = mediaFiles.find( (item: any) => item.id === dragData.id ); if (!mediaItem) { @@ -467,7 +465,7 @@ export function Timeline() { return; } - useTimelineStore.getState().addMediaToNewTrack(mediaItem); + addElementToNewTrack(mediaItem); } } catch (error) { console.error("Error parsing dropped item data:", error); @@ -480,17 +478,12 @@ export function Timeline() { return; } - setIsProcessing(true); - setProgress(0); try { - const processedItems = await processMediaFiles( - e.dataTransfer.files, - (p) => setProgress(p) - ); + const processedItems = await processMediaFiles(e.dataTransfer.files); for (const processedItem of processedItems) { - await addMediaItem(activeProject.id, processedItem); - const currentMediaItems = useMediaStore.getState().mediaItems; - const addedItem = currentMediaItems.find( + await addMediaFile(activeProject.id, processedItem); + const currentMediaFiles = mediaFiles; + const addedItem = currentMediaFiles.find( (item) => item.name === processedItem.name && item.url === processedItem.url ); @@ -516,9 +509,6 @@ export function Timeline() { // Show error if file processing fails console.error("Error processing external files:", error); toast.error("Failed to process dropped files"); - } finally { - setIsProcessing(false); - setProgress(0); } } }; diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index a1a33837..677b9c61 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -40,10 +40,8 @@ export function TimelineElement({ onElementMouseDown, onElementClick, }: TimelineElementProps) { - const { mediaItems } = useMediaStore(); + const { mediaFiles } = useMediaStore(); const { - updateElementTrim, - updateElementDuration, removeElementFromTrack, removeElementFromTrackWithRipple, dragState, @@ -53,12 +51,13 @@ export function TimelineElement({ rippleEditingEnabled, toggleElementHidden, toggleElementMuted, + copySelected, } = useTimelineStore(); const { currentTime } = usePlaybackStore(); const mediaItem = element.type === "media" - ? mediaItems.find((item) => item.id === element.mediaId) + ? mediaFiles.find((file) => file.id === element.mediaId) : null; const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video"; @@ -67,8 +66,6 @@ export function TimelineElement({ element, track, zoomLevel, - onUpdateTrim: updateElementTrim, - onUpdateDuration: updateElementDuration, }); const { requestRevealMedia } = useMediaPanelStore.getState(); @@ -120,6 +117,11 @@ export function TimelineElement({ }); }; + const handleElementCopyContext = (e: React.MouseEvent) => { + e.stopPropagation(); + copySelected(); + }; + const handleElementDeleteContext = (e: React.MouseEvent) => { e.stopPropagation(); if (rippleEditingEnabled) { @@ -190,7 +192,7 @@ export function TimelineElement({ } // Render media element -> - const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + const mediaItem = mediaFiles.find((file) => file.id === element.mediaId); if (!mediaItem) { return ( <span className="text-xs text-foreground/80 truncate"> @@ -334,6 +336,10 @@ export function TimelineElement({ <Scissors className="h-4 w-4 mr-2" /> Split at playhead </ContextMenuItem> + <ContextMenuItem onClick={handleElementCopyContext}> + <Copy className="h-4 w-4 mr-2" /> + Copy element + </ContextMenuItem> <ContextMenuItem onClick={handleToggleElementContext}> {hasAudio ? ( isMuted ? ( diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 5a037710..ad3ca9b7 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -12,6 +12,7 @@ import { canElementGoOnTrack, } from "@/types/timeline"; import { usePlaybackStore } from "@/stores/playback-store"; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; import type { TimelineElement as TimelineElementType, DragData, @@ -33,7 +34,7 @@ export function TimelineTrackContent({ zoomLevel: number; onSnapPointChange?: (snapPoint: SnapPoint | null) => void; }) { - const { mediaItems } = useMediaStore(); + const { mediaFiles } = useMediaStore(); const { tracks, addTrack, @@ -537,7 +538,7 @@ export function TimelineTrackContent({ }); } else { // Media elements - const mediaItem = mediaItems.find( + const mediaItem = mediaFiles.find( (item) => item.id === dragData.id ); if (mediaItem) { @@ -890,29 +891,14 @@ export function TimelineTrackContent({ } addElementToTrack(targetTrackId, { - type: "text", - name: dragData.name || "Text", - content: dragData.content || "Default Text", - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + ...DEFAULT_TEXT_ELEMENT, + name: dragData.name || DEFAULT_TEXT_ELEMENT.name, + content: dragData.content || DEFAULT_TEXT_ELEMENT.content, startTime: textSnappedTime, - 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, }); } else { // Handle media items - const mediaItem = mediaItems.find((item) => item.id === dragData.id); + const mediaItem = mediaFiles.find((item) => item.id === dragData.id); if (!mediaItem) { toast.error("Media item not found"); @@ -1054,7 +1040,7 @@ export function TimelineTrackContent({ } else if (hasFiles) { // External file drops const { activeProject } = useProjectStore.getState(); - const { addMediaItem } = useMediaStore.getState(); + const { addMediaFile } = useMediaStore.getState(); const { addElementToTrack } = useTimelineStore.getState(); if (!activeProject) { @@ -1066,9 +1052,9 @@ export function TimelineTrackContent({ processMediaFiles(e.dataTransfer.files) .then(async (processedItems) => { for (const processedItem of processedItems) { - await addMediaItem(activeProject.id, processedItem); - const currentMediaItems = useMediaStore.getState().mediaItems; - const addedItem = currentMediaItems.find( + await addMediaFile(activeProject.id, processedItem); + const currentMediaFiles = mediaFiles; + const addedItem = currentMediaFiles.find( (item) => item.name === processedItem.name && item.url === processedItem.url diff --git a/apps/web/src/components/export-button.tsx b/apps/web/src/components/export-button.tsx deleted file mode 100644 index c325db5c..00000000 --- a/apps/web/src/components/export-button.tsx +++ /dev/null @@ -1,185 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { TransitionUpIcon } from "./icons"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "./ui/dialog"; -import { Button } from "./ui/button"; -import { Input } from "./ui/input"; -import { useForm } from "react-hook-form"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "./ui/form"; -import { - exportWaitlistSchema, - type ExportWaitlistInput, - type ExportWaitlistResponse, -} from "@/lib/schemas/waitlist"; -import { cn } from "@/lib/utils"; - -export function ExportButton() { - const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); - - const handleExport = () => { - setIsExportDialogOpen(true); - }; - - return ( - <> - <button - type="button" - className="flex items-center gap-1.5 bg-[#38BDF8] text-white rounded-md px-[0.12rem] py-[0.12rem] cursor-pointer hover:brightness-95 transition-all duration-200" - onClick={handleExport} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - handleExport(); - } - }} - > - <div className="flex items-center gap-1.5 bg-linear-270 from-[#2567EC] to-[#37B6F7] rounded-[0.8rem] px-4 py-1 relative shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]"> - <TransitionUpIcon className="z-50" /> - <span className="text-[0.875rem] z-50">Export (soon)</span> - <div className="absolute w-full h-full left-0 top-0 bg-linear-to-t from-white/0 to-white/50 z-10 rounded-[0.8rem] flex items-center justify-center"> - <div className="absolute w-[calc(100%-2px)] h-[calc(100%-2px)] top-[0.08rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] z-50 rounded-[0.8rem]"></div> - </div> - </div> - </button> - <ExportDialog - isOpen={isExportDialogOpen} - onOpenChange={setIsExportDialogOpen} - /> - </> - ); -} - -function ExportDialog({ - isOpen, - onOpenChange, -}: { - isOpen: boolean; - onOpenChange: (open: boolean) => void; -}) { - const form = useForm<ExportWaitlistInput>({ defaultValues: { email: "" } }); - const { isSubmitting } = form.formState; - const [serverMessage, setServerMessage] = useState<{ - text: string; - type: "success" | "error"; - } | null>(null); - - const onSubmit = form.handleSubmit(async (values) => { - const parsed = exportWaitlistSchema.safeParse(values); - if (!parsed.success) { - for (const issue of parsed.error.issues) { - const fieldName = - (issue.path[0] as keyof ExportWaitlistInput) || "email"; - form.setError(fieldName, { type: "zod", message: issue.message }); - } - return; - } - setServerMessage(null); - const { email } = parsed.data; - const response = await fetch("/api/waitlist/export", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), - }); - if (!response.ok) { - setServerMessage({ - text: "Something went wrong. Please try again.", - type: "error", - }); - return; - } - const data: ExportWaitlistResponse = await response.json(); - if (data.success && data.alreadySubscribed) { - setServerMessage({ - text: "You're already on the list.", - type: "success", - }); - return; - } - if (data.success) { - setServerMessage({ - text: "You're on the list. We'll email you when it's ready.", - type: "success", - }); - form.reset(); - return; - } - setServerMessage({ - text: "Couldn't add your email. Please try again.", - type: "error", - }); - }); - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent className="p-6"> - <DialogHeader> - <DialogTitle>Export Project</DialogTitle> - </DialogHeader> - <div className="space-y-4"> - <DialogDescription> - Export isn't ready yet. we're building a custom pipeline to make it - great. - </DialogDescription> - <Form {...form}> - <form className="flex flex-col gap-5" onSubmit={onSubmit}> - <div className="flex flex-col gap-4 w-full"> - <FormField - control={form.control} - name="email" - render={({ field }) => ( - <FormItem> - <FormLabel>Email</FormLabel> - <FormControl> - <Input - type="email" - placeholder="you@example.com" - inputMode="email" - autoComplete="email" - disabled={isSubmitting} - {...field} - /> - </FormControl> - <FormDescription> - We'll let you know once export is ready. - </FormDescription> - <FormMessage /> - </FormItem> - )} - /> - <Button type="submit" disabled={isSubmitting}> - {isSubmitting ? "Sending..." : "Notify me"} - </Button> - </div> - {serverMessage ? ( - <p - role="status" - aria-live="polite" - className={cn( - "text-xs", - serverMessage.type === "success" ? "text-green-600" : "text-red-600" - )} - > - {serverMessage.text} - </p> - ) : null} - </form> - </Form> - </div> - </DialogContent> - </Dialog> - ); -} diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 4d68efb2..2bfc82b7 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -46,7 +46,7 @@ export function Header() { return ( <div className="sticky top-4 z-50 mx-4 md:mx-0"> <HeaderBase - className="bg-background border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]" + className="bg-background border rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[11px]" leftContent={leftContent} rightContent={rightContent} /> diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index 5c629433..1b557412 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -12,7 +12,7 @@ import Link from "next/link"; export function Hero() { return ( - <div className="min-h-[calc(100vh-4.5rem)] supports-[height:100dvh]:min-h-[calc(100dvh-4.5rem)] flex flex-col justify-between items-center text-center px-4"> + <div className="min-h-[calc(100svh-4.5rem)] flex flex-col justify-between items-center text-center px-4"> <Image className="absolute top-0 left-0 -z-50 size-full object-cover invert dark:invert-0 opacity-85" src="/landing-page-dark.png" diff --git a/apps/web/src/components/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx index b8e9ae1d..b8e9ae1d 100644 --- a/apps/web/src/components/editor-provider.tsx +++ b/apps/web/src/components/providers/editor-provider.tsx diff --git a/apps/web/src/components/rename-project-dialog.tsx b/apps/web/src/components/rename-project-dialog.tsx index c50f9f71..dd8dfcc4 100644 --- a/apps/web/src/components/rename-project-dialog.tsx +++ b/apps/web/src/components/rename-project-dialog.tsx @@ -51,7 +51,7 @@ export function RenameProjectDialog({ } }} placeholder="Enter a new name" - className="mt-4" + className="mt-4 bg-background border-2 border-border" /> <DialogFooter> diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx new file mode 100644 index 00000000..b94c1f98 --- /dev/null +++ b/apps/web/src/components/ui/color-picker.tsx @@ -0,0 +1,336 @@ +import * as React from "react"; +import { createPortal } from "react-dom"; +import { cn } from "../../lib/utils"; +import { Input } from "./input"; + +interface ColorPickerProps { + value?: string; + onChange?: (value: string) => void; + className?: string; + containerRef?: React.RefObject<HTMLDivElement>; +} + +const hexToHsv = (hex: string) => { + const r = parseInt(hex.slice(0, 2), 16) / 255; + const g = parseInt(hex.slice(2, 4), 16) / 255; + const b = parseInt(hex.slice(4, 6), 16) / 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const diff = max - min; + + let h = 0; + let s = max === 0 ? 0 : diff / max; + let v = max; + + if (diff !== 0) { + switch (max) { + case r: + h = ((g - b) / diff) % 6; + break; + case g: + h = (b - r) / diff + 2; + break; + case b: + h = (r - g) / diff + 4; + break; + } + } + + h = (h * 60 + 360) % 360; + if (isNaN(h)) h = 0; + + return [h, s, v]; +}; + +const hsvToHex = (h: number, s: number, v: number) => { + const c = v * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = v - c; + + let r = 0, + g = 0, + b = 0; + + if (h >= 0 && h < 60) { + r = c; + g = x; + b = 0; + } else if (h >= 60 && h < 120) { + r = x; + g = c; + b = 0; + } else if (h >= 120 && h < 180) { + r = 0; + g = c; + b = x; + } else if (h >= 180 && h < 240) { + r = 0; + g = x; + b = c; + } else if (h >= 240 && h < 300) { + r = x; + g = 0; + b = c; + } else if (h >= 300 && h < 360) { + r = c; + g = 0; + b = x; + } + + r = Math.round((r + m) * 255); + g = Math.round((g + m) * 255); + b = Math.round((b + m) * 255); + + return [r, g, b].map((x) => x.toString(16).padStart(2, "0")).join(""); +}; + +const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>( + ({ className, value = "FFFFFF", onChange, containerRef, ...props }, ref) => { + const [isOpen, setIsOpen] = React.useState(false); + const [isDragging, setIsDragging] = React.useState< + "saturation" | "hue" | null + >(null); + const [pickerPosition, setPickerPosition] = React.useState({ + right: 0, + bottom: 0, + }); + const [internalHue, setInternalHue] = React.useState(0); + const [inputValue, setInputValue] = React.useState(value); + + const pickerRef = React.useRef<HTMLDivElement>(null); + const saturationRef = React.useRef<HTMLDivElement>(null); + const hueRef = React.useRef<HTMLDivElement>(null); + const triggerRef = React.useRef<HTMLDivElement>(null); + + const [h, s, v] = hexToHsv(value); + const displayHue = s > 0 ? h : internalHue; + + React.useEffect(() => { + setInputValue(value); + }, [value]); + + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + pickerRef.current && + !pickerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); + } + }, [isOpen]); + + React.useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging) return; + + if (isDragging === "saturation" && saturationRef.current) { + const rect = saturationRef.current.getBoundingClientRect(); + const x = Math.max( + 0, + Math.min(1, (e.clientX - rect.left) / rect.width) + ); + const y = Math.max( + 0, + Math.min(1, (e.clientY - rect.top) / rect.height) + ); + const newS = x; + const newV = 1 - y; + const newHex = hsvToHex(displayHue, newS, newV); + onChange?.(newHex); + } + + if (isDragging === "hue" && hueRef.current) { + const rect = hueRef.current.getBoundingClientRect(); + const x = Math.max( + 0, + Math.min(1, (e.clientX - rect.left) / rect.width) + ); + const newH = x * 360; + setInternalHue(newH); + if (s > 0) { + const newHex = hsvToHex(newH, s, v); + onChange?.(newHex); + } + } + }; + + const handleMouseUp = () => { + setIsDragging(null); + }; + + if (isDragging) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + } + }, [isDragging, displayHue, s, v, onChange]); + + const handleSaturationMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging("saturation"); + const rect = saturationRef.current!.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); + const newS = x; + const newV = 1 - y; + const newHex = hsvToHex(displayHue, newS, newV); + onChange?.(newHex); + }; + + const handleHueMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging("hue"); + const rect = hueRef.current!.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const newH = x * 360; + setInternalHue(newH); + if (s > 0) { + const newHex = hsvToHex(newH, s, v); + onChange?.(newHex); + } + }; + + const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const hex = e.target.value.replace("#", ""); + setInputValue(hex); + }; + + const handleInputBlur = () => { + onChange?.(inputValue); + }; + + const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === "Enter") { + onChange?.(inputValue); + e.currentTarget.blur(); + } + }; + + const saturationStyle = { + background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`, + }; + + const hueStyle = { + background: + "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)", + }; + + return ( + <div className="relative"> + <div + ref={ref} + className={cn( + "bg-panel-accent h-9 rounded-full flex items-center gap-2 px-[0.45rem]", + className + )} + {...props} + > + <div + ref={triggerRef} + className="size-6 rounded-full cursor-pointer hover:ring-2 hover:ring-white/20 transition-all" + style={{ backgroundColor: `#${value}` }} + onClick={() => { + if (!isOpen && triggerRef.current && containerRef?.current) { + const containerRect = + containerRef.current.getBoundingClientRect(); + setPickerPosition({ + right: window.innerWidth - containerRect.left - 8, + bottom: window.innerHeight - containerRect.bottom, + }); + } + setIsOpen(!isOpen); + }} + /> + <div className="flex-1 flex items-center"> + <Input + className="bg-transparent p-0 !ring-0 !ring-offset-0 !border-0" + containerClassName="w-full" + value={inputValue} + onChange={handleInputChange} + onBlur={handleInputBlur} + onKeyDown={handleInputKeyDown} + /> + </div> + </div> + + {isOpen && + createPortal( + <div + ref={pickerRef} + className="fixed z-50 p-4 bg-popover border border-border rounded-lg shadow-lg select-none" + style={{ + right: pickerPosition.right, + bottom: pickerPosition.bottom, + }} + > + <div + ref={saturationRef} + className="relative w-48 h-32 cursor-crosshair mb-3" + style={saturationStyle} + onMouseDown={handleSaturationMouseDown} + > + <ColorCircle + size="sm" + position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }} + color={`#${value}`} + /> + </div> + + <div + ref={hueRef} + className="relative w-48 h-4 rounded-lg cursor-pointer" + style={hueStyle} + onMouseDown={handleHueMouseDown} + > + <ColorCircle + size="md" + position={{ + left: `${(displayHue / 360) * 100}%`, + top: "50%", + }} + color={`#${value}`} + /> + </div> + </div>, + document.body + )} + </div> + ); + } +); +ColorPicker.displayName = "ColorPicker"; + +const ColorCircle = ({ + size, + position, + color, +}: { + size: "sm" | "md"; + position: { left: string; top: string }; + color: string; +}) => ( + <div + className={`absolute border-3 border-white rounded-full shadow-lg pointer-events-none ${ + size === "sm" ? "w-3 h-3" : "w-4 h-4" + }`} + style={{ + left: position.left, + top: position.top, + transform: "translate(-50%, -50%)", + backgroundColor: color, + }} + /> +); + +export { ColorPicker }; diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index 9e29c83b..c238b33c 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -12,11 +12,12 @@ import { createPortal } from "react-dom"; import { Plus } from "lucide-react"; import { cn } from "@/lib/utils"; import { usePlaybackStore } from "@/stores/playback-store"; +import { DragData } from "@/types/timeline"; export interface DraggableMediaItemProps { name: string; preview: ReactNode; - dragData: Record<string, any>; + dragData: DragData; onDragStart?: (e: React.DragEvent) => void; onAddToTimeline?: (currentTime: number) => void; aspectRatio?: number; diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx index 479a5f0c..7bb11274 100644 --- a/apps/web/src/components/ui/font-picker.tsx +++ b/apps/web/src/components/ui/font-picker.tsx @@ -20,7 +20,7 @@ export function FontPicker({ }: FontPickerProps) { return ( <Select defaultValue={defaultValue} onValueChange={onValueChange}> - <SelectTrigger className={`w-full text-xs ${className || ""}`}> + <SelectTrigger className={`w-full bg-panel-accent h-8 text-xs ${className || ""}`}> <SelectValue placeholder="Select a font" /> </SelectTrigger> <SelectContent> diff --git a/apps/web/src/components/ui/progress.tsx b/apps/web/src/components/ui/progress.tsx index 283ba17a..6b785a58 100644 --- a/apps/web/src/components/ui/progress.tsx +++ b/apps/web/src/components/ui/progress.tsx @@ -12,13 +12,13 @@ const Progress = React.forwardRef< <ProgressPrimitive.Root ref={ref} className={cn( - "relative h-2 w-full overflow-hidden rounded-full bg-primary/20", + "relative h-2 w-full overflow-hidden rounded-full bg-accent", className )} {...props} > <ProgressPrimitive.Indicator - className="h-full w-full flex-1 bg-primary transition-all" + className="h-full w-full flex-1 bg-foreground transition-all" style={{ transform: `translateX(-${100 - (value || 0)}%)` }} /> </ProgressPrimitive.Root> diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx index 549cf841..13694b43 100644 --- a/apps/web/src/components/ui/sonner.tsx +++ b/apps/web/src/components/ui/sonner.tsx @@ -12,6 +12,8 @@ const Toaster = ({ ...props }: ToasterProps) => { <Sonner theme={theme as ToasterProps["theme"]} className="toaster group" + position="top-center" + offset={20} toastOptions={{ classNames: { toast: @@ -23,6 +25,8 @@ const Toaster = ({ ...props }: ToasterProps) => { "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", }, }} + expand={false} + richColors {...props} /> ); diff --git a/apps/web/src/components/ui/textarea.tsx b/apps/web/src/components/ui/textarea.tsx index b9004a34..d9ae9ea6 100644 --- a/apps/web/src/components/ui/textarea.tsx +++ b/apps/web/src/components/ui/textarea.tsx @@ -9,7 +9,9 @@ const Textarea = React.forwardRef< return ( <textarea className={cn( - "flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-base shadow-xs placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-xs", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex min-h-[60px] w-full rounded-md border bg-accent/50 px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]", + "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className )} ref={ref} |
