diff options
| author | Maze <167211895+mazeincoding@users.noreply.github.com> | 2026-01-31 00:20:04 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-31 00:20:04 +0100 |
| commit | 7bf0984698e43b9aa2dd879f65951318ed410390 (patch) | |
| tree | ccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/components/editor/media-panel | |
| parent | 0173db99448d1c4c6415dcf15272e17551b48b04 (diff) | |
| download | opencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz | |
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/components/editor/media-panel')
10 files changed, 0 insertions, 2684 deletions
diff --git a/apps/web/src/components/editor/media-panel/drag-overlay.tsx b/apps/web/src/components/editor/media-panel/drag-overlay.tsx deleted file mode 100644 index e1fd257e..00000000 --- a/apps/web/src/components/editor/media-panel/drag-overlay.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Upload, Plus, Image } from "lucide-react"; -import { Button } from "@/components/ui/button"; - -interface MediaDragOverlayProps { - isVisible: boolean; - isProcessing?: boolean; - progress?: number; - onClick?: () => void; - isEmptyState?: boolean; -} - -export function MediaDragOverlay({ - isVisible, - isProcessing = false, - progress = 0, - onClick, - isEmptyState = false, -}: MediaDragOverlayProps) { - if (!isVisible) return null; - - const handleClick = (e: React.MouseEvent) => { - if (isProcessing || !onClick) return; - e.preventDefault(); - e.stopPropagation(); - onClick(); - }; - - return ( - <div - className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8" - onClick={handleClick} - > - <div className="flex items-center justify-center"> - <Upload className="h-10 w-10 text-foreground" /> - </div> - - <div className="space-y-2"> - <p className="text-xs text-muted-foreground max-w-sm"> - {isProcessing - ? `Processing your files (${progress}%)` - : "Drag and drop videos, photos, and audio files here"} - </p> - </div> - - {isProcessing && ( - <div className="w-full max-w-xs"> - <div className="w-full bg-muted/50 rounded-full h-2"> - <div - className="bg-primary h-2 rounded-full transition-all duration-300" - style={{ width: `${progress}%` }} - /> - </div> - </div> - )} - </div> - ); -} diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx deleted file mode 100644 index 89b53b64..00000000 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ /dev/null @@ -1,52 +0,0 @@ -"use client"; - -import { TabBar } from "./tabbar"; -import { MediaView } from "./views/media"; -import { useMediaPanelStore, Tab } from "./store"; -import { TextView } from "./views/text"; -import { SoundsView } from "./views/sounds"; -import { StickersView } from "./views/stickers"; -import { Separator } from "@/components/ui/separator"; -import { SettingsView } from "./views/settings"; -import { Captions } from "./views/captions"; - -export function MediaPanel() { - const { activeTab } = useMediaPanelStore(); - - const viewMap: Record<Tab, React.ReactNode> = { - media: <MediaView />, - sounds: <SoundsView />, - text: <TextView />, - stickers: <StickersView />, - effects: ( - <div className="p-4 text-muted-foreground"> - Effects view coming soon... - </div> - ), - transitions: ( - <div className="p-4 text-muted-foreground"> - Transitions view coming soon... - </div> - ), - captions: <Captions />, - filters: ( - <div className="p-4 text-muted-foreground"> - Filters view coming soon... - </div> - ), - adjustment: ( - <div className="p-4 text-muted-foreground"> - Adjustment view coming soon... - </div> - ), - settings: <SettingsView />, - }; - - return ( - <div className="h-full flex bg-panel"> - <TabBar /> - <Separator orientation="vertical" /> - <div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div> - </div> - ); -} diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts deleted file mode 100644 index eb645ec3..00000000 --- a/apps/web/src/components/editor/media-panel/store.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { - CaptionsIcon, - ArrowLeftRightIcon, - SparklesIcon, - StickerIcon, - MusicIcon, - VideoIcon, - BlendIcon, - SlidersHorizontalIcon, - LucideIcon, - TypeIcon, - SettingsIcon, -} from "lucide-react"; -import { create } from "zustand"; - -export type Tab = - | "media" - | "sounds" - | "text" - | "stickers" - | "effects" - | "transitions" - | "captions" - | "filters" - | "adjustment" - | "settings"; - -export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = { - media: { - icon: VideoIcon, - label: "Media", - }, - sounds: { - icon: MusicIcon, - label: "Sounds", - }, - text: { - icon: TypeIcon, - label: "Text", - }, - stickers: { - icon: StickerIcon, - label: "Stickers", - }, - effects: { - icon: SparklesIcon, - label: "Effects", - }, - transitions: { - icon: ArrowLeftRightIcon, - label: "Transitions", - }, - captions: { - icon: CaptionsIcon, - label: "Captions", - }, - filters: { - icon: BlendIcon, - label: "Filters", - }, - adjustment: { - icon: SlidersHorizontalIcon, - label: "Adjustment", - }, - settings: { - icon: SettingsIcon, - label: "Settings", - }, -}; - -interface MediaPanelStore { - activeTab: Tab; - setActiveTab: (tab: Tab) => void; - highlightMediaId: string | null; - requestRevealMedia: (mediaId: string) => void; - clearHighlight: () => void; -} - -export const useMediaPanelStore = create<MediaPanelStore>((set) => ({ - activeTab: "media", - setActiveTab: (tab) => set({ activeTab: tab }), - highlightMediaId: null, - requestRevealMedia: (mediaId) => - set({ activeTab: "media", highlightMediaId: mediaId }), - clearHighlight: () => set({ highlightMediaId: null }), -})); diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx deleted file mode 100644 index e2bfdff7..00000000 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ /dev/null @@ -1,99 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils"; -import { Tab, tabs, useMediaPanelStore } from "./store"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useEffect, useRef, useState } from "react"; - -export function TabBar() { - const { activeTab, setActiveTab } = useMediaPanelStore(); - const scrollRef = useRef<HTMLDivElement>(null); - const [showTopFade, setShowTopFade] = useState(false); - const [showBottomFade, setShowBottomFade] = useState(false); - - const checkScrollPosition = () => { - const element = scrollRef.current; - if (!element) return; - - const { scrollTop, scrollHeight, clientHeight } = element; - setShowTopFade(scrollTop > 0); - setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1); - }; - - useEffect(() => { - const element = scrollRef.current; - if (!element) return; - - checkScrollPosition(); - element.addEventListener("scroll", checkScrollPosition); - - const resizeObserver = new ResizeObserver(checkScrollPosition); - resizeObserver.observe(element); - - return () => { - element.removeEventListener("scroll", checkScrollPosition); - resizeObserver.disconnect(); - }; - }, []); - - return ( - <div className="flex relative"> - <div - ref={scrollRef} - className="h-full px-4 flex flex-col justify-start items-center gap-5 overflow-y-auto scrollbar-hidden relative w-full py-4" - > - {(Object.keys(tabs) as Tab[]).map((tabKey) => { - const tab = tabs[tabKey]; - return ( - <div - className={cn( - "flex z-[100] flex-col gap-0.5 items-center cursor-pointer", - activeTab === tabKey - ? "text-primary !opacity-100" - : "text-muted-foreground" - )} - onClick={() => setActiveTab(tabKey)} - key={tabKey} - > - <Tooltip delayDuration={10}> - <TooltipTrigger asChild> - <tab.icon className="size-[1.1rem]! opacity-100 hover:opacity-75" /> - </TooltipTrigger> - <TooltipContent - side="right" - align="center" - variant="sidebar" - sideOffset={8} - > - <div className="dark:text-base-gray-950 text-black text-sm font-medium leading-none dark:text-white"> - {tab.label} - </div> - </TooltipContent> - </Tooltip> - </div> - ); - })} - </div> - - <FadeOverlay direction="top" show={showTopFade} /> - <FadeOverlay direction="bottom" show={showBottomFade} /> - </div> - ); -} - -function FadeOverlay({ direction, show }: { direction: "top" | "bottom", show: boolean }) { - return ( - <div - className={cn( - "absolute left-0 right-0 h-6 pointer-events-none z-[101] transition-opacity duration-200", - direction === "top" && show - ? "top-0 bg-gradient-to-b from-panel to-transparent" - : "bottom-0 bg-gradient-to-t from-panel to-transparent" - )} - /> - ); -}
\ No newline at end of file diff --git a/apps/web/src/components/editor/media-panel/views/captions.tsx b/apps/web/src/components/editor/media-panel/views/captions.tsx deleted file mode 100644 index fbe5b418..00000000 --- a/apps/web/src/components/editor/media-panel/views/captions.tsx +++ /dev/null @@ -1,303 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { PropertyGroup } from "../../properties-panel/property-item"; -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/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, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { TextElement } from "@/types/timeline"; - -export const languages: Language[] = [ - { code: "US", name: "English" }, - { code: "ES", name: "Spanish" }, - { code: "IT", name: "Italian" }, - { code: "FR", name: "French" }, - { code: "DE", name: "German" }, - { code: "PT", name: "Portuguese" }, - { code: "RU", name: "Russian" }, - { code: "JP", name: "Japanese" }, - { code: "CN", name: "Chinese" }, -]; - -const PRIVACY_DIALOG_KEY = "opencut-transcription-privacy-accepted"; - -export function Captions() { - const [selectedCountry, setSelectedCountry] = useState("auto"); - const [isProcessing, setIsProcessing] = useState(false); - const [processingStep, setProcessingStep] = useState<string>(""); - const [error, setError] = useState<string | null>(null); - const [showPrivacyDialog, setShowPrivacyDialog] = useState(false); - const [hasAcceptedPrivacy, setHasAcceptedPrivacy] = useState(false); - const containerRef = useRef<HTMLDivElement>(null); - const { insertTrackAt, addElementToTrack } = useTimelineStore(); - - // Check if user has already accepted privacy on mount - useEffect(() => { - const hasAccepted = localStorage.getItem(PRIVACY_DIALOG_KEY) === "true"; - setHasAcceptedPrivacy(hasAccepted); - }, []); - - const handleGenerateTranscript = async () => { - try { - setIsProcessing(true); - setError(null); - setProcessingStep("Extracting audio..."); - - const audioBlob = await extractTimelineAudio(); - - setProcessingStep("Encrypting audio..."); - - // Encrypt the audio with a random key (zero-knowledge) - const audioBuffer = await audioBlob.arrayBuffer(); - const encryptionResult = await encryptWithRandomKey(audioBuffer); - - // Convert encrypted data to blob for upload - const encryptedBlob = new Blob([encryptionResult.encryptedData]); - - setProcessingStep("Uploading..."); - const uploadResponse = await fetch("/api/get-upload-url", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fileExtension: "wav" }), - }); - - if (!uploadResponse.ok) { - const error = await uploadResponse.json(); - throw new Error(error.message || "Failed to get upload URL"); - } - - const { uploadUrl, fileName } = await uploadResponse.json(); - - // Upload to R2 - await fetch(uploadUrl, { - method: "PUT", - body: encryptedBlob, - }); - - setProcessingStep("Transcribing..."); - - // Call Modal transcription API with encryption parameters - const transcriptionResponse = await fetch("/api/transcribe", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - filename: fileName, - language: - selectedCountry === "auto" ? "auto" : selectedCountry.toLowerCase(), - // Send the raw encryption key and IV (zero-knowledge) - decryptionKey: arrayBufferToBase64(encryptionResult.key), - iv: arrayBufferToBase64(encryptionResult.iv), - }), - }); - - if (!transcriptionResponse.ok) { - const error = await transcriptionResponse.json(); - throw new Error(error.message || "Transcription failed"); - } - - const { text, segments } = await transcriptionResponse.json(); - - console.log("Transcription completed:", { text, segments }); - - const shortCaptions: Array<{ - text: string; - startTime: number; - duration: number; - }> = []; - - let globalEndTime = 0; // Track the end time of the last caption globally - - segments.forEach((segment: any) => { - const words = segment.text.trim().split(/\s+/); - const segmentDuration = segment.end - segment.start; - const wordsPerSecond = words.length / segmentDuration; - - // Split into chunks of 2-4 words - const chunks: string[] = []; - for (let i = 0; i < words.length; i += 3) { - chunks.push(words.slice(i, i + 3).join(" ")); - } - - // Calculate timing for each chunk to place them sequentially - let chunkStartTime = segment.start; - chunks.forEach((chunk) => { - const chunkWords = chunk.split(/\s+/).length; - const chunkDuration = Math.max(0.8, chunkWords / wordsPerSecond); // Minimum 0.8s per chunk - - let adjustedStartTime = chunkStartTime; - - // Prevent overlapping: if this caption would start before the last one ends, - // start it right after the last one ends - if (adjustedStartTime < globalEndTime) { - adjustedStartTime = globalEndTime; - } - - shortCaptions.push({ - text: chunk, - startTime: adjustedStartTime, - duration: chunkDuration, - }); - - // Update global end time - globalEndTime = adjustedStartTime + chunkDuration; - - // Next chunk starts when this one ends (for within-segment timing) - chunkStartTime += chunkDuration; - }); - }); - - // Create a single track for all captions - const captionTrackId = insertTrackAt("text", 0); - - // Add all caption elements to the same track - shortCaptions.forEach((caption, index) => { - addElementToTrack(captionTrackId, { - ...DEFAULT_TEXT_ELEMENT, - name: `Caption ${index + 1}`, - content: caption.text, - duration: caption.duration, - startTime: caption.startTime, - fontSize: 65, // Larger for captions - fontWeight: "bold", // Bold for captions - } as TextElement); - }); - - console.log( - `✅ ${shortCaptions.length} short-form caption chunks added to timeline!` - ); - } catch (error) { - console.error("Transcription failed:", error); - setError( - error instanceof Error ? error.message : "An unexpected error occurred" - ); - } finally { - setIsProcessing(false); - setProcessingStep(""); - } - }; - - return ( - <BaseView ref={containerRef} className="flex flex-col justify-between h-full"> - <PropertyGroup title="Language"> - <LanguageSelect - selectedCountry={selectedCountry} - onSelect={setSelectedCountry} - containerRef={containerRef} - languages={languages} - /> - </PropertyGroup> - - <div className="flex flex-col gap-4"> - {error && ( - <div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md"> - <p className="text-sm text-destructive">{error}</p> - </div> - )} - - <Button - className="w-full" - onClick={() => { - if (hasAcceptedPrivacy) { - handleGenerateTranscript(); - } else { - setShowPrivacyDialog(true); - } - }} - disabled={isProcessing} - > - {isProcessing && <Loader2 className="mr-1 h-4 w-4 animate-spin" />} - {isProcessing ? processingStep : "Generate transcript"} - </Button> - - <Dialog open={showPrivacyDialog} onOpenChange={setShowPrivacyDialog}> - <DialogContent className="sm:max-w-md"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <Shield className="h-5 w-5" /> - Audio Processing Notice - </DialogTitle> - <DialogDescription className="space-y-3"> - <p> - To generate captions, we need to process your timeline audio - using speech-to-text technology. - </p> - - <div className="space-y-2 pt-2"> - <div className="flex items-start gap-2"> - <Shield className="h-4 w-4 flex-shrink-0" /> - <span className="text-sm"> - Zero-knowledge encryption - we cannot decrypt your files - even if we wanted to - </span> - </div> - - <div className="flex items-start gap-2"> - <Shield className="h-4 w-4 flex-shrink-0" /> - <span className="text-sm"> - Encryption keys generated randomly in your browser, never - stored anywhere - </span> - </div> - - <div className="flex items-start gap-2"> - <Upload className="h-4 w-4 flex-shrink-0" /> - <span className="text-sm"> - Audio encrypted before upload - raw audio never leaves - your device - </span> - </div> - - <div className="flex items-start gap-2"> - <Trash2 className="h-4 w-4 flex-shrink-0" /> - <span className="text-sm"> - Everything permanently deleted within seconds after - transcription - </span> - </div> - </div> - - <p className="text-xs text-muted-foreground"> - <strong>True zero-knowledge privacy:</strong> Encryption keys - are generated randomly in your browser and never stored - anywhere. It's cryptographically impossible for us, our cloud - providers, or anyone else to decrypt your audio files. - </p> - </DialogDescription> - </DialogHeader> - <DialogFooter className="gap-2"> - <Button - variant="outline" - onClick={() => setShowPrivacyDialog(false)} - disabled={isProcessing} - > - Cancel - </Button> - <Button - onClick={() => { - localStorage.setItem(PRIVACY_DIALOG_KEY, "true"); - setHasAcceptedPrivacy(true); - setShowPrivacyDialog(false); - handleGenerateTranscript(); - }} - disabled={isProcessing} - > - Continue & Generate Captions - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - </div> - </BaseView> - ); -} diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx deleted file mode 100644 index 07a44a7c..00000000 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ /dev/null @@ -1,545 +0,0 @@ -"use client"; - -import { useDragDrop } from "@/hooks/use-drag-drop"; -import { processMediaFiles } from "@/lib/media-processing"; -import { useMediaStore } from "@/stores/media-store"; -import { MediaFile } from "@/types/media"; -import { - ArrowDown01, - CloudUpload, - Grid2X2, - Image, - List, - Loader2, - Music, - Video, -} from "lucide-react"; -import { useRef, useState, useMemo } from "react"; -import { useHighlightScroll } from "@/hooks/use-highlight-scroll"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; -import { - 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 { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { usePanelStore } from "@/stores/panel-store"; -import { useMediaPanelStore } from "../store"; - -function MediaItemWithContextMenu({ - item, - children, - onRemove, -}: { - item: MediaFile; - children: React.ReactNode; - onRemove: (e: React.MouseEvent, id: string) => Promise<void>; -}) { - return ( - <ContextMenu> - <ContextMenuTrigger>{children}</ContextMenuTrigger> - <ContextMenuContent> - <ContextMenuItem>Export clips</ContextMenuItem> - <ContextMenuItem - variant="destructive" - onClick={(e) => onRemove(e, item.id)} - > - Delete - </ContextMenuItem> - </ContextMenuContent> - </ContextMenu> - ); -} - -export function MediaView() { - 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 [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">( - "name" - ); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); - const { highlightMediaId, clearHighlight } = useMediaPanelStore(); - const { highlightedId, registerElement } = useHighlightScroll( - highlightMediaId, - clearHighlight - ); - - const processFiles = async (files: FileList | File[]) => { - if (!files || files.length === 0) return; - if (!activeProject) { - toast.error("No active project"); - return; - } - - setIsProcessing(true); - setProgress(0); - try { - const processedItems = await processMediaFiles(files, (p) => - setProgress(p) - ); - for (const item of processedItems) { - await addMediaFile(activeProject.id, item); - } - } catch (error) { - console.error("Error processing files:", error); - toast.error("Failed to process files"); - } finally { - setIsProcessing(false); - setProgress(0); - } - }; - - const { isDragOver, dragProps } = useDragDrop({ - // When files are dropped, process them - onDrop: processFiles, - }); - - const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker - - const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { - if (e.target.files) processFiles(e.target.files); - e.target.value = ""; // Reset input - }; - - const handleRemove = async (e: React.MouseEvent, id: string) => { - e.stopPropagation(); - - if (!activeProject) { - toast.error("No active project"); - return; - } - - await removeMediaFile(activeProject.id, id); - }; - - const formatDuration = (duration: number) => { - // Format seconds as mm:ss - const min = Math.floor(duration / 60); - const sec = Math.floor(duration % 60); - return `${min}:${sec.toString().padStart(2, "0")}`; - }; - - const filteredMediaItems = useMemo(() => { - let filtered = mediaFiles.filter((item) => { - if (item.ephemeral) return false; - return true; - }); - - filtered.sort((a, b) => { - let valueA: string | number; - let valueB: string | number; - - 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; - }); - - return filtered; - }, [mediaFiles, sortBy, sortOrder]); - - const previewComponents = useMemo(() => { - const previews = new Map<string, React.ReactNode>(); - - filteredMediaItems.forEach((item) => { - let preview: React.ReactNode; - - if (item.type === "image") { - preview = ( - <div className="w-full h-full flex items-center justify-center"> - <img - src={item.url} - alt={item.name} - className="w-full max-h-full object-cover" - loading="lazy" - /> - </div> - ); - } else if (item.type === "video") { - if (item.thumbnailUrl) { - preview = ( - <div className="relative w-full h-full"> - <img - src={item.thumbnailUrl} - alt={item.name} - className="w-full h-full object-cover rounded" - loading="lazy" - /> - <div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded"> - <Video className="h-6 w-6 text-white drop-shadow-md" /> - </div> - {item.duration && ( - <div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded"> - {formatDuration(item.duration)} - </div> - )} - </div> - ); - } else { - preview = ( - <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"> - <Video className="h-6 w-6 mb-1" /> - <span className="text-xs">Video</span> - {item.duration && ( - <span className="text-xs opacity-70"> - {formatDuration(item.duration)} - </span> - )} - </div> - ); - } - } else if (item.type === "audio") { - preview = ( - <div className="w-full h-full bg-linear-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20"> - <Music className="h-6 w-6 mb-1" /> - <span className="text-xs">Audio</span> - {item.duration && ( - <span className="text-xs opacity-70"> - {formatDuration(item.duration)} - </span> - )} - </div> - ); - } else { - preview = ( - <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"> - <Image className="h-6 w-6" /> - <span className="text-xs mt-1">Unknown</span> - </div> - ); - } - - previews.set(item.id, preview); - }); - - return previews; - }, [filteredMediaItems]); - - const renderPreview = (item: MediaFile) => previewComponents.get(item.id); - - return ( - <> - {/* Hidden file input for uploading media */} - <input - ref={fileInputRef} - type="file" - accept="image/*,video/*,audio/*" - multiple - className="hidden" - onChange={handleFileChange} - /> - - <div - className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`} - {...dragProps} - > - <div className="p-3 pb-2 bg-panel"> - {/* Search and filter controls */} - <div className="flex items-center gap-2"> - <Button - variant="outline" - size="lg" - onClick={handleFileSelect} - disabled={isProcessing} - className="!bg-background px-4 flex-1 justify-center items-center h-9 opacity-100 hover:opacity-75 transition-opacity" - > - {isProcessing ? ( - <Loader2 className="h-4 w-4 animate-spin" /> - ) : ( - <CloudUpload className="h-4 w-4" /> - )} - <span>Upload</span> - </Button> - <div className="flex items-center gap-0"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Button - size="icon" - variant="text" - onClick={() => - setMediaViewMode( - mediaViewMode === "grid" ? "list" : "grid" - ) - } - disabled={isProcessing} - className="justify-center items-center" - > - {mediaViewMode === "grid" ? ( - <List strokeWidth={1.5} className="!size-[1.05rem]" /> - ) : ( - <Grid2X2 - strokeWidth={1.5} - className="!size-[1.05rem]" - /> - )} - </Button> - </TooltipTrigger> - <TooltipContent> - <p> - {mediaViewMode === "grid" - ? "Switch to list view" - : "Switch to grid view"} - </p> - </TooltipContent> - <Tooltip> - <DropdownMenu> - <TooltipTrigger asChild> - <DropdownMenuTrigger asChild> - <Button - size="icon" - variant="text" - disabled={isProcessing} - className="justify-center items-center" - > - <ArrowDown01 - strokeWidth={1.5} - className="!size-[1.05rem]" - /> - </Button> - </DropdownMenuTrigger> - </TooltipTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem - onClick={() => { - if (sortBy === "name") { - setSortOrder( - sortOrder === "asc" ? "desc" : "asc" - ); - } else { - setSortBy("name"); - setSortOrder("asc"); - } - }} - > - Name{" "} - {sortBy === "name" && - (sortOrder === "asc" ? "↑" : "↓")} - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => { - if (sortBy === "type") { - setSortOrder( - sortOrder === "asc" ? "desc" : "asc" - ); - } else { - setSortBy("type"); - setSortOrder("asc"); - } - }} - > - Type{" "} - {sortBy === "type" && - (sortOrder === "asc" ? "↑" : "↓")} - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => { - if (sortBy === "duration") { - setSortOrder( - sortOrder === "asc" ? "desc" : "asc" - ); - } else { - setSortBy("duration"); - setSortOrder("asc"); - } - }} - > - Duration{" "} - {sortBy === "duration" && - (sortOrder === "asc" ? "↑" : "↓")} - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => { - if (sortBy === "size") { - setSortOrder( - sortOrder === "asc" ? "desc" : "asc" - ); - } else { - setSortBy("size"); - setSortOrder("asc"); - } - }} - > - File Size{" "} - {sortBy === "size" && - (sortOrder === "asc" ? "↑" : "↓")} - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - <TooltipContent> - <p> - Sort by {sortBy} ( - {sortOrder === "asc" ? "ascending" : "descending"}) - </p> - </TooltipContent> - </Tooltip> - </Tooltip> - </TooltipProvider> - </div> - </div> - </div> - - <div className="h-full w-full overflow-y-auto scrollbar-thin pt-1"> - <div className="flex-1 p-3 pt-0 w-full"> - {isDragOver || filteredMediaItems.length === 0 ? ( - <MediaDragOverlay - isVisible={true} - isProcessing={isProcessing} - progress={progress} - onClick={handleFileSelect} - isEmptyState={filteredMediaItems.length === 0 && !isDragOver} - /> - ) : mediaViewMode === "grid" ? ( - <GridView - filteredMediaItems={filteredMediaItems} - renderPreview={renderPreview} - handleRemove={handleRemove} - highlightedId={highlightedId} - registerElement={registerElement} - /> - ) : ( - <ListView - filteredMediaItems={filteredMediaItems} - renderPreview={renderPreview} - handleRemove={handleRemove} - highlightedId={highlightedId} - registerElement={registerElement} - /> - )} - </div> - </div> - </div> - </> - ); -} - -function GridView({ - filteredMediaItems, - renderPreview, - handleRemove, - highlightedId, - registerElement, -}: { - 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" - style={{ - gridTemplateColumns: "repeat(auto-fill, 160px)", - }} - > - {filteredMediaItems.map((item) => ( - <div key={item.id} ref={(el) => registerElement(item.id, el)}> - <MediaItemWithContextMenu item={item} onRemove={handleRemove}> - <DraggableMediaItem - name={item.name} - preview={renderPreview(item)} - dragData={{ - id: item.id, - type: item.type, - name: item.name, - }} - showPlusOnDrag={false} - onAddToTimeline={(currentTime) => - addElementAtTime(item, currentTime) - } - rounded={false} - variant="card" - isHighlighted={highlightedId === item.id} - /> - </MediaItemWithContextMenu> - </div> - ))} - </div> - ); -} - -function ListView({ - filteredMediaItems, - renderPreview, - handleRemove, - highlightedId, - registerElement, -}: { - 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) => ( - <div key={item.id} ref={(el) => registerElement(item.id, el)}> - <MediaItemWithContextMenu item={item} onRemove={handleRemove}> - <DraggableMediaItem - name={item.name} - preview={renderPreview(item)} - dragData={{ - id: item.id, - type: item.type, - name: item.name, - }} - showPlusOnDrag={false} - onAddToTimeline={(currentTime) => - addElementAtTime(item, currentTime) - } - variant="compact" - isHighlighted={highlightedId === item.id} - /> - </MediaItemWithContextMenu> - </div> - ))} - </div> - ); -} diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx deleted file mode 100644 index be54a9c4..00000000 --- a/apps/web/src/components/editor/media-panel/views/settings.tsx +++ /dev/null @@ -1,332 +0,0 @@ -"use client"; - -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view"; -import { - PropertyItem, - PropertyItemLabel, - PropertyItemValue, - PropertyGroup, -} from "../../properties-panel/property-item"; -import { FPS_PRESETS } from "@/constants/timeline-constants"; -import { useProjectStore } from "@/stores/project-store"; -import type { BlurIntensity } from "@/types/project"; -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, PlusIcon } from "lucide-react"; -import { useMemo, memo, useCallback } from "react"; -import { syntaxUIGradients } from "@/data/colors/syntax-ui"; -import { Button } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; - -export function SettingsView() { - return <ProjectSettingsTabs />; -} - -function ProjectSettingsTabs() { - return ( - <BaseView - defaultTab="project-info" - tabs={[ - { - value: "project-info", - label: "Project info", - content: ( - <div className="p-5"> - <ProjectInfoView /> - </div> - ), - }, - { - value: "background", - label: "Background", - content: ( - <div className="flex flex-col justify-between h-full"> - <div className="flex-1 p-5"> - <BackgroundView /> - </div> - <div className="flex flex-col sticky -bottom-0 bg-panel/85 backdrop-blur-lg"> - <Separator /> - <Button className="w-fit h-auto p-5 py-4 !bg-transparent shadow-none text-muted-foreground hover:text-foreground/85 text-xs"> - Custom background - <PlusIcon /> - </Button> - </div> - - {/* Another UI, looks so beautiful i don't wanna remove it */} - {/* <div className="flex flex-col justify-center items-center pb-5 sticky bottom-0"> - <Button className="w-fit h-auto gap-1.5 px-3.5 py-1.5 bg-foreground hover:bg-foreground/85 text-background rounded-full"> - <span className="text-sm">Custom</span> - <PlusIcon className="" /> - </Button> - </div> */} - </div> - ), - }, - ]} - className="flex flex-col justify-between h-full p-0" - /> - ); -} - -function ProjectInfoView() { - const { activeProject, updateProjectFps, updateCanvasSize } = - useProjectStore(); - const { canvasPresets } = useEditorStore(); - const { getDisplayName } = useAspectRatio(); - - const handleAspectRatioChange = (value: string) => { - const preset = canvasPresets.find((p) => p.name === value); - if (preset) { - updateCanvasSize( - { width: preset.width, height: preset.height }, - "preset" - ); - } - }; - - const handleFpsChange = (value: string) => { - const fps = parseFloat(value); - updateProjectFps(fps); - }; - - return ( - <div className="flex flex-col gap-4"> - <PropertyItem direction="column"> - <PropertyItemLabel>Name</PropertyItemLabel> - <PropertyItemValue> - {activeProject?.name || "Untitled project"} - </PropertyItemValue> - </PropertyItem> - - <PropertyItem direction="column"> - <PropertyItemLabel>Aspect ratio</PropertyItemLabel> - <PropertyItemValue> - <Select - value={getDisplayName()} - onValueChange={handleAspectRatioChange} - > - <SelectTrigger className="bg-panel-accent"> - <SelectValue placeholder="Select an aspect ratio" /> - </SelectTrigger> - <SelectContent> - {canvasPresets.map((preset) => ( - <SelectItem key={preset.name} value={preset.name}> - {preset.name} - </SelectItem> - ))} - </SelectContent> - </Select> - </PropertyItemValue> - </PropertyItem> - - <PropertyItem direction="column"> - <PropertyItemLabel>Frame rate</PropertyItemLabel> - <PropertyItemValue> - <Select - value={(activeProject?.fps || 30).toString()} - onValueChange={handleFpsChange} - > - <SelectTrigger className="bg-panel-accent"> - <SelectValue placeholder="Select a frame rate" /> - </SelectTrigger> - <SelectContent> - {FPS_PRESETS.map((preset) => ( - <SelectItem key={preset.value} value={preset.value}> - {preset.label} - </SelectItem> - ))} - </SelectContent> - </Select> - </PropertyItemValue> - </PropertyItem> - </div> - ); -} - -const BlurPreview = memo( - ({ - blur, - isSelected, - onSelect, - }: { - blur: { label: string; value: number }; - isSelected: boolean; - onSelect: () => void; - }) => ( - <div - className={cn( - "w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary relative overflow-hidden", - isSelected && "border-2 border-primary" - )} - onClick={onSelect} - > - <Image - src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" - alt={`Blur preview ${blur.label}`} - fill - className="object-cover" - style={{ filter: `blur(${blur.value}px)` }} - loading="eager" - /> - <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> - ) -); - -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, index) => ( - <div - key={`${index}-${bg}`} - className={cn( - "w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary", - isColorBackground && - bg === currentBackgroundColor && - "border-2 border-primary" - )} - style={ - useBackgroundColor - ? { backgroundColor: bg } - : { - background: bg, - backgroundSize: "cover", - backgroundPosition: "center", - backgroundRepeat: "no-repeat", - } - } - onClick={() => handleColorSelect(bg)} - /> - )), - [ - backgrounds, - isColorBackground, - currentBackgroundColor, - handleColorSelect, - useBackgroundColor, - ] - ); - } -); - -BackgroundPreviews.displayName = "BackgroundPreviews"; - -function BackgroundView() { - const { activeProject, updateBackgroundType } = useProjectStore(); - - const blurLevels = useMemo<Array<{ label: string; value: BlurIntensity }>>( - () => [ - { label: "Light", value: 4 }, - { label: "Medium", value: 8 }, - { label: "Heavy", value: 18 }, - ], - [] - ); - - const handleBlurSelect = useCallback( - async (blurIntensity: BlurIntensity) => { - 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) => ( - <BlurPreview - key={blur.value} - blur={blur} - isSelected={isBlurBackground && currentBlurIntensity === blur.value} - onSelect={() => handleBlurSelect(blur.value)} - /> - )), - [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect] - ); - - return ( - <div className="flex flex-col gap-4 h-full"> - <PropertyGroup title="Blur" defaultExpanded={false}> - <div className="grid grid-cols-4 gap-2 w-full">{blurPreviews}</div> - </PropertyGroup> - - <PropertyGroup title="Colors" defaultExpanded={false}> - <div className="grid grid-cols-4 gap-2 w-full"> - <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> - <BackgroundPreviews - backgrounds={colors} - currentBackgroundColor={currentBackgroundColor} - isColorBackground={isColorBackground} - handleColorSelect={handleColorSelect} - useBackgroundColor={true} - /> - </div> - </PropertyGroup> - - <PropertyGroup title="Pattern craft" defaultExpanded={false}> - <div className="grid grid-cols-4 gap-2 w-full"> - <BackgroundPreviews - backgrounds={patternCraftGradients} - currentBackgroundColor={currentBackgroundColor} - isColorBackground={isColorBackground} - handleColorSelect={handleColorSelect} - /> - </div> - </PropertyGroup> - - <PropertyGroup title="Syntax UI" defaultExpanded={false}> - <div className="grid grid-cols-4 gap-2 w-full"> - <BackgroundPreviews - backgrounds={syntaxUIGradients} - currentBackgroundColor={currentBackgroundColor} - isColorBackground={isColorBackground} - handleColorSelect={handleColorSelect} - /> - </div> - </PropertyGroup> - </div> - ); -} diff --git a/apps/web/src/components/editor/media-panel/views/sounds.tsx b/apps/web/src/components/editor/media-panel/views/sounds.tsx deleted file mode 100644 index 700eaea7..00000000 --- a/apps/web/src/components/editor/media-panel/views/sounds.tsx +++ /dev/null @@ -1,562 +0,0 @@ -"use client"; - -import { Input } from "@/components/ui/input"; -import { useState, useMemo, useEffect } from "react"; -import { Separator } from "@/components/ui/separator"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - PlayIcon, - PauseIcon, - HeartIcon, - PlusIcon, - ListFilter, -} from "lucide-react"; -import { useSoundsStore } from "@/stores/sounds-store"; -import { useSoundSearch } from "@/hooks/use-sound-search"; -import type { SoundEffect, SavedSound } from "@/types/sounds"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuCheckboxItem, -} from "@/components/ui/dropdown-menu"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { cn } from "@/lib/utils"; -import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; - -export function SoundsView() { - return ( - <div className="h-full flex flex-col"> - <Tabs defaultValue="sound-effects" className="flex flex-col h-full"> - <div className="px-3 pt-4 pb-0"> - <TabsList> - <TabsTrigger value="sound-effects">Sound effects</TabsTrigger> - <TabsTrigger value="songs">Songs</TabsTrigger> - <TabsTrigger value="saved">Saved</TabsTrigger> - </TabsList> - </div> - <Separator className="my-4" /> - <TabsContent - value="sound-effects" - className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0" - > - <SoundEffectsView /> - </TabsContent> - <TabsContent - value="saved" - className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0" - > - <SavedSoundsView /> - </TabsContent> - <TabsContent - value="songs" - className="p-5 pt-0 mt-0 flex-1 flex flex-col min-h-0" - > - <SongsView /> - </TabsContent> - </Tabs> - </div> - ); -} - -function SoundEffectsView() { - const { - topSoundEffects, - isLoading, - searchQuery, - setSearchQuery, - scrollPosition, - setScrollPosition, - loadSavedSounds, - isSoundSaved, - toggleSavedSound, - showCommercialOnly, - toggleCommercialFilter, - hasLoaded, - setTopSoundEffects, - setLoading, - setError, - setHasLoaded, - setCurrentPage, - setHasNextPage, - setTotalCount, - } = useSoundsStore(); - const { - results: searchResults, - isLoading: isSearching, - loadMore, - hasNextPage, - isLoadingMore, - } = useSoundSearch(searchQuery, showCommercialOnly); - - // Audio playback state - const [playingId, setPlayingId] = useState<number | null>(null); - const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>( - null - ); - - const { scrollAreaRef, handleScroll } = useInfiniteScroll({ - onLoadMore: loadMore, - hasMore: hasNextPage, - isLoading: isLoadingMore || isSearching, - }); - - useEffect(() => { - loadSavedSounds(); - - if (!hasLoaded) { - let ignore = false; - - const fetchTopSounds = async () => { - try { - if (!ignore) { - setLoading(true); - setError(null); - } - - const response = await fetch( - "/api/sounds/search?page_size=50&sort=downloads" - ); - - if (!ignore) { - if (!response.ok) { - throw new Error(`Failed to fetch: ${response.status}`); - } - - const data = await response.json(); - setTopSoundEffects(data.results); - setHasLoaded(true); - - setCurrentPage(1); - setHasNextPage(!!data.next); - setTotalCount(data.count); - } - } catch (error) { - if (!ignore) { - console.error("Failed to fetch top sounds:", error); - setError( - error instanceof Error ? error.message : "Failed to load sounds" - ); - } - } finally { - if (!ignore) { - setLoading(false); - } - } - }; - - const timeoutId = setTimeout(fetchTopSounds, 100); - - return () => { - clearTimeout(timeoutId); - ignore = true; - }; - } - - if (scrollAreaRef.current && scrollPosition > 0) { - const timeoutId = setTimeout(() => { - scrollAreaRef.current?.scrollTo({ top: scrollPosition }); - }, 100); - - return () => clearTimeout(timeoutId); - } - }, [ - hasLoaded, - setTopSoundEffects, - setLoading, - setError, - setHasLoaded, - setCurrentPage, - setHasNextPage, - setTotalCount, - ]); - - const handleScrollWithPosition = (event: React.UIEvent<HTMLDivElement>) => { - const { scrollTop } = event.currentTarget; - setScrollPosition(scrollTop); - handleScroll(event); - }; - - const displayedSounds = useMemo(() => { - const sounds = searchQuery ? searchResults : topSoundEffects; - return sounds; - }, [searchQuery, searchResults, topSoundEffects]); - - const playSound = (sound: SoundEffect) => { - if (playingId === sound.id) { - audioElement?.pause(); - setPlayingId(null); - return; - } - - // Stop previous sound - audioElement?.pause(); - - if (sound.previewUrl) { - const audio = new Audio(sound.previewUrl); - audio.addEventListener("ended", () => { - setPlayingId(null); - }); - audio.addEventListener("error", (e) => { - setPlayingId(null); - }); - audio.play().catch((error) => { - setPlayingId(null); - }); - - setAudioElement(audio); - setPlayingId(sound.id); - } - }; - - return ( - <div className="flex flex-col gap-5 mt-1 h-full"> - <div className="flex items-center gap-3"> - <Input - placeholder="Search sound effects" - className="bg-panel-accent w-full" - containerClassName="w-full" - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - showClearIcon - onClear={() => setSearchQuery("")} - /> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="text" - size="icon" - className={cn(showCommercialOnly && "text-primary")} - > - <ListFilter className="w-4 h-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-56"> - <DropdownMenuCheckboxItem - checked={showCommercialOnly} - onCheckedChange={toggleCommercialFilter} - > - Show only commercially licensed - </DropdownMenuCheckboxItem> - <div className="px-2 py-1.5 text-xs text-muted-foreground"> - {showCommercialOnly - ? "Only showing sounds licensed for commercial use" - : "Showing all sounds regardless of license"} - </div> - </DropdownMenuContent> - </DropdownMenu> - </div> - - <div className="relative h-full overflow-hidden"> - <ScrollArea - className="flex-1 h-full" - ref={scrollAreaRef} - onScrollCapture={handleScrollWithPosition} - > - <div className="flex flex-col gap-4"> - {isLoading && !searchQuery && ( - <div className="text-muted-foreground text-sm"> - Loading sounds... - </div> - )} - {isSearching && searchQuery && ( - <div className="text-muted-foreground text-sm">Searching...</div> - )} - {displayedSounds.map((sound) => ( - <AudioItem - key={sound.id} - sound={sound} - isPlaying={playingId === sound.id} - onPlay={() => playSound(sound)} - isSaved={isSoundSaved(sound.id)} - onToggleSaved={() => toggleSavedSound(sound)} - /> - ))} - {!isLoading && !isSearching && displayedSounds.length === 0 && ( - <div className="text-muted-foreground text-sm"> - {searchQuery ? "No sounds found" : "No sounds available"} - </div> - )} - {isLoadingMore && ( - <div className="text-muted-foreground text-sm text-center py-4"> - Loading more sounds... - </div> - )} - </div> - </ScrollArea> - </div> - </div> - ); -} - -function SavedSoundsView() { - const { - savedSounds, - isLoadingSavedSounds, - savedSoundsError, - loadSavedSounds, - isSoundSaved, - toggleSavedSound, - clearSavedSounds, - } = useSoundsStore(); - - // Audio playback state - const [playingId, setPlayingId] = useState<number | null>(null); - const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>( - null - ); - - // Clear confirmation dialog state - const [showClearDialog, setShowClearDialog] = useState(false); - - // Load saved sounds when tab becomes active - useEffect(() => { - loadSavedSounds(); - }, [loadSavedSounds]); - - const playSound = (sound: SavedSound) => { - if (playingId === sound.id) { - audioElement?.pause(); - setPlayingId(null); - return; - } - - // Stop previous sound - audioElement?.pause(); - - if (sound.previewUrl) { - const audio = new Audio(sound.previewUrl); - audio.addEventListener("ended", () => { - setPlayingId(null); - }); - audio.addEventListener("error", (e) => { - setPlayingId(null); - }); - audio.play().catch((error) => { - setPlayingId(null); - }); - - setAudioElement(audio); - setPlayingId(sound.id); - } - }; - - // Convert SavedSound to SoundEffect for compatibility with AudioItem - const convertToSoundEffect = (savedSound: SavedSound): SoundEffect => ({ - id: savedSound.id, - name: savedSound.name, - description: "", - url: "", - previewUrl: savedSound.previewUrl, - downloadUrl: savedSound.downloadUrl, - duration: savedSound.duration, - filesize: 0, - type: "audio", - channels: 0, - bitrate: 0, - bitdepth: 0, - samplerate: 0, - username: savedSound.username, - tags: savedSound.tags, - license: savedSound.license, - created: savedSound.savedAt, - downloads: 0, - rating: 0, - ratingCount: 0, - }); - - if (isLoadingSavedSounds) { - return ( - <div className="flex items-center justify-center h-full"> - <div className="text-muted-foreground text-sm"> - Loading saved sounds... - </div> - </div> - ); - } - - if (savedSoundsError) { - return ( - <div className="flex items-center justify-center h-full"> - <div className="text-destructive text-sm"> - Error: {savedSoundsError} - </div> - </div> - ); - } - - if (savedSounds.length === 0) { - return ( - <div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3"> - <HeartIcon - className="w-10 h-10 text-muted-foreground" - strokeWidth={1.5} - /> - <div className="flex flex-col gap-2 text-center"> - <p className="text-lg font-medium">No saved sounds</p> - <p className="text-sm text-muted-foreground text-balance"> - Click the heart icon on any sound to save it here - </p> - </div> - </div> - ); - } - - return ( - <div className="flex flex-col gap-5 mt-1 h-full"> - <div className="flex items-center justify-between"> - <p className="text-sm text-muted-foreground"> - {savedSounds.length} saved{" "} - {savedSounds.length === 1 ? "sound" : "sounds"} - </p> - <Dialog open={showClearDialog} onOpenChange={setShowClearDialog}> - <DialogTrigger asChild> - <Button - variant="text" - size="sm" - className="h-auto text-muted-foreground hover:text-destructive !opacity-100" - > - Clear all - </Button> - </DialogTrigger> - <DialogContent> - <DialogHeader> - <DialogTitle>Clear all saved sounds?</DialogTitle> - <DialogDescription> - This will permanently remove all {savedSounds.length} saved - sounds from your collection. This action cannot be undone. - </DialogDescription> - </DialogHeader> - <DialogFooter> - <Button variant="text" onClick={() => setShowClearDialog(false)}> - Cancel - </Button> - <Button - variant="destructive" - onClick={async () => { - await clearSavedSounds(); - setShowClearDialog(false); - }} - > - Clear all sounds - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - </div> - - <div className="relative h-full overflow-hidden"> - <ScrollArea className="flex-1 h-full"> - <div className="flex flex-col gap-4"> - {savedSounds.map((sound) => ( - <AudioItem - key={sound.id} - sound={convertToSoundEffect(sound)} - isPlaying={playingId === sound.id} - onPlay={() => playSound(sound)} - isSaved={isSoundSaved(sound.id)} - onToggleSaved={() => - toggleSavedSound(convertToSoundEffect(sound)) - } - /> - ))} - </div> - </ScrollArea> - </div> - </div> - ); -} - -function SongsView() { - return <div>Songs</div>; -} - -interface AudioItemProps { - sound: SoundEffect; - isPlaying: boolean; - onPlay: () => void; - isSaved: boolean; - onToggleSaved: () => void; -} - -function AudioItem({ - sound, - isPlaying, - onPlay, - isSaved, - onToggleSaved, -}: AudioItemProps) { - const { addSoundToTimeline } = useSoundsStore(); - - const handleClick = () => { - onPlay(); - }; - - const handleSaveClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onToggleSaved(); - }; - - const handleAddToTimeline = async (e: React.MouseEvent) => { - e.stopPropagation(); - await addSoundToTimeline(sound); - }; - - return ( - <div - className="group flex items-center gap-3 opacity-100 hover:opacity-75 transition-opacity cursor-pointer" - onClick={handleClick} - > - <div className="relative w-12 h-12 bg-accent rounded-md flex items-center justify-center overflow-hidden shrink-0"> - <div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-transparent" /> - {isPlaying ? ( - <PauseIcon className="w-5 h-5" /> - ) : ( - <PlayIcon className="w-5 h-5" /> - )} - </div> - - <div className="flex-1 min-w-0 overflow-hidden"> - <p className="font-medium truncate text-sm">{sound.name}</p> - <span className="text-xs text-muted-foreground truncate block"> - {sound.username} - </span> - </div> - - <div className="flex items-center gap-3 pr-2"> - <Button - variant="text" - size="icon" - className="text-muted-foreground hover:text-foreground !opacity-100 w-auto" - onClick={handleAddToTimeline} - title="Add to timeline" - > - <PlusIcon className="w-4 h-4" /> - </Button> - <Button - variant="text" - size="icon" - className={`hover:text-foreground !opacity-100 w-auto ${ - isSaved - ? "text-red-500 hover:text-red-600" - : "text-muted-foreground" - }`} - onClick={handleSaveClick} - title={isSaved ? "Remove from saved" : "Save sound"} - > - <HeartIcon className={`w-4 h-4 ${isSaved ? "fill-current" : ""}`} /> - </Button> - </div> - </div> - ); -} diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx deleted file mode 100644 index f67450cc..00000000 --- a/apps/web/src/components/editor/media-panel/views/stickers.tsx +++ /dev/null @@ -1,612 +0,0 @@ -"use client"; - -import { useEffect, useState, useMemo } from "react"; -import { useStickersStore } from "@/stores/stickers-store"; -import { useMediaStore } from "@/stores/media-store"; -import { useProjectStore } from "@/stores/project-store"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { usePlaybackStore } from "@/stores/playback-store"; -import { - Loader2, - Grid3X3, - Hash, - Smile, - Clock, - X, - Sparkles, - ArrowRight, - StickerIcon, -} from "lucide-react"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; - -import { - getIconSvgUrl, - buildIconSvgUrl, - ICONIFY_HOSTS, - POPULAR_COLLECTIONS, -} from "@/lib/iconify-api"; -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"; -import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; - -export function StickersView() { - const { selectedCategory, setSelectedCategory } = useStickersStore(); - - return ( - <BaseView - value={selectedCategory} - onValueChange={(v) => { - if (["all", "general", "brands", "emoji"].includes(v)) { - setSelectedCategory(v as StickerCategory); - } - }} - tabs={[ - { - value: "all", - label: "All", - icon: <Grid3X3 className="h-3 w-3" />, - content: <StickersContentView category="all" />, - }, - { - value: "general", - label: "Icons", - icon: <Sparkles className="h-3 w-3" />, - content: <StickersContentView category="general" />, - }, - { - value: "brands", - label: "Brands", - icon: <Hash className="h-3 w-3" />, - content: <StickersContentView category="brands" />, - }, - { - value: "emoji", - label: "Emoji", - icon: <Smile className="h-3 w-3" />, - content: <StickersContentView category="emoji" />, - }, - ]} - className="flex flex-col h-full p-0 overflow-hidden" - /> - ); -} - -function StickerGrid({ - icons, - onAdd, - addingSticker, - capSize = false, -}: { - icons: string[]; - onAdd: (iconName: string) => void; - addingSticker: string | null; - capSize?: boolean; -}) { - return ( - <div - className="grid gap-2" - style={{ - gridTemplateColumns: capSize - ? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))" - : "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))", - ["--sticker-min" as any]: "96px", - ...(capSize ? ({ ["--sticker-max"]: "160px" } as any) : {}), - }} - > - {icons.map((iconName) => ( - <StickerItem - key={iconName} - iconName={iconName} - onAdd={onAdd} - isAdding={addingSticker === iconName} - capSize={capSize} - /> - ))} - </div> - ); -} - -function CollectionGrid({ - collections, - onSelectCollection, -}: { - collections: Array<{ - prefix: string; - name: string; - total: number; - category?: string; - }>; - onSelectCollection: (prefix: string) => void; -}) { - return ( - <div className="grid grid-cols-1 gap-2"> - {collections.map((collection) => ( - <CollectionItem - key={collection.prefix} - title={collection.name} - subtitle={`${collection.total.toLocaleString()} icons${collection.category ? ` • ${collection.category}` : ""}`} - onClick={() => onSelectCollection(collection.prefix)} - /> - ))} - </div> - ); -} - -function EmptyView({ message }: { message: string }) { - return ( - <div className="bg-panel h-full p-4 flex flex-col items-center justify-center gap-3"> - <StickerIcon - className="w-10 h-10 text-muted-foreground" - strokeWidth={1.5} - /> - <div className="flex flex-col gap-2 text-center"> - <p className="text-lg font-medium">No stickers found</p> - <p className="text-sm text-muted-foreground text-balance">{message}</p> - </div> - </div> - ); -} - -function StickersContentView({ category }: { category: StickerCategory }) { - const { activeProject } = useProjectStore(); - const { addElementAtTime } = useTimelineStore(); - const { currentTime } = usePlaybackStore(); - const { addMediaFile } = useMediaStore(); - const { - searchQuery, - selectedCollection, - viewMode, - collections, - currentCollection, - searchResults, - recentStickers, - isLoadingCollections, - isLoadingCollection, - isSearching, - setSearchQuery, - setSelectedCollection, - loadCollections, - searchStickers, - addStickerToTimeline, - clearRecentStickers, - setSelectedCategory, - addingSticker, - } = useStickersStore(); - - const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery); - const [collectionsToShow, setCollectionsToShow] = useState(20); - const [showCollectionItems, setShowCollectionItems] = useState(false); - - const filteredCollections = useMemo(() => { - if (category === "all") { - return Object.entries(collections).map(([prefix, collection]) => ({ - prefix, - name: collection.name, - total: collection.total, - category: collection.category, - })); - } - - const collectionList = - POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS]; - if (!collectionList) return []; - - return collectionList - .map((c) => { - const collection = collections[c.prefix]; - return collection - ? { - prefix: c.prefix, - name: c.name, - total: collection.total, - } - : null; - }) - .filter(Boolean) as Array<{ - prefix: string; - name: string; - total: number; - }>; - }, [collections, category]); - - const { scrollAreaRef, handleScroll } = useInfiniteScroll({ - onLoadMore: () => setCollectionsToShow((prev) => prev + 20), - hasMore: filteredCollections.length > collectionsToShow, - isLoading: isLoadingCollections, - enabled: viewMode === "browse" && !selectedCollection && category === "all", - }); - - useEffect(() => { - if (Object.keys(collections).length === 0) { - loadCollections(); - } - }, []); - - useEffect(() => { - const timer = setTimeout(() => { - if (localSearchQuery !== searchQuery) { - setSearchQuery(localSearchQuery); - if (localSearchQuery.trim()) { - searchStickers(localSearchQuery); - } - } - }, 500); - - return () => clearTimeout(timer); - }, [localSearchQuery]); - - const handleAddSticker = async (iconName: string) => { - try { - await addStickerToTimeline(iconName); - } catch (error) { - console.error("Failed to add sticker:", error); - toast.error("Failed to add sticker to timeline"); - } - }; - - const iconsToDisplay = useMemo(() => { - if (viewMode === "search" && searchResults) { - return searchResults.icons; - } - - if (viewMode === "collection" && currentCollection) { - const icons: string[] = []; - - if (currentCollection.uncategorized) { - icons.push( - ...currentCollection.uncategorized.map( - (name) => `${currentCollection.prefix}:${name}` - ) - ); - } - - if (currentCollection.categories) { - Object.values(currentCollection.categories).forEach((categoryIcons) => { - icons.push( - ...categoryIcons.map( - (name) => `${currentCollection.prefix}:${name}` - ) - ); - }); - } - - return icons.slice(0, 100); - } - - return []; - }, [viewMode, searchResults, currentCollection]); - - const isInCollection = viewMode === "collection" && !!selectedCollection; - - useEffect(() => { - if (isInCollection) { - setShowCollectionItems(false); - const timer = setTimeout(() => setShowCollectionItems(true), 350); - return () => clearTimeout(timer); - } else { - setShowCollectionItems(false); - } - }, [isInCollection]); - - return ( - <div className="flex flex-col gap-5 mt-1 h-full p-4"> - <div className="space-y-3"> - <InputWithBack - isExpanded={isInCollection} - setIsExpanded={(expanded) => { - if (!expanded && isInCollection) { - setSelectedCollection(null); - } - }} - placeholder={ - category === "all" - ? "Search all stickers" - : category === "general" - ? "Search icons" - : category === "brands" - ? "Search brands" - : "Search Emojis" - } - value={localSearchQuery} - onChange={setLocalSearchQuery} - disableAnimation={true} - /> - </div> - - <div className="relative h-full overflow-hidden"> - <ScrollArea - className="flex-1 h-full" - ref={scrollAreaRef} - onScrollCapture={handleScroll} - > - <div className="flex flex-col gap-4 h-full"> - {recentStickers.length > 0 && viewMode === "browse" && ( - <div className="h-full"> - <div className="flex items-center gap-2 mb-2"> - <Clock className="h-4 w-4 text-muted-foreground" /> - <span className="text-sm font-medium">Recent</span> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <button - onClick={clearRecentStickers} - className="ml-auto h-5 w-5 p-0 rounded hover:bg-accent flex items-center justify-center" - > - <X className="h-3 w-3 text-muted-foreground" /> - </button> - </TooltipTrigger> - <TooltipContent> - <p>Clear recent stickers</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - <StickerGrid - icons={recentStickers.slice(0, 12)} - onAdd={handleAddSticker} - addingSticker={addingSticker} - capSize - /> - </div> - )} - - {viewMode === "collection" && selectedCollection && ( - <div className="h-full"> - {isLoadingCollection ? ( - <div className="flex items-center justify-center py-8"> - <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> - </div> - ) : showCollectionItems ? ( - <StickerGrid - icons={iconsToDisplay} - onAdd={handleAddSticker} - addingSticker={addingSticker} - /> - ) : ( - <div className="flex items-center justify-center py-8"> - <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> - </div> - )} - </div> - )} - - {viewMode === "search" && ( - <div className="h-full"> - {isSearching ? ( - <div className="flex items-center justify-center py-8"> - <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> - </div> - ) : searchResults?.icons.length ? ( - <> - <div className="flex items-center justify-between mb-3"> - <span className="text-sm text-muted-foreground"> - {searchResults.total} results - </span> - </div> - <StickerGrid - icons={iconsToDisplay} - onAdd={handleAddSticker} - addingSticker={addingSticker} - capSize - /> - </> - ) : searchQuery ? ( - <div className="flex flex-col items-center justify-center py-8 gap-3"> - <EmptyView - message={`No stickers found for "${searchQuery}"`} - /> - {category !== "all" && ( - <Button - variant="outline" - onClick={() => { - const q = localSearchQuery || searchQuery; - if (q) { - setSearchQuery(q); - } - setSelectedCategory("all"); - if (q) { - searchStickers(q); - } - }} - > - Search in all icons - </Button> - )} - </div> - ) : null} - </div> - )} - - {viewMode === "browse" && !selectedCollection && ( - <div className="space-y-4 h-full"> - {isLoadingCollections ? ( - <div className="flex items-center justify-center py-8"> - <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> - </div> - ) : ( - <> - {category !== "all" && ( - <div className="h-full"> - <h3 className="text-sm font-medium mb-2"> - Popular{" "} - {category === "general" - ? "Icon Sets" - : category === "brands" - ? "Brand Icons" - : "Emoji Sets"} - </h3> - <CollectionGrid - collections={filteredCollections} - onSelectCollection={setSelectedCollection} - /> - </div> - )} - - {category === "all" && filteredCollections.length > 0 && ( - <div className="h-full"> - <CollectionGrid - collections={filteredCollections.slice( - 0, - collectionsToShow - )} - onSelectCollection={setSelectedCollection} - /> - </div> - )} - </> - )} - </div> - )} - </div> - </ScrollArea> - </div> - </div> - ); -} - -interface CollectionItemProps { - title: string; - subtitle: string; - onClick: () => void; -} - -function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) { - return ( - <Button - variant="outline" - className="justify-between h-auto py-2 " - onClick={onClick} - > - <div className="text-left"> - <p className="font-medium">{title}</p> - <p className="text-xs text-muted-foreground">{subtitle}</p> - </div> - <ArrowRight className="h-4 w-4" /> - </Button> - ); -} - -interface StickerItemProps { - iconName: string; - onAdd: (iconName: string) => void; - isAdding?: boolean; - capSize?: boolean; -} - -function StickerItem({ - iconName, - onAdd, - isAdding, - capSize = false, -}: StickerItemProps) { - const [imageError, setImageError] = useState(false); - const [hostIndex, setHostIndex] = useState(0); - - useEffect(() => { - setImageError(false); - setHostIndex(0); - }, [iconName]); - - const displayName = iconName.split(":")[1] || iconName; - const collectionPrefix = iconName.split(":")[0]; - - const preview = imageError ? ( - <div className="w-full h-full flex items-center justify-center p-2"> - <span className="text-xs text-muted-foreground text-center break-all"> - {displayName} - </span> - </div> - ) : ( - <div className="w-full h-full p-4 flex items-center justify-center"> - <Image - src={ - hostIndex === 0 - ? getIconSvgUrl(iconName, { width: 64, height: 64 }) - : buildIconSvgUrl( - ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)], - iconName, - { width: 64, height: 64 } - ) - } - alt={displayName} - width={64} - height={64} - className="w-full h-full object-contain" - style={ - capSize - ? { - maxWidth: "var(--sticker-max, 160px)", - maxHeight: "var(--sticker-max, 160px)", - } - : undefined - } - onError={() => { - const next = hostIndex + 1; - if (next < ICONIFY_HOSTS.length) { - setHostIndex(next); - } else { - setImageError(true); - } - }} - loading="lazy" - unoptimized - /> - </div> - ); - - return ( - <Tooltip> - <TooltipTrigger asChild> - <div - className={cn( - "relative", - isAdding && "opacity-50 pointer-events-none" - )} - > - <DraggableMediaItem - name={displayName} - preview={preview} - dragData={{ - id: "sticker-placeholder", - type: "image", - name: displayName, - }} - onAddToTimeline={() => onAdd(iconName)} - aspectRatio={1} - showLabel={false} - rounded={true} - variant="card" - className="" - containerClassName="w-full" - isDraggable={false} - /> - {isAdding && ( - <div className="absolute inset-0 bg-black/60 flex items-center justify-center rounded-md z-10"> - <Loader2 className="h-6 w-6 animate-spin text-white" /> - </div> - )} - </div> - </TooltipTrigger> - <TooltipContent> - <div className="space-y-1"> - <p className="font-medium">{displayName}</p> - <p className="text-xs text-muted-foreground">{collectionPrefix}</p> - </div> - </TooltipContent> - </Tooltip> - ); -} diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx deleted file mode 100644 index 22fc2413..00000000 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { DraggableMediaItem } from "@/components/ui/draggable-item"; -import { PanelBaseView as BaseView } from "@/components/editor/panel-base-view"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; - -export function TextView() { - return ( - <BaseView> - <DraggableMediaItem - name="Default text" - preview={ - <div className="flex items-center justify-center w-full h-full bg-panel-accent rounded"> - <span className="text-xs select-none">Default text</span> - </div> - } - dragData={{ - 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().addElementAtTime( - { - ...DEFAULT_TEXT_ELEMENT, - id: "temp-text-id", - }, - currentTime - ) - } - showLabel={false} - /> - </BaseView> - ); -} |
