From 59a6c539a150adf9ff9ed3d91854b2bea6e7c517 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Thu, 3 Jul 2025 20:43:23 +0200 Subject: [PATCH] feat: implement media panel with tab navigation --- .../components/editor/media-panel/index.tsx | 56 ++ .../components/editor/media-panel/store.ts | 73 ++ .../components/editor/media-panel/tabbar.tsx | 29 + .../views/media.tsx} | 731 ++++++++---------- 4 files changed, 500 insertions(+), 389 deletions(-) create mode 100644 apps/web/src/components/editor/media-panel/index.tsx create mode 100644 apps/web/src/components/editor/media-panel/store.ts create mode 100644 apps/web/src/components/editor/media-panel/tabbar.tsx rename apps/web/src/components/editor/{media-panel.tsx => media-panel/views/media.tsx} (87%) diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx new file mode 100644 index 0000000..f855fa3 --- /dev/null +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { TabBar } from "./tabbar"; +import { MediaView } from "./views/media"; +import { useMediaPanelStore, Tab } from "./store"; + +export function MediaPanel() { + const { activeTab } = useMediaPanelStore(); + + const viewMap: Record = { + media: , + audio: ( +
Audio view coming soon...
+ ), + text: ( +
Text view coming soon...
+ ), + stickers: ( +
+ Stickers view coming soon... +
+ ), + effects: ( +
+ Effects view coming soon... +
+ ), + transitions: ( +
+ Transitions view coming soon... +
+ ), + captions: ( +
+ Captions view coming soon... +
+ ), + filters: ( +
+ Filters view coming soon... +
+ ), + adjustment: ( +
+ Adjustment view coming soon... +
+ ), + }; + + return ( +
+ +
{viewMap[activeTab]}
+
+ ); +} diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts new file mode 100644 index 0000000..a9a1e68 --- /dev/null +++ b/apps/web/src/components/editor/media-panel/store.ts @@ -0,0 +1,73 @@ +import { + CaptionsIcon, + ArrowLeftRightIcon, + SparklesIcon, + StickerIcon, + TextIcon, + MusicIcon, + VideoIcon, + BlendIcon, + SlidersHorizontalIcon, + LucideIcon, +} from "lucide-react"; +import { create } from "zustand"; + +export type Tab = + | "media" + | "audio" + | "text" + | "stickers" + | "effects" + | "transitions" + | "captions" + | "filters" + | "adjustment"; + +export const tabs: { [key in Tab]: { icon: LucideIcon; label: string } } = { + media: { + icon: VideoIcon, + label: "Media", + }, + audio: { + icon: MusicIcon, + label: "Audio", + }, + text: { + icon: TextIcon, + label: "Text", + }, + stickers: { + icon: StickerIcon, + label: "Stickers", + }, + effects: { + icon: SparklesIcon, + label: "Effects", + }, + transitions: { + icon: ArrowLeftRightIcon, + label: "Transitions", + }, + captions: { + icon: CaptionsIcon, + label: "Captions", + }, + filters: { + icon: BlendIcon, + label: "Filters", + }, + adjustment: { + icon: SlidersHorizontalIcon, + label: "Adjustment", + }, +}; + +interface MediaPanelStore { + activeTab: Tab; + setActiveTab: (tab: Tab) => void; +} + +export const useMediaPanelStore = create((set) => ({ + activeTab: "media", + setActiveTab: (tab) => set({ activeTab: tab }), +})); diff --git a/apps/web/src/components/editor/media-panel/tabbar.tsx b/apps/web/src/components/editor/media-panel/tabbar.tsx new file mode 100644 index 0000000..90f8a70 --- /dev/null +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Tab, tabs, useMediaPanelStore } from "./store"; + +export function TabBar() { + const { activeTab, setActiveTab } = useMediaPanelStore(); + + return ( +
+ {(Object.keys(tabs) as Tab[]).map((tabKey) => { + const tab = tabs[tabKey]; + return ( +
setActiveTab(tabKey)} + key={tabKey} + > + + {tab.label} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/editor/media-panel.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx similarity index 87% rename from apps/web/src/components/editor/media-panel.tsx rename to apps/web/src/components/editor/media-panel/views/media.tsx index 73ac0ad..e2c7210 100644 --- a/apps/web/src/components/editor/media-panel.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -1,389 +1,342 @@ -"use client"; - -import { useDragDrop } from "@/hooks/use-drag-drop"; -import { processMediaFiles } from "@/lib/media-processing"; -import { useMediaStore, type MediaItem } from "@/stores/media-store"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { Image, Music, MusicIcon, Plus, TextIcon, Upload, Video, VideoIcon } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; -import { AspectRatio } from "../ui/aspect-ratio"; -import { Button } from "../ui/button"; -import { DragOverlay } from "../ui/drag-overlay"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "../ui/context-menu"; -import { Input } from "../ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../ui/select"; -import { cn } from "@/lib/utils"; - -// MediaPanel lets users add, view, and drag media (images, videos, audio) into the project. -// You can upload files or drag them from your computer. Dragging from here to the timeline adds them to your video project. - -export function MediaPanel() { - const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); - const fileInputRef = useRef(null); - const [isProcessing, setIsProcessing] = useState(false); - const [progress, setProgress] = useState(0); - const [searchQuery, setSearchQuery] = useState(""); - const [mediaFilter, setMediaFilter] = useState("all"); - - const processFiles = async (files: FileList | File[]) => { - if (!files || files.length === 0) return; - setIsProcessing(true); - setProgress(0); - try { - // Process files (extract metadata, generate thumbnails, etc.) - const processedItems = await processMediaFiles(files, (p) => - setProgress(p) - ); - // Add each processed media item to the store - for (const item of processedItems) { - await addMediaItem(item); - } - } catch (error) { - // Show error toast if processing fails - console.error("Error processing files:", error); - toast.error("Failed to process files"); - } finally { - setIsProcessing(false); - setProgress(0); - } - }; - - const { isDragOver, dragProps } = useDragDrop({ - // When files are dropped, process them - onDrop: processFiles, - }); - - const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker - - const handleFileChange = (e: React.ChangeEvent) => { - // When files are selected via file picker, process them - if (e.target.files) processFiles(e.target.files); - e.target.value = ""; // Reset input - }; - - const handleRemove = async (e: React.MouseEvent, id: string) => { - // Remove a media item from the store - e.stopPropagation(); - - // Remove tracks automatically when delete media - const { tracks, removeTrack } = useTimelineStore.getState(); - tracks.forEach((track) => { - const clipsToRemove = track.clips.filter((clip) => clip.mediaId === id); - clipsToRemove.forEach((clip) => { - useTimelineStore.getState().removeClipFromTrack(track.id, clip.id); - }); - // Only remove track if it becomes empty and has no other clips - const updatedTrack = useTimelineStore - .getState() - .tracks.find((t) => t.id === track.id); - if (updatedTrack && updatedTrack.clips.length === 0) { - removeTrack(track.id); - } - }); - await removeMediaItem(id); - }; - - const formatDuration = (duration: number) => { - // Format seconds as mm:ss - const min = Math.floor(duration / 60); - const sec = Math.floor(duration % 60); - return `${min}:${sec.toString().padStart(2, "0")}`; - }; - - const startDrag = (e: React.DragEvent, item: MediaItem) => { - // When dragging a media item, set drag data for timeline to read - e.dataTransfer.setData( - "application/x-media-item", - JSON.stringify({ - id: item.id, - type: item.type, - name: item.name, - }) - ); - e.dataTransfer.effectAllowed = "copy"; - }; - - const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); - - useEffect(() => { - const filtered = mediaItems.filter((item) => { - if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { - return false; - } - - if ( - searchQuery && - !item.name.toLowerCase().includes(searchQuery.toLowerCase()) - ) { - return false; - } - - return true; - }); - - setFilteredMediaItems(filtered); - }, [mediaItems, mediaFilter, searchQuery]); - - const renderPreview = (item: MediaItem) => { - // Render a preview for each media type (image, video, audio, unknown) - if (item.type === "image") { - return ( -
- {item.name} -
- ); - } - - if (item.type === "video") { - if (item.thumbnailUrl) { - return ( -
- {item.name} -
-
- {item.duration && ( -
- {formatDuration(item.duration)} -
- )} -
- ); - } - return ( -
-
- ); - } - - if (item.type === "audio") { - return ( -
- - Audio - {item.duration && ( - - {formatDuration(item.duration)} - - )} -
- ); - } - - return ( -
- - Unknown -
- ); - }; - - return ( - <> - {/* Hidden file input for uploading media */} - - -
- {/* Show overlay when dragging files over the panel */} - - - - -
- {/* Button to add/upload media */} -
- {/* Search and filter controls */} - - setSearchQuery(e.target.value)} - /> - - {/* Add media button */} - -
-
- -
- {/* Show message if no media, otherwise show media grid */} - {filteredMediaItems.length === 0 ? ( -
-
- -
-

- No media in project -

-

- Drag files here or use the button above -

-
- ) : ( -
- {/* Render each media item as a draggable button */} - {filteredMediaItems.map((item) => ( -
- - - - - - Export clips - handleRemove(e, item.id)} - > - Delete - - - -
- ))} -
- )} -
-
- - ); -} - -type Tab = "media" | "audio" | "text"; - -function TabBar() { - const [activeTab, setActiveTab] = useState("media"); - - const tabs = [ - { - icon: VideoIcon, - label: "Media", - }, - { - icon: MusicIcon, - label: "Audio", - }, - { - icon: TextIcon, - label: "Text", - }, - ]; - - return ( -
- {tabs.map((tab) => ( -
setActiveTab(tab.label.toLowerCase() as Tab)} - key={tab.label} - > - - {tab.label} -
- ))} -
- ); -} +"use client"; + +import { useDragDrop } from "@/hooks/use-drag-drop"; +import { processMediaFiles } from "@/lib/media-processing"; +import { useMediaStore, type MediaItem } from "@/stores/media-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { Image, Music, Plus, Upload, Video } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { AspectRatio } from "@/components/ui/aspect-ratio"; +import { Button } from "@/components/ui/button"; +import { DragOverlay } from "@/components/ui/drag-overlay"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export function MediaView() { + const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); + const fileInputRef = useRef(null); + const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); + const [searchQuery, setSearchQuery] = useState(""); + const [mediaFilter, setMediaFilter] = useState("all"); + + const processFiles = async (files: FileList | File[]) => { + if (!files || files.length === 0) return; + setIsProcessing(true); + setProgress(0); + try { + // Process files (extract metadata, generate thumbnails, etc.) + const processedItems = await processMediaFiles(files, (p) => + setProgress(p) + ); + // Add each processed media item to the store + for (const item of processedItems) { + await addMediaItem(item); + } + } catch (error) { + // Show error toast if processing fails + console.error("Error processing files:", error); + toast.error("Failed to process files"); + } finally { + setIsProcessing(false); + setProgress(0); + } + }; + + const { isDragOver, dragProps } = useDragDrop({ + // When files are dropped, process them + onDrop: processFiles, + }); + + const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker + + const handleFileChange = (e: React.ChangeEvent) => { + // When files are selected via file picker, process them + if (e.target.files) processFiles(e.target.files); + e.target.value = ""; // Reset input + }; + + const handleRemove = async (e: React.MouseEvent, id: string) => { + // Remove a media item from the store + e.stopPropagation(); + + // Remove tracks automatically when delete media + const { tracks, removeTrack } = useTimelineStore.getState(); + tracks.forEach((track) => { + const clipsToRemove = track.clips.filter((clip) => clip.mediaId === id); + clipsToRemove.forEach((clip) => { + useTimelineStore.getState().removeClipFromTrack(track.id, clip.id); + }); + // Only remove track if it becomes empty and has no other clips + const updatedTrack = useTimelineStore + .getState() + .tracks.find((t) => t.id === track.id); + if (updatedTrack && updatedTrack.clips.length === 0) { + removeTrack(track.id); + } + }); + await removeMediaItem(id); + }; + + const formatDuration = (duration: number) => { + // Format seconds as mm:ss + const min = Math.floor(duration / 60); + const sec = Math.floor(duration % 60); + return `${min}:${sec.toString().padStart(2, "0")}`; + }; + + const startDrag = (e: React.DragEvent, item: MediaItem) => { + // When dragging a media item, set drag data for timeline to read + e.dataTransfer.setData( + "application/x-media-item", + JSON.stringify({ + id: item.id, + type: item.type, + name: item.name, + }) + ); + e.dataTransfer.effectAllowed = "copy"; + }; + + const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); + + useEffect(() => { + const filtered = mediaItems.filter((item) => { + if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { + return false; + } + + if ( + searchQuery && + !item.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) { + return false; + } + + return true; + }); + + setFilteredMediaItems(filtered); + }, [mediaItems, mediaFilter, searchQuery]); + + const renderPreview = (item: MediaItem) => { + // Render a preview for each media type (image, video, audio, unknown) + if (item.type === "image") { + return ( +
+ {item.name} +
+ ); + } + + if (item.type === "video") { + if (item.thumbnailUrl) { + return ( +
+ {item.name} +
+
+ {item.duration && ( +
+ {formatDuration(item.duration)} +
+ )} +
+ ); + } + return ( +
+
+ ); + } + + if (item.type === "audio") { + return ( +
+ + Audio + {item.duration && ( + + {formatDuration(item.duration)} + + )} +
+ ); + } + + return ( +
+ + Unknown +
+ ); + }; + + return ( + <> + {/* Hidden file input for uploading media */} + + +
+ {/* Show overlay when dragging files over the panel */} + + +
+ {/* Button to add/upload media */} +
+ {/* Search and filter controls */} + + setSearchQuery(e.target.value)} + /> + + {/* Add media button */} + +
+
+ +
+ {/* Show message if no media, otherwise show media grid */} + {filteredMediaItems.length === 0 ? ( +
+
+ +
+

+ No media in project +

+

+ Drag files here or use the button above +

+
+ ) : ( +
+ {/* Render each media item as a draggable button */} + {filteredMediaItems.map((item) => ( +
+ + + + + + Export clips + handleRemove(e, item.id)} + > + Delete + + + +
+ ))} +
+ )} +
+
+ + ); +}