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/panels | |
| parent | 0173db99448d1c4c6415dcf15272e17551b48b04 (diff) | |
| download | opencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz | |
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/components/editor/panels')
17 files changed, 3721 insertions, 0 deletions
diff --git a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx new file mode 100644 index 00000000..c6b0861c --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx @@ -0,0 +1,61 @@ +import { HugeiconsIcon } from "@hugeicons/react"; +import { UploadIcon } from "@hugeicons/core-free-icons"; + +interface MediaDragOverlayProps { + isVisible: boolean; + isProcessing?: boolean; + progress?: number; + onClick?: () => void; +} + +export function MediaDragOverlay({ + isVisible, + isProcessing = false, + progress = 0, + onClick, +}: MediaDragOverlayProps) { + if (!isVisible) return null; + + const handleClick = ({ + event, + }: { + event: React.MouseEvent<HTMLButtonElement>; + }) => { + if (isProcessing || !onClick) return; + event.preventDefault(); + event.stopPropagation(); + onClick(); + }; + + return ( + <button + className="bg-foreground/5 hover:bg-foreground/10 flex size-full flex-col items-center justify-center gap-4 rounded-lg p-8 text-center" + type="button" + disabled={isProcessing || !onClick} + onClick={(event) => handleClick({ event })} + > + <div className="flex items-center justify-center"> + <HugeiconsIcon icon={UploadIcon} className="text-foreground size-10" /> + </div> + + <div className="space-y-2"> + <p className="text-muted-foreground max-w-sm text-xs"> + {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="bg-muted/50 h-2 w-full rounded-full"> + <div + className="bg-primary h-2 rounded-full" + style={{ width: `${progress}%` }} + /> + </div> + </div> + )} + </button> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx new file mode 100644 index 00000000..10cb1f8c --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { Plus } from "lucide-react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { AspectRatio } from "@/components/ui/aspect-ratio"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useEditor } from "@/hooks/use-editor"; +import { clearDragData, setDragData } from "@/lib/drag-data"; +import type { TimelineDragData } from "@/types/drag"; +import { cn } from "@/utils/ui"; + +export interface DraggableItemProps { + name: string; + preview: ReactNode; + dragData: TimelineDragData; + onDragStart?: ({ e }: { e: React.DragEvent }) => void; + onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void; + aspectRatio?: number; + className?: string; + containerClassName?: string; + shouldShowPlusOnDrag?: boolean; + shouldShowLabel?: boolean; + isRounded?: boolean; + variant?: "card" | "compact"; + isDraggable?: boolean; + isHighlighted?: boolean; +} + +export function DraggableItem({ + name, + preview, + dragData, + onDragStart, + onAddToTimeline, + aspectRatio = 16 / 9, + className = "", + containerClassName, + shouldShowPlusOnDrag = true, + shouldShowLabel = true, + isRounded = true, + variant = "card", + isDraggable = true, + isHighlighted = false, +}: DraggableItemProps) { + const [isDragging, setIsDragging] = useState(false); + const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); + const dragRef = useRef<HTMLDivElement>(null); + const editor = useEditor(); + const highlightClassName = `ring-2 ring-primary bg-primary/10 ${isRounded ? "rounded-sm" : ""}`; + + const handleAddToTimeline = () => { + onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() }); + }; + + const emptyImg = new window.Image(); + emptyImg.src = + "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; + + useEffect(() => { + if (!isDragging) return; + + const handleDragOver = (e: DragEvent) => { + setDragPosition({ x: e.clientX, y: e.clientY }); + }; + + document.addEventListener("dragover", handleDragOver); + + return () => { + document.removeEventListener("dragover", handleDragOver); + }; + }, [isDragging]); + + const handleDragStart = (e: React.DragEvent) => { + e.dataTransfer.setDragImage(emptyImg, 0, 0); + + setDragData({ dataTransfer: e.dataTransfer, dragData }); + e.dataTransfer.effectAllowed = "copy"; + + setDragPosition({ x: e.clientX, y: e.clientY }); + setIsDragging(true); + + onDragStart?.({ e }); + }; + + const handleDragEnd = () => { + setIsDragging(false); + clearDragData(); + }; + + return ( + <> + {variant === "card" ? ( + <div + ref={dragRef} + className={cn("group relative", containerClassName ?? "size-28")} + > + <div + className={cn( + "relative flex h-auto w-full cursor-default flex-col gap-1 p-1", + className, + isHighlighted && highlightClassName, + )} + > + <AspectRatio + ratio={aspectRatio} + className={cn( + "bg-panel-accent relative overflow-hidden", + isRounded && "rounded-sm", + isDraggable && "[&::-webkit-drag-ghost]:opacity-0", + )} + draggable={isDraggable} + onDragStart={isDraggable ? handleDragStart : undefined} + onDragEnd={isDraggable ? handleDragEnd : undefined} + > + {preview} + {!isDragging && ( + <PlusButton + className="opacity-0 group-hover:opacity-100" + onClick={handleAddToTimeline} + /> + )} + </AspectRatio> + {shouldShowLabel && ( + <span + className="text-muted-foreground w-full truncate text-left text-[0.7rem]" + title={name} + > + <span className="sr-only">{name}</span> + <span aria-hidden="true"> + {name.length > 8 + ? `${name.slice(0, 16)}...${name.slice(-3)}` + : name} + </span> + </span> + )} + </div> + </div> + ) : ( + <div + ref={dragRef} + className={cn( + "group relative w-full", + isHighlighted && highlightClassName, + )} + > + <button + type="button" + className={cn( + "flex h-8 w-full cursor-default items-center gap-3 px-1", + isDraggable && "[&::-webkit-drag-ghost]:opacity-0", + className, + )} + draggable={isDraggable} + onDragStart={isDraggable ? handleDragStart : undefined} + onDragEnd={isDraggable ? handleDragEnd : undefined} + > + <div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]"> + {preview} + </div> + <span className="w-full flex-1 truncate text-sm text-left">{name}</span> + </button> + </div> + )} + + {isDraggable && + isDragging && + typeof document !== "undefined" && + createPortal( + <div + className="pointer-events-none fixed z-9999" + style={{ + left: dragPosition.x - 40, + top: dragPosition.y - 40, + }} + > + <div className="w-[80px]"> + <AspectRatio + ratio={1} + className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3" + > + <div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover"> + {preview} + </div> + {shouldShowPlusOnDrag && ( + <PlusButton + onClick={handleAddToTimeline} + tooltipText="Add to timeline or drag to position" + /> + )} + </AspectRatio> + </div> + </div>, + document.body, + )} + </> + ); +} + +function PlusButton({ + className, + onClick, + tooltipText, +}: { + className?: string; + onClick?: () => void; + tooltipText?: string; +}) { + const button = ( + <Button + size="icon" + className={cn( + "bg-background hover:bg-panel text-foreground absolute right-2 bottom-2 size-5", + className, + )} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onClick?.(); + }} + title={tooltipText} + > + <Plus /> + </Button> + ); + + if (tooltipText) { + return ( + <Tooltip> + <TooltipTrigger asChild>{button}</TooltipTrigger> + <TooltipContent> + <p>{tooltipText}</p> + </TooltipContent> + </Tooltip> + ); + } + + return button; +} diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx new file mode 100644 index 00000000..9161cb1d --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/index.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Separator } from "@/components/ui/separator"; +import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store"; +import { TabBar } from "./tabbar"; +import { Captions } from "./views/captions"; +import { MediaView } from "./views/media"; +import { SettingsView } from "./views/settings"; +import { SoundsView } from "./views/sounds"; +import { StickersView } from "./views/stickers"; +import { TextView } from "./views/text"; + +export function AssetsPanel() { + const { activeTab } = useAssetsPanelStore(); + + const viewMap: Record<Tab, React.ReactNode> = { + media: <MediaView />, + sounds: <SoundsView />, + text: <TextView />, + stickers: <StickersView />, + effects: ( + <div className="text-muted-foreground p-4"> + Effects view coming soon... + </div> + ), + transitions: ( + <div className="text-muted-foreground p-4"> + Transitions view coming soon... + </div> + ), + captions: <Captions />, + filters: ( + <div className="text-muted-foreground p-4"> + Filters view coming soon... + </div> + ), + adjustment: ( + <div className="text-muted-foreground p-4"> + Adjustment view coming soon... + </div> + ), + settings: <SettingsView />, + }; + + return ( + <div className="bg-panel flex h-full"> + <TabBar /> + <Separator orientation="vertical" /> + <div className="flex-1 overflow-hidden">{viewMap[activeTab]}</div> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx new file mode 100644 index 00000000..2c950fdc --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/utils/ui"; +import { + TAB_KEYS, + tabs, + useAssetsPanelStore, +} from "@/stores/assets-panel-store"; + +export function TabBar() { + const { activeTab, setActiveTab } = useAssetsPanelStore(); + const scrollRef = useRef<HTMLDivElement>(null); + const [showTopFade, setShowTopFade] = useState(false); + const [showBottomFade, setShowBottomFade] = useState(false); + + const checkScrollPosition = useCallback(() => { + 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(); + }; + }, [checkScrollPosition]); + + return ( + <div className="relative flex"> + <div + ref={scrollRef} + className="scrollbar-hidden relative flex size-full flex-col items-center justify-start gap-5 overflow-y-auto px-4 py-4" + > + {TAB_KEYS.map((tabKey) => { + const tab = tabs[tabKey]; + return ( + <Tooltip key={tabKey} delayDuration={10}> + <TooltipTrigger asChild> + <button + type="button" + aria-label={tab.label} + className={cn( + "flex cursor-pointer flex-col items-center gap-0.5 [&>svg]:size-4.5! opacity-100 hover:opacity-75", + activeTab === tabKey + ? "text-primary !opacity-100" + : "text-muted-foreground", + )} + onClick={() => setActiveTab(tabKey)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setActiveTab(tabKey); + } + }} + > + <tab.icon className=" " /> + </button> + </TooltipTrigger> + <TooltipContent + side="right" + align="center" + variant="sidebar" + sideOffset={8} + > + <div className="text-foreground text-sm leading-none font-medium"> + {tab.label} + </div> + </TooltipContent> + </Tooltip> + ); + })} + </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( + "pointer-events-none absolute right-0 left-0 h-6", + direction === "top" && show + ? "from-panel top-0 bg-gradient-to-b to-transparent" + : "from-panel bottom-0 bg-gradient-to-t to-transparent", + )} + /> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx new file mode 100644 index 00000000..306bad2a --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx @@ -0,0 +1,152 @@ +import { Button } from "@/components/ui/button"; +import { PropertyGroup } from "@/components/editor/panels/properties/property-item"; +import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useState, useRef } from "react"; +import { extractTimelineAudio } from "@/lib/media/mediabunny"; +import { useEditor } from "@/hooks/use-editor"; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; +import { TRANSCRIPTION_LANGUAGES } from "@/constants/transcription-constants"; +import type { + TranscriptionLanguage, + TranscriptionProgress, +} from "@/types/transcription"; +import { transcriptionService } from "@/services/transcription"; +import { decodeAudioToFloat32 } from "@/lib/media/audio"; +import { buildCaptionChunks } from "@/lib/transcription/caption"; +import { Spinner } from "@/components/ui/spinner"; + +export function Captions() { + const [selectedLanguage, setSelectedLanguage] = + useState<TranscriptionLanguage>("auto"); + const [isProcessing, setIsProcessing] = useState(false); + const [processingStep, setProcessingStep] = useState(""); + const [error, setError] = useState<string | null>(null); + const containerRef = useRef<HTMLDivElement>(null); + const editor = useEditor(); + + const handleProgress = (progress: TranscriptionProgress) => { + if (progress.status === "loading-model") { + setProcessingStep(`Loading model ${Math.round(progress.progress)}%`); + } else if (progress.status === "transcribing") { + setProcessingStep("Transcribing..."); + } + }; + + const handleGenerateTranscript = async () => { + try { + setIsProcessing(true); + setError(null); + setProcessingStep("Extracting audio..."); + + const audioBlob = await extractTimelineAudio({ + tracks: editor.timeline.getTracks(), + mediaAssets: editor.media.getAssets(), + totalDuration: editor.timeline.getTotalDuration(), + }); + + setProcessingStep("Preparing audio..."); + const { samples } = await decodeAudioToFloat32({ audioBlob }); + + const result = await transcriptionService.transcribe({ + audioData: samples, + language: selectedLanguage === "auto" ? undefined : selectedLanguage, + onProgress: handleProgress, + }); + + setProcessingStep("Generating captions..."); + const captionChunks = buildCaptionChunks({ segments: result.segments }); + + const captionTrackId = editor.timeline.addTrack({ + type: "text", + index: 0, + }); + + for (let i = 0; i < captionChunks.length; i++) { + const caption = captionChunks[i]; + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId: captionTrackId }, + element: { + ...DEFAULT_TEXT_ELEMENT, + name: `Caption ${i + 1}`, + content: caption.text, + duration: caption.duration, + startTime: caption.startTime, + fontSize: 65, + fontWeight: "bold", + }, + }); + } + } catch (error) { + console.error("Transcription failed:", error); + setError( + error instanceof Error ? error.message : "An unexpected error occurred", + ); + } finally { + setIsProcessing(false); + setProcessingStep(""); + } + }; + + const handleLanguageChange = ({ value }: { value: string }) => { + if (value === "auto") { + setSelectedLanguage("auto"); + return; + } + + const matchedLanguage = TRANSCRIPTION_LANGUAGES.find( + (language) => language.code === value, + ); + if (!matchedLanguage) return; + setSelectedLanguage(matchedLanguage.code); + }; + + return ( + <BaseView + ref={containerRef} + className="flex h-full flex-col justify-between" + > + <PropertyGroup title="Language"> + <Select + value={selectedLanguage} + onValueChange={(value) => handleLanguageChange({ value })} + > + <SelectTrigger className="bg-panel-accent h-8 w-full text-xs"> + <SelectValue placeholder="Select a language" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="auto">Auto detect</SelectItem> + {TRANSCRIPTION_LANGUAGES.map((language) => ( + <SelectItem key={language.code} value={language.code}> + {language.name} + </SelectItem> + ))} + </SelectContent> + </Select> + </PropertyGroup> + + <div className="flex flex-col gap-4"> + {error && ( + <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3"> + <p className="text-destructive text-sm">{error}</p> + </div> + )} + + <Button + className="w-full" + onClick={handleGenerateTranscript} + disabled={isProcessing} + > + {isProcessing && <Spinner className="mr-1" />} + {isProcessing ? processingStep : "Generate transcript"} + </Button> + </div> + </BaseView> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/media.tsx b/apps/web/src/components/editor/panels/assets/views/media.tsx new file mode 100644 index 00000000..76ae0064 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/media.tsx @@ -0,0 +1,675 @@ +"use client"; + +import Image from "next/image"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay"; +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { Button } from "@/components/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useEditor } from "@/hooks/use-editor"; +import { useFileUpload } from "@/hooks/use-file-upload"; +import { useRevealItem } from "@/hooks/use-reveal-item"; +import { processMediaAssets } from "@/lib/media/processing"; +import { + buildImageElement, + buildUploadAudioElement, + buildVideoElement, +} from "@/lib/timeline/element-utils"; +import { useAssetsPanelStore } from "@/stores/assets-panel-store"; +import type { MediaAsset } from "@/types/assets"; +import type { CreateTimelineElement } from "@/types/timeline"; +import { cn } from "@/utils/ui"; +import { + CloudUploadIcon, + GridViewIcon, + LeftToRightListDashIcon, + SortingOneNineIcon, + Image02Icon, + MusicNote03Icon, + Video01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; + +export function MediaView() { + const editor = useEditor(); + const mediaFiles = editor.media.getAssets(); + const activeProject = editor.project.getActive(); + + const { mediaViewMode, setMediaViewMode, highlightMediaId, clearHighlight } = + useAssetsPanelStore(); + const { highlightedId, registerElement } = useRevealItem( + highlightMediaId, + clearHighlight, + ); + + 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 processFiles = async ({ files }: { files: FileList }) => { + if (!files || files.length === 0) return; + if (!activeProject) { + toast.error("No active project"); + return; + } + + setIsProcessing(true); + setProgress(0); + try { + const processedAssets = await processMediaAssets({ + files, + onProgress: (progress: { progress: number }) => + setProgress(progress.progress), + }); + for (const asset of processedAssets) { + await editor.media.addMediaAsset({ + projectId: activeProject.metadata.id, + asset, + }); + } + } catch (error) { + console.error("Error processing files:", error); + toast.error("Failed to process files"); + } finally { + setIsProcessing(false); + setProgress(0); + } + }; + + const { isDragOver, dragProps, openFilePicker, fileInputProps } = + useFileUpload({ + accept: "image/*,video/*,audio/*", + multiple: true, + onFilesSelected: (files) => processFiles({ files }), + }); + + const handleRemove = async ({ + event, + id, + }: { + event: React.MouseEvent; + id: string; + }) => { + event.stopPropagation(); + + if (!activeProject) { + toast.error("No active project"); + return; + } + + await editor.media.removeMediaAsset({ + projectId: activeProject.metadata.id, + id, + }); + }; + + const addElementAtTime = ({ + asset, + startTime, + }: { + asset: MediaAsset; + startTime: number; + }): boolean => { + const element = createElementFromMedia({ asset, startTime }); + editor.timeline.insertElement({ + element, + placement: { mode: "auto" }, + }); + return true; + }; + + const filteredMediaItems = useMemo(() => { + const filtered = mediaFiles.filter((item) => !item.ephemeral); + + 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) => { + previews.set(item.id, <MediaPreview item={item} />); + previews.set( + `compact-${item.id}`, + <MediaPreview item={item} variant="compact" />, + ); + }); + + return previews; + }, [filteredMediaItems]); + + const renderPreview = (item: MediaAsset) => previewComponents.get(item.id); + const renderCompactPreview = (item: MediaAsset) => + previewComponents.get(`compact-${item.id}`); + + return ( + <> + <input {...fileInputProps} /> + + <div + className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`} + {...dragProps} + > + <div className="bg-panel py-2 px-4 flex items-center justify-between border-b"> + <span className="text-muted-foreground text-sm">Assets</span> + <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="items-center justify-center" + > + {mediaViewMode === "grid" ? ( + <HugeiconsIcon icon={LeftToRightListDashIcon} /> + ) : ( + <HugeiconsIcon icon={GridViewIcon} /> + )} + </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="items-center justify-center" + > + <HugeiconsIcon icon={SortingOneNineIcon} /> + </Button> + </DropdownMenuTrigger> + </TooltipTrigger> + <DropdownMenuContent align="end"> + <SortMenuItem + label="Name" + sortKey="name" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={({ key }) => { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + <SortMenuItem + label="Type" + sortKey="type" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={({ key }) => { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + <SortMenuItem + label="Duration" + sortKey="duration" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={({ key }) => { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + <SortMenuItem + label="File size" + sortKey="size" + currentSortBy={sortBy} + currentSortOrder={sortOrder} + onSort={({ key }) => { + if (sortBy === key) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(key); + setSortOrder("asc"); + } + }} + /> + </DropdownMenuContent> + </DropdownMenu> + <TooltipContent> + <p> + Sort by {sortBy} ( + {sortOrder === "asc" ? "ascending" : "descending"}) + </p> + </TooltipContent> + </Tooltip> + </Tooltip> + </TooltipProvider> + <Button + variant="outline" + onClick={openFilePicker} + disabled={isProcessing} + size="sm" + className="items-center justify-center gap-1.5 ml-1.5" + > + <HugeiconsIcon icon={CloudUploadIcon} /> + Upload + </Button> + </div> + </div> + + <div className="scrollbar-thin size-full overflow-y-auto pt-1"> + <div className="w-full flex-1 p-3 pt-0"> + {isDragOver || filteredMediaItems.length === 0 ? ( + <MediaDragOverlay + isVisible={true} + isProcessing={isProcessing} + progress={progress} + onClick={openFilePicker} + /> + ) : mediaViewMode === "grid" ? ( + <GridView + items={filteredMediaItems} + renderPreview={renderPreview} + onRemove={handleRemove} + onAddToTimeline={addElementAtTime} + highlightedId={highlightedId} + registerElement={registerElement} + /> + ) : ( + <ListView + items={filteredMediaItems} + renderPreview={renderCompactPreview} + onRemove={handleRemove} + onAddToTimeline={addElementAtTime} + highlightedId={highlightedId} + registerElement={registerElement} + /> + )} + </div> + </div> + </div> + </> + ); +} + +function MediaItemWithContextMenu({ + item, + children, + onRemove, +}: { + item: MediaAsset; + children: React.ReactNode; + onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; +}) { + return ( + <ContextMenu> + <ContextMenuTrigger>{children}</ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem>Export clips</ContextMenuItem> + <ContextMenuItem + variant="destructive" + onClick={(event) => onRemove({ event, id: item.id })} + > + Delete + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ); +} + +function GridView({ + items, + renderPreview, + onRemove, + onAddToTimeline, + highlightedId, + registerElement, +}: { + items: MediaAsset[]; + renderPreview: (item: MediaAsset) => React.ReactNode; + onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; + onAddToTimeline: ({ + asset, + startTime, + }: { + asset: MediaAsset; + startTime: number; + }) => boolean; + highlightedId: string | null; + registerElement: (id: string, element: HTMLElement | null) => void; +}) { + return ( + <div + className="grid gap-2" + style={{ + gridTemplateColumns: "repeat(auto-fill, 160px)", + }} + > + {items.map((item) => ( + <div key={item.id} ref={(el) => registerElement(item.id, el)}> + <MediaItemWithContextMenu item={item} onRemove={onRemove}> + <DraggableItem + name={item.name} + preview={renderPreview(item)} + dragData={{ + id: item.id, + type: "media", + mediaType: item.type, + name: item.name, + }} + shouldShowPlusOnDrag={false} + onAddToTimeline={({ currentTime }) => + onAddToTimeline({ asset: item, startTime: currentTime }) + } + isRounded={false} + variant="card" + isHighlighted={highlightedId === item.id} + /> + </MediaItemWithContextMenu> + </div> + ))} + </div> + ); +} + +function ListView({ + items, + renderPreview, + onRemove, + onAddToTimeline, + highlightedId, + registerElement, +}: { + items: MediaAsset[]; + renderPreview: (item: MediaAsset) => React.ReactNode; + onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; + onAddToTimeline: ({ + asset, + startTime, + }: { + asset: MediaAsset; + startTime: number; + }) => boolean; + highlightedId: string | null; + registerElement: (id: string, element: HTMLElement | null) => void; +}) { + return ( + <div className="space-y-1"> + {items.map((item) => ( + <div key={item.id} ref={(el) => registerElement(item.id, el)}> + <MediaItemWithContextMenu item={item} onRemove={onRemove}> + <DraggableItem + name={item.name} + preview={renderPreview(item)} + dragData={{ + id: item.id, + type: "media", + mediaType: item.type, + name: item.name, + }} + shouldShowPlusOnDrag={false} + onAddToTimeline={({ currentTime }) => + onAddToTimeline({ asset: item, startTime: currentTime }) + } + variant="compact" + isHighlighted={highlightedId === item.id} + /> + </MediaItemWithContextMenu> + </div> + ))} + </div> + ); +} + +const formatDuration = ({ duration }: { duration: number }) => { + const min = Math.floor(duration / 60); + const sec = Math.floor(duration % 60); + return `${min}:${sec.toString().padStart(2, "0")}`; +}; + +function MediaDurationBadge({ duration }: { duration?: number }) { + if (!duration) return null; + + return ( + <div className="absolute right-1 bottom-1 rounded bg-black/70 px-1 text-xs text-white"> + {formatDuration({ duration })} + </div> + ); +} + +function MediaDurationLabel({ duration }: { duration?: number }) { + if (!duration) return null; + + return ( + <span className="text-xs opacity-70">{formatDuration({ duration })}</span> + ); +} + +function MediaTypePlaceholder({ + icon, + label, + duration, + variant, +}: { + icon: IconSvgElement; + label: string; + duration?: number; + variant: "muted" | "bordered"; +}) { + const iconClassName = cn("size-6", variant === "bordered" && "mb-1"); + + return ( + <div + className={cn( + "text-muted-foreground flex size-full flex-col items-center justify-center rounded", + variant === "muted" ? "bg-muted/30" : "border", + )} + > + <HugeiconsIcon icon={icon} className={iconClassName} /> + <span className="text-xs">{label}</span> + <MediaDurationLabel duration={duration} /> + </div> + ); +} + +function MediaPreview({ + item, + variant = "grid", +}: { + item: MediaAsset; + variant?: "grid" | "compact"; +}) { + const shouldShowDurationBadge = variant === "grid"; + + if (item.type === "image") { + return ( + <div className="relative flex size-full items-center justify-center"> + <Image + src={item.url ?? ""} + alt={item.name} + fill + sizes="100vw" + className="object-cover" + loading="lazy" + unoptimized + /> + </div> + ); + } + + if (item.type === "video") { + if (item.thumbnailUrl) { + return ( + <div className="relative size-full"> + <Image + src={item.thumbnailUrl} + alt={item.name} + fill + sizes="100vw" + className="rounded object-cover" + loading="lazy" + unoptimized + /> + {shouldShowDurationBadge ? ( + <MediaDurationBadge duration={item.duration} /> + ) : null} + </div> + ); + } + + return ( + <MediaTypePlaceholder + icon={Video01Icon} + label="Video" + duration={item.duration} + variant="muted" + /> + ); + } + + if (item.type === "audio") { + return ( + <MediaTypePlaceholder + icon={MusicNote03Icon} + label="Audio" + duration={item.duration} + variant="bordered" + /> + ); + } + + return ( + <MediaTypePlaceholder icon={Image02Icon} label="Unknown" variant="muted" /> + ); +} + +function SortMenuItem({ + label, + sortKey, + currentSortBy, + currentSortOrder, + onSort, +}: { + label: string; + sortKey: "name" | "type" | "duration" | "size"; + currentSortBy: string; + currentSortOrder: "asc" | "desc"; + onSort: ({ key }: { key: "name" | "type" | "duration" | "size" }) => void; +}) { + const isActive = currentSortBy === sortKey; + const arrow = isActive ? (currentSortOrder === "asc" ? "↑" : "↓") : ""; + + return ( + <DropdownMenuItem onClick={() => onSort({ key: sortKey })}> + {label} {arrow} + </DropdownMenuItem> + ); +} + +function createElementFromMedia({ + asset, + startTime, +}: { + asset: MediaAsset; + startTime: number; +}): CreateTimelineElement { + const duration = + asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION; + + switch (asset.type) { + case "video": + return buildVideoElement({ + mediaId: asset.id, + name: asset.name, + duration, + startTime, + }); + case "image": + return buildImageElement({ + mediaId: asset.id, + name: asset.name, + duration, + startTime, + }); + case "audio": + return buildUploadAudioElement({ + mediaId: asset.id, + name: asset.name, + duration, + startTime, + }); + default: + throw new Error(`Unsupported media type: ${asset.type}`); + } +} diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx new file mode 100644 index 00000000..b8c54510 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx @@ -0,0 +1,394 @@ +"use client"; + +import Image from "next/image"; +import { memo, useCallback, useMemo } from "react"; +import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + BLUR_INTENSITY_PRESETS, + DEFAULT_BLUR_INTENSITY, + DEFAULT_COLOR, + FPS_PRESETS, +} from "@/constants/project-constants"; +import { patternCraftGradients } from "@/data/colors/pattern-craft"; +import { colors } from "@/data/colors/solid"; +import { syntaxUIGradients } from "@/data/colors/syntax-ui"; +import { useEditor } from "@/hooks/use-editor"; +import { useEditorStore } from "@/stores/editor-store"; +import type { TProject } from "@/types/project"; +import { dimensionToAspectRatio } from "@/utils/geometry"; +import { cn } from "@/utils/ui"; +import { + PropertyGroup, + PropertyItem, + PropertyItemLabel, + PropertyItemValue, +} from "@/components/editor/panels/properties/property-item"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { DropperIcon } from "@hugeicons/core-free-icons"; + +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 h-full flex-col justify-between"> + <div className="flex-1 p-5"> + <BackgroundView /> + </div> + {/* <div className="bg-panel/85 sticky -bottom-0 flex flex-col backdrop-blur-lg"> + <Separator /> + <Button className="text-muted-foreground hover:text-foreground/85 h-auto w-fit !bg-transparent p-5 py-4 text-xs shadow-none"> + Custom background + <PlusIcon /> + </Button> + </div> */} + + {/* another ui */} + {/* <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 h-full flex-col justify-between p-0" + /> + ); +} + +function getCurrentCanvasSize({ activeProject }: { activeProject: TProject }) { + const { canvasSize } = activeProject.settings; + + return { + width: canvasSize.width, + height: canvasSize.height, + }; +} + +function ProjectInfoView() { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + const { canvasPresets } = useEditorStore(); + + const findPresetIndexByAspectRatio = ({ + presets, + targetAspectRatio, + }: { + presets: Array<{ width: number; height: number }>; + targetAspectRatio: string; + }) => { + for (let index = 0; index < presets.length; index++) { + const preset = presets[index]; + const presetAspectRatio = dimensionToAspectRatio({ + width: preset.width, + height: preset.height, + }); + if (presetAspectRatio === targetAspectRatio) { + return index; + } + } + return -1; + }; + + const currentCanvasSize = getCurrentCanvasSize({ activeProject }); + const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize); + const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null; + const presetIndex = findPresetIndexByAspectRatio({ + presets: canvasPresets, + targetAspectRatio: currentAspectRatio, + }); + const originalPresetValue = "original"; + const selectedPresetValue = + presetIndex !== -1 ? presetIndex.toString() : originalPresetValue; + + const handleAspectRatioChange = ({ value }: { value: string }) => { + if (value === originalPresetValue) { + const canvasSize = originalCanvasSize ?? currentCanvasSize; + editor.project.updateSettings({ + settings: { canvasSize }, + }); + return; + } + const index = parseInt(value, 10); + const preset = canvasPresets[index]; + if (preset) { + editor.project.updateSettings({ settings: { canvasSize: preset } }); + } + }; + + const handleFpsChange = (value: string) => { + const fps = parseFloat(value); + editor.project.updateSettings({ settings: { fps } }); + }; + + return ( + <div className="flex flex-col gap-4"> + <PropertyItem direction="column"> + <PropertyItemLabel>Name</PropertyItemLabel> + <PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue> + </PropertyItem> + + <PropertyItem direction="column"> + <PropertyItemLabel>Aspect ratio</PropertyItemLabel> + <PropertyItemValue> + <Select + value={selectedPresetValue} + onValueChange={(value) => handleAspectRatioChange({ value })} + > + <SelectTrigger className="bg-panel-accent"> + <SelectValue placeholder="Select an aspect ratio" /> + </SelectTrigger> + <SelectContent> + <SelectItem value={originalPresetValue}>Original</SelectItem> + {canvasPresets.map((preset, index) => { + const label = dimensionToAspectRatio({ + width: preset.width, + height: preset.height, + }); + return ( + <SelectItem key={label} value={index.toString()}> + {label} + </SelectItem> + ); + })} + </SelectContent> + </Select> + </PropertyItemValue> + </PropertyItem> + + <PropertyItem direction="column"> + <PropertyItemLabel>Frame rate</PropertyItemLabel> + <PropertyItemValue> + <Select + value={activeProject.settings.fps.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; + }) => ( + <button + className={cn( + "border-foreground/15 hover:border-primary relative aspect-square w-full cursor-pointer overflow-hidden rounded-sm border", + isSelected && "border-primary border-2", + )} + onClick={onSelect} + type="button" + aria-label={`Select ${blur.label} blur`} + > + <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 right-1 bottom-1 left-1 text-center"> + <span className="rounded bg-black/50 px-1 text-xs text-white"> + {blur.label} + </span> + </div> + </button> + ), +); + +BlurPreview.displayName = "BlurPreview"; + +const BackgroundPreviews = memo( + ({ + backgrounds, + currentBackgroundColor, + isColorBackground, + handleColorSelect, + useBackgroundColor = false, + }: { + backgrounds: string[]; + currentBackgroundColor: string; + isColorBackground: boolean; + handleColorSelect: ({ bg }: { bg: string }) => void; + useBackgroundColor?: boolean; + }) => { + return useMemo( + () => + backgrounds.map((bg, index) => ( + <button + key={`${index}-${bg}`} + className={cn( + "border-foreground/15 hover:border-primary aspect-square w-full cursor-pointer rounded-sm border", + isColorBackground && + bg === currentBackgroundColor && + "border-primary border-2", + )} + style={ + useBackgroundColor + ? { backgroundColor: bg } + : { + background: bg, + backgroundSize: "cover", + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + } + } + onClick={() => handleColorSelect({ bg })} + type="button" + aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`} + /> + )), + [ + backgrounds, + isColorBackground, + currentBackgroundColor, + handleColorSelect, + useBackgroundColor, + ], + ); + }, +); + +BackgroundPreviews.displayName = "BackgroundPreviews"; + +function BackgroundView() { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []); + + const handleBlurSelect = useCallback( + async ({ blurIntensity }: { blurIntensity: number }) => { + await editor.project.updateSettings({ + settings: { background: { type: "blur", blurIntensity } }, + }); + }, + [editor.project], + ); + + const handleColorSelect = useCallback( + async ({ color }: { color: string }) => { + await editor.project.updateSettings({ + settings: { background: { type: "color", color } }, + }); + }, + [editor.project], + ); + + const currentBlurIntensity = + activeProject.settings.background.type === "blur" + ? activeProject.settings.background.blurIntensity + : DEFAULT_BLUR_INTENSITY; + + const currentBackgroundColor = + activeProject.settings.background.type === "color" + ? activeProject.settings.background.color + : DEFAULT_COLOR; + + const isBlurBackground = activeProject.settings.background.type === "blur"; + const isColorBackground = activeProject.settings.background.type === "color"; + + const blurPreviews = useMemo( + () => + blurLevels.map((blur) => ( + <BlurPreview + key={blur.value} + blur={blur} + isSelected={isBlurBackground && currentBlurIntensity === blur.value} + onSelect={() => handleBlurSelect({ blurIntensity: blur.value })} + /> + )), + [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect], + ); + + return ( + <div className="flex h-full flex-col gap-4"> + <PropertyGroup title="Blur" defaultExpanded={false}> + <div className="grid w-full grid-cols-4 gap-2">{blurPreviews}</div> + </PropertyGroup> + + <PropertyGroup title="Colors" defaultExpanded={false}> + <div className="grid w-full grid-cols-4 gap-2"> + <div className="border-foreground/15 hover:border-primary flex aspect-square w-full cursor-pointer items-center justify-center rounded-sm border"> + <HugeiconsIcon icon={DropperIcon} className="size-4" /> + </div> + <BackgroundPreviews + backgrounds={colors} + currentBackgroundColor={currentBackgroundColor} + isColorBackground={isColorBackground} + handleColorSelect={({ bg }) => handleColorSelect({ color: bg })} + useBackgroundColor={true} + /> + </div> + </PropertyGroup> + + <PropertyGroup title="Pattern craft" defaultExpanded={false}> + <div className="grid w-full grid-cols-4 gap-2"> + <BackgroundPreviews + backgrounds={patternCraftGradients} + currentBackgroundColor={currentBackgroundColor} + isColorBackground={isColorBackground} + handleColorSelect={({ bg }) => handleColorSelect({ color: bg })} + /> + </div> + </PropertyGroup> + + <PropertyGroup title="Syntax UI" defaultExpanded={false}> + <div className="grid w-full grid-cols-4 gap-2"> + <BackgroundPreviews + backgrounds={syntaxUIGradients} + currentBackgroundColor={currentBackgroundColor} + isColorBackground={isColorBackground} + handleColorSelect={({ bg }) => handleColorSelect({ color: bg })} + /> + </div> + </PropertyGroup> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx new file mode 100644 index 00000000..84a5d0bf --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx @@ -0,0 +1,574 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; +import { useSoundSearch } from "@/hooks/use-sound-search"; +import { useSoundsStore } from "@/stores/sounds-store"; +import type { SavedSound, SoundEffect } from "@/types/sounds"; +import { cn } from "@/utils/ui"; +import { + FavouriteIcon, + FilterMailIcon, + PauseIcon, + PlayIcon, + PlusSignIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +export function SoundsView() { + return ( + <div className="flex h-full flex-col"> + <Tabs defaultValue="sound-effects" className="flex h-full flex-col"> + <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="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0" + > + <SoundEffectsView /> + </TabsContent> + <TabsContent + value="saved" + className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0" + > + <SavedSoundsView /> + </TabsContent> + <TabsContent + value="songs" + className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0" + > + <SongsView /> + </TabsContent> + </Tabs> + </div> + ); +} + +function SoundEffectsView() { + const { + topSoundEffects, + isLoading, + searchQuery, + setSearchQuery, + scrollPosition, + setScrollPosition, + loadSavedSounds, + showCommercialOnly, + toggleCommercialFilter, + hasLoaded, + setTopSoundEffects, + setLoading, + setError, + setHasLoaded, + setCurrentPage, + setHasNextPage, + setTotalCount, + } = useSoundsStore(); + const { + results: searchResults, + isLoading: isSearching, + loadMore, + hasNextPage, + isLoadingMore, + } = useSoundSearch({ + query: searchQuery, + commercialOnly: showCommercialOnly, + }); + + 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(); + }, [loadSavedSounds]); + + useEffect(() => { + if (hasLoaded) { + return; + } + + let shouldIgnore = false; + + const fetchTopSounds = async () => { + try { + if (!shouldIgnore) { + setLoading({ loading: true }); + setError({ error: null }); + } + + const response = await fetch( + "/api/sounds/search?page_size=50&sort=downloads", + ); + + if (!shouldIgnore) { + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status}`); + } + + const data = await response.json(); + setTopSoundEffects({ sounds: data.results }); + setHasLoaded({ loaded: true }); + + setCurrentPage({ page: 1 }); + setHasNextPage({ hasNext: !!data.next }); + setTotalCount({ count: data.count }); + } + } catch (error) { + if (!shouldIgnore) { + console.error("Failed to fetch top sounds:", error); + setError({ + error: + error instanceof Error ? error.message : "Failed to load sounds", + }); + } + } finally { + if (!shouldIgnore) { + setLoading({ loading: false }); + } + } + }; + + const timeoutId = setTimeout(fetchTopSounds, 100, {}); + + return () => { + shouldIgnore = true; + clearTimeout(timeoutId); + }; + }, [ + hasLoaded, + setTopSoundEffects, + setLoading, + setError, + setHasLoaded, + setCurrentPage, + setHasNextPage, + setTotalCount, + ]); + + useEffect(() => { + if (!scrollAreaRef.current || scrollPosition <= 0) { + return; + } + + const restoreScrollPosition = () => { + scrollAreaRef.current?.scrollTo({ top: scrollPosition }); + }; + + const timeoutId = setTimeout(restoreScrollPosition, 100, {}); + + return () => clearTimeout(timeoutId); + }, [scrollPosition, scrollAreaRef]); + + const handleScrollWithPosition = ({ + currentTarget, + }: React.UIEvent<HTMLDivElement>) => { + const { scrollTop } = currentTarget; + setScrollPosition({ position: scrollTop }); + handleScroll({ currentTarget } as React.UIEvent<HTMLDivElement>); + }; + + const displayedSounds = searchQuery ? searchResults : topSoundEffects; + + const playSound = ({ sound }: { sound: SoundEffect }) => { + if (playingId === sound.id) { + audioElement?.pause(); + setPlayingId(null); + return; + } + + audioElement?.pause(); + + if (sound.previewUrl) { + const audio = new Audio(sound.previewUrl); + audio.addEventListener("ended", () => { + setPlayingId(null); + }); + audio.addEventListener("error", () => { + setPlayingId(null); + }); + audio.play().catch((error) => { + console.error("Failed to play sound preview:", error); + setPlayingId(null); + }); + + setAudioElement(audio); + setPlayingId(sound.id); + } + }; + + return ( + <div className="mt-1 flex h-full flex-col gap-5"> + <div className="flex items-center gap-3"> + <Input + placeholder="Search sound effects" + className="bg-panel-accent w-full" + containerClassName="w-full" + value={searchQuery} + onChange={({ currentTarget }) => + setSearchQuery({ query: currentTarget.value }) + } + showClearIcon + onClear={() => setSearchQuery({ query: "" })} + /> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="text" + size="icon" + className={cn(showCommercialOnly && "text-primary")} + > + <HugeiconsIcon icon={FilterMailIcon} /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + <DropdownMenuCheckboxItem + checked={showCommercialOnly} + onCheckedChange={() => toggleCommercialFilter()} + > + Show only commercially licensed + </DropdownMenuCheckboxItem> + <div className="text-muted-foreground px-2 py-1.5 text-xs"> + {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="h-full flex-1" + 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} + /> + ))} + {!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 py-4 text-center text-sm"> + Loading more sounds... + </div> + )} + </div> + </ScrollArea> + </div> + </div> + ); +} + +function SavedSoundsView() { + const { + savedSounds, + isLoadingSavedSounds, + savedSoundsError, + loadSavedSounds, + clearSavedSounds, + } = useSoundsStore(); + + const [playingId, setPlayingId] = useState<number | null>(null); + const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>( + null, + ); + + const [showClearDialog, setShowClearDialog] = useState(false); + + useEffect(() => { + loadSavedSounds(); + }, [loadSavedSounds]); + + const playSound = ({ sound }: { sound: SoundEffect }) => { + if (playingId === sound.id) { + audioElement?.pause(); + setPlayingId(null); + return; + } + + audioElement?.pause(); + + if (sound.previewUrl) { + const audio = new Audio(sound.previewUrl); + audio.addEventListener("ended", () => { + setPlayingId(null); + }); + audio.addEventListener("error", () => { + setPlayingId(null); + }); + audio.play().catch((error) => { + console.error("Failed to play sound preview:", error); + setPlayingId(null); + }); + + setAudioElement(audio); + setPlayingId(sound.id); + } + }; + + const convertToSoundEffect = ({ + savedSound, + }: { + 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 h-full items-center justify-center"> + <div className="text-muted-foreground text-sm"> + Loading saved sounds... + </div> + </div> + ); + } + + if (savedSoundsError) { + return ( + <div className="flex h-full items-center justify-center"> + <div className="text-destructive text-sm"> + Error: {savedSoundsError} + </div> + </div> + ); + } + + if (savedSounds.length === 0) { + return ( + <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4"> + <HugeiconsIcon + icon={FavouriteIcon} + className="text-muted-foreground size-10" + /> + <div className="flex flex-col gap-2 text-center"> + <p className="text-lg font-medium">No saved sounds</p> + <p className="text-muted-foreground text-sm text-balance"> + Click the heart icon on any sound to save it here + </p> + </div> + </div> + ); + } + + return ( + <div className="mt-1 flex h-full flex-col gap-5"> + <div className="flex items-center justify-between"> + <p className="text-muted-foreground text-sm"> + {savedSounds.length} saved{" "} + {savedSounds.length === 1 ? "sound" : "sounds"} + </p> + <Dialog open={showClearDialog} onOpenChange={setShowClearDialog}> + <DialogTrigger asChild> + <Button + variant="text" + size="sm" + className="text-muted-foreground hover:text-destructive h-auto !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 ({ + stopPropagation, + }: React.MouseEvent<HTMLButtonElement>) => { + stopPropagation(); + await clearSavedSounds(); + setShowClearDialog(false); + }} + > + Clear all sounds + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </div> + + <div className="relative h-full overflow-hidden"> + <ScrollArea className="h-full flex-1"> + <div className="flex flex-col gap-4"> + {savedSounds.map((sound) => ( + <AudioItem + key={sound.id} + sound={convertToSoundEffect({ savedSound: sound })} + isPlaying={playingId === sound.id} + onPlay={playSound} + /> + ))} + </div> + </ScrollArea> + </div> + </div> + ); +} + +function SongsView() { + return <div>Songs</div>; +} + +interface AudioItemProps { + sound: SoundEffect; + isPlaying: boolean; + onPlay: ({ sound }: { sound: SoundEffect }) => void; +} + +function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) { + const { addSoundToTimeline, isSoundSaved, toggleSavedSound } = + useSoundsStore(); + const isSaved = isSoundSaved({ soundId: sound.id }); + + const handleClick = () => { + onPlay({ sound }); + }; + + const handleSaveClick = ({ + stopPropagation, + }: React.MouseEvent<HTMLButtonElement>) => { + stopPropagation(); + toggleSavedSound({ soundEffect: sound }); + }; + + const handleAddToTimeline = async ({ + stopPropagation, + }: React.MouseEvent<HTMLButtonElement>) => { + stopPropagation(); + await addSoundToTimeline({ sound }); + }; + + return ( + <div className="group flex items-center gap-3 opacity-100 hover:opacity-75"> + <button + type="button" + className="flex min-w-0 flex-1 items-center gap-3 text-left" + onClick={handleClick} + > + <div className="bg-accent relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-md"> + <div className="from-primary/20 absolute inset-0 bg-gradient-to-br to-transparent" /> + {isPlaying ? ( + <HugeiconsIcon icon={PauseIcon} className="size-5" /> + ) : ( + <HugeiconsIcon icon={PlayIcon} className="size-5" /> + )} + </div> + + <div className="min-w-0 flex-1 overflow-hidden"> + <p className="truncate text-sm font-medium">{sound.name}</p> + <span className="text-muted-foreground block truncate text-xs"> + {sound.username} + </span> + </div> + </button> + + <div className="flex items-center gap-3 pr-2"> + <Button + variant="text" + size="icon" + className="text-muted-foreground hover:text-foreground w-auto !opacity-100" + onClick={handleAddToTimeline} + title="Add to timeline" + > + <HugeiconsIcon icon={PlusSignIcon} /> + </Button> + <Button + variant="text" + size="icon" + className={`hover:text-foreground w-auto !opacity-100 ${ + isSaved + ? "text-red-500 hover:text-red-600" + : "text-muted-foreground" + }`} + onClick={handleSaveClick} + title={isSaved ? "Remove from saved" : "Save sound"} + > + <HugeiconsIcon + icon={FavouriteIcon} + className={`${isSaved ? "fill-current" : ""}`} + /> + </Button> + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx new file mode 100644 index 00000000..6b48e775 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -0,0 +1,615 @@ +"use client"; + +import Image from "next/image"; +import type { CSSProperties } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { Button } from "@/components/ui/button"; +import { InputWithBack } from "@/components/ui/input-with-back"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { STICKER_CATEGORIES } from "@/constants/stickers-constants"; +import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; +import { + buildIconSvgUrl, + getIconSvgUrl, + ICONIFY_HOSTS, + POPULAR_COLLECTIONS, +} from "@/lib/iconify-api"; +import { useStickersStore } from "@/stores/stickers-store"; +import type { StickerCategory } from "@/types/stickers"; +import { cn } from "@/utils/ui"; +import { + ArrowRightIcon, + HappyIcon, + ClockIcon, + LayoutGridIcon, + MultiplicationSignIcon, + SparklesIcon, + HashtagIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Spinner } from "@/components/ui/spinner"; + +function isStickerCategory(value: string): value is StickerCategory { + return STICKER_CATEGORIES.includes(value as StickerCategory); +} + +export function StickersView() { + const { selectedCategory, setSelectedCategory } = useStickersStore(); + + return ( + <BaseView + value={selectedCategory} + onValueChange={(v) => { + if (isStickerCategory(v)) { + setSelectedCategory({ category: v }); + } + }} + tabs={[ + { + value: "all", + label: "All", + icon: <HugeiconsIcon icon={LayoutGridIcon} className="size-3" />, + content: <StickersContentView category="all" />, + }, + { + value: "general", + label: "Icons", + icon: <HugeiconsIcon icon={SparklesIcon} className="size-3" />, + content: <StickersContentView category="general" />, + }, + { + value: "brands", + label: "Brands", + icon: <HugeiconsIcon icon={HashtagIcon} className="size-3" />, + content: <StickersContentView category="brands" />, + }, + { + value: "emoji", + label: "Emoji", + icon: <HugeiconsIcon icon={HappyIcon} className="size-3" />, + content: <StickersContentView category="emoji" />, + }, + ]} + className="flex h-full flex-col overflow-hidden p-0" + /> + ); +} + +function StickerGrid({ + icons, + onAdd, + addingSticker, + capSize = false, +}: { + icons: string[]; + onAdd: (iconName: string) => void; + addingSticker: string | null; + capSize?: boolean; +}) { + const gridStyle: CSSProperties & { + "--sticker-min": string; + "--sticker-max"?: string; + } = { + 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": "96px", + ...(capSize ? { "--sticker-max": "160px" } : {}), + }; + + return ( + <div className="grid gap-2" style={gridStyle}> + {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 }: { 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({ prefix: collection.prefix })} + /> + ))} + </div> + ); +} + +function EmptyView({ message }: { message: string }) { + return ( + <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4"> + <HugeiconsIcon + icon={HappyIcon} + className="text-muted-foreground size-10" + /> + <div className="flex flex-col gap-2 text-center"> + <p className="text-lg font-medium">No stickers found</p> + <p className="text-muted-foreground text-sm text-balance">{message}</p> + </div> + </div> + ); +} + +function StickersContentView({ category }: { category: StickerCategory }) { + 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(); + } + }, [collections, loadCollections]); + + useEffect(() => { + const timer = setTimeout(() => { + if (localSearchQuery !== searchQuery) { + setSearchQuery({ query: localSearchQuery }); + if (localSearchQuery.trim()) { + searchStickers({ query: localSearchQuery }); + } + } + }, 500); + + return () => clearTimeout(timer); + }, [localSearchQuery, searchQuery, searchStickers, setSearchQuery]); + + 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="mt-1 flex h-full flex-col gap-5 p-4"> + <div className="space-y-3"> + <InputWithBack + isExpanded={isInCollection} + setIsExpanded={(expanded) => { + if (!expanded && isInCollection) { + setSelectedCollection({ collection: 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="h-full flex-1" + ref={scrollAreaRef} + onScrollCapture={handleScroll} + > + <div className="flex h-full flex-col gap-4"> + {recentStickers.length > 0 && viewMode === "browse" && ( + <div className="h-full"> + <div className="mb-2 flex items-center gap-2"> + <HugeiconsIcon + icon={ClockIcon} + className="text-muted-foreground size-4" + /> + <span className="text-sm font-medium">Recent</span> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <button + type="button" + onClick={clearRecentStickers} + className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0" + > + <HugeiconsIcon + icon={MultiplicationSignIcon} + className="text-muted-foreground size-3" + /> + </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"> + <Spinner className="text-muted-foreground size-6" /> + </div> + ) : showCollectionItems ? ( + <StickerGrid + icons={iconsToDisplay} + onAdd={handleAddSticker} + addingSticker={addingSticker} + /> + ) : ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground size-6" /> + </div> + )} + </div> + )} + + {viewMode === "search" && ( + <div className="h-full"> + {isSearching ? ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground size-6" /> + </div> + ) : searchResults?.icons.length ? ( + <> + <div className="mb-3 flex items-center justify-between"> + <span className="text-muted-foreground text-sm"> + {searchResults.total} results + </span> + </div> + <StickerGrid + icons={iconsToDisplay} + onAdd={handleAddSticker} + addingSticker={addingSticker} + capSize + /> + </> + ) : searchQuery ? ( + <div className="flex flex-col items-center justify-center gap-3 py-8"> + <EmptyView + message={`No stickers found for "${searchQuery}"`} + /> + {category !== "all" && ( + <Button + variant="outline" + onClick={() => { + const q = localSearchQuery || searchQuery; + if (q) { + setSearchQuery({ query: q }); + } + setSelectedCategory({ category: "all" }); + if (q) { + searchStickers({ query: q }); + } + }} + > + Search in all icons + </Button> + )} + </div> + ) : null} + </div> + )} + + {viewMode === "browse" && !selectedCollection && ( + <div className="h-full space-y-4"> + {isLoadingCollections ? ( + <div className="flex items-center justify-center py-8"> + <Spinner className="text-muted-foreground size-6" /> + </div> + ) : ( + <> + {category !== "all" && ( + <div className="h-full"> + <CollectionGrid + collections={filteredCollections} + onSelectCollection={({ prefix }) => + setSelectedCollection({ collection: prefix }) + } + /> + </div> + )} + + {category === "all" && filteredCollections.length > 0 && ( + <div className="h-full"> + <CollectionGrid + collections={filteredCollections.slice( + 0, + collectionsToShow, + )} + onSelectCollection={({ prefix }) => + setSelectedCollection({ collection: prefix }) + } + /> + </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="h-auto justify-between rounded-md py-2" + onClick={onClick} + > + <div className="text-left"> + <p className="font-medium">{title}</p> + <p className="text-muted-foreground text-xs">{subtitle}</p> + </div> + <HugeiconsIcon icon={ArrowRightIcon} className="size-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(() => { + if (!iconName) { + return; + } + setImageError(false); + setHostIndex(0); + }, [iconName]); + + const displayName = iconName.split(":")[1] || iconName; + const collectionPrefix = iconName.split(":")[0]; + + const preview = imageError ? ( + <div className="flex size-full items-center justify-center p-2"> + <span className="text-muted-foreground text-center text-xs break-all"> + {displayName} + </span> + </div> + ) : ( + <div className="flex size-full items-center justify-center p-4"> + <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="size-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 && "pointer-events-none opacity-50", + )} + > + <DraggableItem + name={displayName} + preview={preview} + dragData={{ + id: iconName, + type: "sticker", + name: displayName, + iconName, + }} + onAddToTimeline={() => onAdd(iconName)} + aspectRatio={1} + shouldShowLabel={false} + isRounded={true} + variant="card" + className="" + containerClassName="w-full" + /> + {isAdding && ( + <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60"> + <Spinner className="size-6 text-white" /> + </div> + )} + </div> + </TooltipTrigger> + <TooltipContent> + <div className="space-y-1"> + <p className="font-medium">{displayName}</p> + <p className="text-muted-foreground text-xs">{collectionPrefix}</p> + </div> + </TooltipContent> + </Tooltip> + ); +} diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx new file mode 100644 index 00000000..6ccf5d98 --- /dev/null +++ b/apps/web/src/components/editor/panels/assets/views/text.tsx @@ -0,0 +1,46 @@ +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; +import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view"; +import { useEditor } from "@/hooks/use-editor"; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; +import { buildTextElement } from "@/lib/timeline/element-utils"; + +export function TextView() { + const editor = useEditor(); + + const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => { + const activeScene = editor.scenes.getActiveScene(); + if (!activeScene) return; + + const element = buildTextElement({ + raw: DEFAULT_TEXT_ELEMENT, + startTime: currentTime, + }); + + editor.timeline.insertElement({ + element, + placement: { mode: "auto" }, + }); + }; + + return ( + <BaseView> + <DraggableItem + name="Default text" + preview={ + <div className="bg-panel-accent flex size-full items-center justify-center 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={handleAddToTimeline} + shouldShowLabel={false} + /> + </BaseView> + ); +} diff --git a/apps/web/src/components/editor/panels/panel-base-view.tsx b/apps/web/src/components/editor/panels/panel-base-view.tsx new file mode 100644 index 00000000..b16d1db6 --- /dev/null +++ b/apps/web/src/components/editor/panels/panel-base-view.tsx @@ -0,0 +1,85 @@ +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/utils/ui"; + +interface PanelBaseViewProps { + children?: React.ReactNode; + defaultTab?: string; + value?: string; + onValueChange?: (value: string) => void; + tabs?: { + value: string; + label: string; + icon?: React.ReactNode; + content: React.ReactNode; + }[]; + className?: string; + ref?: React.RefObject<HTMLDivElement>; +} + +function ViewContent({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + <ScrollArea className="flex-1"> + <div className={cn("p-5", className)}>{children}</div> + </ScrollArea> + ); +} + +export function PanelBaseView({ + children, + defaultTab, + value, + onValueChange, + tabs, + className = "", + ref, +}: PanelBaseViewProps) { + return ( + <div className={cn("flex h-full flex-col", className)} ref={ref}> + {!tabs || tabs.length === 0 ? ( + <ViewContent className={className}>{children}</ViewContent> + ) : ( + <Tabs + defaultValue={defaultTab} + value={value} + onValueChange={onValueChange} + className="flex h-full flex-col" + > + <div className="bg-panel sticky top-0 z-10"> + <div className="px-3 pt-3 pb-0"> + <TabsList> + {tabs.map((tab) => ( + <TabsTrigger key={tab.value} value={tab.value}> + {tab.icon ? ( + <span className="mr-1 inline-flex items-center"> + {tab.icon} + </span> + ) : null} + {tab.label} + </TabsTrigger> + ))} + </TabsList> + </div> + <Separator className="mt-3" /> + </div> + {tabs.map((tab) => ( + <TabsContent + key={tab.value} + value={tab.value} + className="mt-0 flex min-h-0 flex-1 flex-col" + > + <ViewContent className={className}>{tab.content}</ViewContent> + </TabsContent> + ))} + </Tabs> + )} + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx new file mode 100644 index 00000000..5ed9db00 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useCallback, useMemo, useRef } from "react"; +import useDeepCompareEffect from "use-deep-compare-effect"; +import { useEditor } from "@/hooks/use-editor"; +import { useRafLoop } from "@/hooks/use-raf-loop"; +import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; +import type { RootNode } from "@/services/renderer/nodes/root-node"; +import { buildScene } from "@/services/renderer/scene-builder"; +import { getLastFrameTime } from "@/lib/time"; + +function usePreviewSize() { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + + return { + width: activeProject?.settings.canvasSize.width, + height: activeProject?.settings.canvasSize.height, + }; +} + +function RenderTreeController() { + const editor = useEditor(); + const tracks = editor.timeline.getTracks(); + const mediaAssets = editor.media.getAssets(); + const activeProject = editor.project.getActive(); + + const { width, height } = usePreviewSize(); + + useDeepCompareEffect(() => { + if (!activeProject) return; + + const duration = editor.timeline.getTotalDuration(); + const renderTree = buildScene({ + tracks, + mediaAssets, + duration, + canvasSize: { width, height }, + background: activeProject.settings.background, + }); + + editor.renderer.setRenderTree({ renderTree }); + }, [tracks, mediaAssets, activeProject?.settings.background, width, height]); + + return null; +} + +export function PreviewPanel() { + return ( + <div className="bg-panel relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm"> + <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2"> + <PreviewCanvas /> + <RenderTreeController /> + </div> + </div> + ); +} + +function PreviewCanvas() { + const ref = useRef<HTMLCanvasElement>(null); + const lastFrameRef = useRef(-1); + const lastSceneRef = useRef<RootNode | null>(null); + const renderingRef = useRef(false); + const { width, height } = usePreviewSize(); + const editor = useEditor(); + const activeProject = editor.project.getActive(); + + const renderer = useMemo(() => { + return new CanvasRenderer({ + width, + height, + fps: activeProject.settings.fps, + }); + }, [width, height, activeProject.settings.fps]); + + const renderTree = editor.renderer.getRenderTree(); + + const render = useCallback(() => { + if (ref.current && renderTree && !renderingRef.current) { + const time = editor.playback.getCurrentTime(); + const lastFrameTime = getLastFrameTime({ + duration: renderTree.duration, + fps: renderer.fps, + }); + const renderTime = Math.min(time, lastFrameTime); + const frame = Math.floor(renderTime * renderer.fps); + + if ( + frame !== lastFrameRef.current || + renderTree !== lastSceneRef.current + ) { + renderingRef.current = true; + lastSceneRef.current = renderTree; + lastFrameRef.current = frame; + renderer + .renderToCanvas({ + node: renderTree, + time: renderTime, + targetCanvas: ref.current, + }) + .then(() => { + renderingRef.current = false; + }); + } + } + }, [renderer, renderTree, editor.playback]); + + useRafLoop(render); + + return ( + <canvas + ref={ref} + width={width} + height={height} + className="block max-h-full max-w-full border" + style={{ + background: + activeProject.settings.background.type === "blur" + ? "transparent" + : activeProject?.settings.background.color, + }} + /> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/audio-properties.tsx b/apps/web/src/components/editor/panels/properties/audio-properties.tsx new file mode 100644 index 00000000..0b83a4b6 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/audio-properties.tsx @@ -0,0 +1,5 @@ +import type { AudioElement } from "@/types/timeline"; + +export function AudioProperties({ _element }: { _element: AudioElement }) { + return <div className="space-y-4 p-5">Audio properties</div>; +} diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx new file mode 100644 index 00000000..3a552b93 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { ScrollArea } from "@/components/ui/scroll-area"; +import { AudioProperties } from "./audio-properties"; +import { VideoProperties } from "./video-properties"; +import { TextProperties } from "./text-properties"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Settings05Icon } from "@hugeicons/core-free-icons"; +import { useEditor } from "@/hooks/use-editor"; +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; + +export function PropertiesPanel() { + const editor = useEditor(); + const { selectedElements } = useElementSelection(); + + const elementsWithTracks = editor.timeline.getElementsWithTracks({ + elements: selectedElements, + }); + + return ( + <> + {selectedElements.length > 0 ? ( + <ScrollArea className="bg-panel h-full rounded-sm"> + {elementsWithTracks.map(({ track, element }) => { + if (element.type === "text") { + return ( + <div key={element.id}> + <TextProperties element={element} trackId={track.id} /> + </div> + ); + } + if (element.type === "audio") { + return <AudioProperties key={element.id} _element={element} />; + } + if (element.type === "video" || element.type === "image") { + return ( + <div key={element.id}> + <VideoProperties _element={element} /> + </div> + ); + } + return null; + })} + </ScrollArea> + ) : ( + <EmptyView /> + )} + </> + ); +} + +function EmptyView() { + return ( + <div className="bg-panel flex h-full flex-col items-center justify-center gap-3 p-4"> + <HugeiconsIcon + icon={Settings05Icon} + className="text-muted-foreground/75 size-10" + strokeWidth={1} + /> + <div className="flex flex-col gap-2 text-center"> + <p className="text-lg font-medium">It's empty here</p> + <p className="text-muted-foreground text-sm text-balance"> + Click an element on the timeline to edit its properties + </p> + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/property-item.tsx b/apps/web/src/components/editor/panels/properties/property-item.tsx new file mode 100644 index 00000000..6d2fe444 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/property-item.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { ArrowDownIcon } from "@hugeicons/core-free-icons"; + +interface PropertyItemProps { + direction?: "row" | "column"; + children: React.ReactNode; + className?: string; +} + +export function PropertyItem({ + direction = "row", + children, + className, +}: PropertyItemProps) { + return ( + <div + className={cn( + "flex gap-2", + direction === "row" + ? "items-center justify-between gap-6" + : "flex-col gap-1.5", + className, + )} + > + {children} + </div> + ); +} + +export function PropertyItemLabel({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + <span className={cn("text-muted-foreground text-xs", className)}> + {children} + </span> + ); +} + +export function PropertyItemValue({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return <div className={cn("flex-1 text-sm", className)}>{children}</div>; +} + +interface PropertyGroupProps { + title: string; + children: React.ReactNode; + defaultExpanded?: boolean; + className?: string; + titleClassName?: string; +} + +export function PropertyGroup({ + title, + children, + defaultExpanded = true, + className, + titleClassName, +}: PropertyGroupProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + return ( + <PropertyItem direction="column" className={cn("gap-3", className)}> + <button + type="button" + className="flex items-center gap-1.5 cursor-pointer" + onClick={() => setIsExpanded(!isExpanded)} + > + <PropertyItemLabel className={cn(titleClassName)}> + {title} + </PropertyItemLabel> + <HugeiconsIcon + icon={ArrowDownIcon} + className={cn("size-3", !isExpanded && "-rotate-90")} + /> + </button> + {isExpanded && <PropertyItemValue>{children}</PropertyItemValue>} + </PropertyItem> + ); +} diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx new file mode 100644 index 00000000..f8c9bf87 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -0,0 +1,410 @@ +import { Textarea } from "@/components/ui/textarea"; +import { FontPicker } from "@/components/ui/font-picker"; +import type { FontFamily } from "@/constants/font-constants"; +import type { TextElement } from "@/types/timeline"; +import { Slider } from "@/components/ui/slider"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useState, useRef } from "react"; +import { PanelBaseView } from "@/components/editor/panels/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 } from "@/utils/ui"; +import { capitalizeFirstLetter, uppercase } from "@/utils/string"; +import { clamp } from "@/utils/math"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { LayoutGridIcon } from "@hugeicons/core-free-icons"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useEditor } from "@/hooks/use-editor"; +import { DEFAULT_COLOR } from "@/constants/project-constants"; + +export function TextProperties({ + element, + trackId, +}: { + element: TextElement; + trackId: string; +}) { + const editor = useEditor(); + const { activeTab, setActiveTab } = useTextPropertiesStore(); + const containerRef = useRef<HTMLDivElement>(null); + const [fontSizeInput, setFontSizeInput] = useState( + element.fontSize.toString(), + ); + const [opacityInput, setOpacityInput] = useState( + Math.round(element.opacity * 100).toString(), + ); + + const lastSelectedColor = useRef(DEFAULT_COLOR); + + const handleFontSizeChange = ({ value }: { value: string }) => { + setFontSizeInput(value); + + if (value.trim() !== "") { + const parsed = parseInt(value, 10); + const fontSize = Number.isNaN(parsed) + ? element.fontSize + : clamp({ value: parsed, min: 8, max: 300 }); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { fontSize }, + }); + } + }; + + const handleFontSizeBlur = () => { + const parsed = parseInt(fontSizeInput, 10); + const fontSize = Number.isNaN(parsed) + ? element.fontSize + : clamp({ value: parsed, min: 8, max: 300 }); + setFontSizeInput(fontSize.toString()); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { fontSize }, + }); + }; + + const handleOpacityChange = ({ value }: { value: string }) => { + setOpacityInput(value); + + if (value.trim() !== "") { + const parsed = parseInt(value, 10); + const opacityPercent = Number.isNaN(parsed) + ? Math.round(element.opacity * 100) + : clamp({ value: parsed, min: 0, max: 100 }); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { opacity: opacityPercent / 100 }, + }); + } + }; + + const handleOpacityBlur = () => { + const parsed = parseInt(opacityInput, 10); + const opacityPercent = Number.isNaN(parsed) + ? Math.round(element.opacity * 100) + : clamp({ value: parsed, min: 0, max: 100 }); + setOpacityInput(opacityPercent.toString()); + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { opacity: opacityPercent / 100 }, + }); + }; + + const handleColorChange = ({ color }: { color: string }) => { + if (color !== "transparent") { + lastSelectedColor.current = color; + } + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { backgroundColor: color }, + }); + }; + + const handleTransparentToggle = ({ + isTransparent, + }: { + isTransparent: boolean; + }) => { + const newColor = isTransparent ? "transparent" : lastSelectedColor.current; + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { backgroundColor: newColor }, + }); + }; + + return ( + <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="bg-panel-accent min-h-20" + onChange={(e) => + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { content: e.target.value }, + }) + } + /> + <PropertyItem direction="column"> + <PropertyItemLabel>Font</PropertyItemLabel> + <PropertyItemValue> + <FontPicker + defaultValue={element.fontFamily} + onValueChange={(value: FontFamily) => + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { 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={() => + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { + 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={() => + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { + fontStyle: + element.fontStyle === "italic" + ? "normal" + : "italic", + }, + }) + } + className="h-8 px-3 italic" + > + I + </Button> + <Button + variant={ + element.textDecoration === "underline" + ? "default" + : "outline" + } + size="sm" + onClick={() => + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { + 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={() => + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { + 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]) => { + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { fontSize: value }, + }); + setFontSizeInput(value.toString()); + }} + className="w-full" + /> + <Input + type="number" + value={fontSizeInput} + min={8} + max={300} + onChange={(e) => + handleFontSizeChange({ value: e.target.value }) + } + onBlur={handleFontSizeBlur} + className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Color</PropertyItemLabel> + <PropertyItemValue> + <ColorPicker + value={uppercase({ + string: (element.color || "FFFFFF").replace("#", ""), + })} + onChange={(color) => { + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { 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]) => { + editor.timeline.updateTextElement({ + trackId, + elementId: element.id, + updates: { opacity: value / 100 }, + }); + setOpacityInput(value.toString()); + }} + className="w-full" + /> + <Input + type="number" + value={opacityInput} + min={0} + max={100} + onChange={(e) => + handleOpacityChange({ value: e.target.value }) + } + onBlur={handleOpacityBlur} + className="bg-panel-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + </div> + </PropertyItemValue> + </PropertyItem> + <PropertyItem direction="column"> + <PropertyItemLabel>Background</PropertyItemLabel> + <PropertyItemValue> + <div className="flex items-center gap-2"> + <ColorPicker + value={capitalizeFirstLetter({ + string: + element.backgroundColor === "transparent" + ? lastSelectedColor.current.replace("#", "") + : element.backgroundColor.replace("#", ""), + })} + onChange={(color) => + handleColorChange({ color: `#${color}` }) + } + containerRef={containerRef} + className={ + element.backgroundColor === "transparent" + ? "pointer-events-none opacity-50" + : "" + } + /> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="icon" + onClick={() => + handleTransparentToggle({ + isTransparent: + element.backgroundColor !== "transparent", + }) + } + className="bg-panel-accent size-9 overflow-hidden rounded-full p-0" + > + <HugeiconsIcon + icon={LayoutGridIcon} + 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/panels/properties/video-properties.tsx b/apps/web/src/components/editor/panels/properties/video-properties.tsx new file mode 100644 index 00000000..bf6d92d8 --- /dev/null +++ b/apps/web/src/components/editor/panels/properties/video-properties.tsx @@ -0,0 +1,9 @@ +import type { ImageElement, VideoElement } from "@/types/timeline"; + +export function VideoProperties({ + _element, +}: { + _element: VideoElement | ImageElement; +}) { + return <div className="space-y-4 p-5">Video properties</div>; +} |
