diff options
Diffstat (limited to 'apps/web/src/components/editor/media-panel')
| -rw-r--r-- | apps/web/src/components/editor/media-panel/drag-overlay.tsx | 114 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel/index.tsx | 110 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel/store.ts | 146 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel/tabbar.tsx | 248 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel/views/media.tsx | 596 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel/views/text.tsx | 106 |
6 files changed, 660 insertions, 660 deletions
diff --git a/apps/web/src/components/editor/media-panel/drag-overlay.tsx b/apps/web/src/components/editor/media-panel/drag-overlay.tsx index 3f3820db..0c75d146 100644 --- a/apps/web/src/components/editor/media-panel/drag-overlay.tsx +++ b/apps/web/src/components/editor/media-panel/drag-overlay.tsx @@ -1,57 +1,57 @@ -import { Upload, Plus, Image } from "lucide-react";
-import { Button } from "@/components/ui/button";
-
-interface MediaDragOverlayProps {
- isVisible: boolean;
- isProcessing?: boolean;
- progress?: number;
- onClick?: () => void;
- isEmptyState?: boolean;
-}
-
-export function MediaDragOverlay({
- isVisible,
- isProcessing = false,
- progress = 0,
- onClick,
- isEmptyState = false,
-}: MediaDragOverlayProps) {
- if (!isVisible) return null;
-
- const handleClick = (e: React.MouseEvent) => {
- if (isProcessing || !onClick) return;
- e.preventDefault();
- e.stopPropagation();
- onClick();
- };
-
- return (
- <div
- className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8"
- onClick={handleClick}
- >
- <div className="flex items-center justify-center">
- <Upload className="h-10 w-10 text-foreground" />
- </div>
-
- <div className="space-y-2">
- <p className="text-xs text-muted-foreground max-w-sm">
- {isProcessing
- ? `Processing your files (${progress}%)`
- : "Drag and drop videos, photos, and audio files here"}
- </p>
- </div>
-
- {isProcessing && (
- <div className="w-full max-w-xs">
- <div className="w-full bg-muted/50 rounded-full h-2">
- <div
- className="bg-primary h-2 rounded-full transition-all duration-300"
- style={{ width: `${progress}%` }}
- />
- </div>
- </div>
- )}
- </div>
- );
-}
+import { Upload, Plus, Image } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface MediaDragOverlayProps { + isVisible: boolean; + isProcessing?: boolean; + progress?: number; + onClick?: () => void; + isEmptyState?: boolean; +} + +export function MediaDragOverlay({ + isVisible, + isProcessing = false, + progress = 0, + onClick, + isEmptyState = false, +}: MediaDragOverlayProps) { + if (!isVisible) return null; + + const handleClick = (e: React.MouseEvent) => { + if (isProcessing || !onClick) return; + e.preventDefault(); + e.stopPropagation(); + onClick(); + }; + + return ( + <div + className="flex flex-col items-center justify-center gap-4 h-full text-center rounded-lg bg-foreground/5 hover:bg-foreground/10 transition-all duration-200 p-8" + onClick={handleClick} + > + <div className="flex items-center justify-center"> + <Upload className="h-10 w-10 text-foreground" /> + </div> + + <div className="space-y-2"> + <p className="text-xs text-muted-foreground max-w-sm"> + {isProcessing + ? `Processing your files (${progress}%)` + : "Drag and drop videos, photos, and audio files here"} + </p> + </div> + + {isProcessing && ( + <div className="w-full max-w-xs"> + <div className="w-full bg-muted/50 rounded-full h-2"> + <div + className="bg-primary h-2 rounded-full transition-all duration-300" + style={{ width: `${progress}%` }} + /> + </div> + </div> + )} + </div> + ); +} diff --git a/apps/web/src/components/editor/media-panel/index.tsx b/apps/web/src/components/editor/media-panel/index.tsx index 9f7322c7..50fee601 100644 --- a/apps/web/src/components/editor/media-panel/index.tsx +++ b/apps/web/src/components/editor/media-panel/index.tsx @@ -1,55 +1,55 @@ -"use client";
-
-import { TabBar } from "./tabbar";
-import { MediaView } from "./views/media";
-import { useMediaPanelStore, Tab } from "./store";
-import { TextView } from "./views/text";
-
-export function MediaPanel() {
- const { activeTab } = useMediaPanelStore();
-
- const viewMap: Record<Tab, React.ReactNode> = {
- media: <MediaView />,
- audio: (
- <div className="p-4 text-muted-foreground">Audio view coming soon...</div>
- ),
- text: <TextView />,
- stickers: (
- <div className="p-4 text-muted-foreground">
- Stickers view coming soon...
- </div>
- ),
- effects: (
- <div className="p-4 text-muted-foreground">
- Effects view coming soon...
- </div>
- ),
- transitions: (
- <div className="p-4 text-muted-foreground">
- Transitions view coming soon...
- </div>
- ),
- captions: (
- <div className="p-4 text-muted-foreground">
- Captions view coming soon...
- </div>
- ),
- filters: (
- <div className="p-4 text-muted-foreground">
- Filters view coming soon...
- </div>
- ),
- adjustment: (
- <div className="p-4 text-muted-foreground">
- Adjustment view coming soon...
- </div>
- ),
- };
-
- return (
- <div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden">
- <TabBar />
- <div className="flex-1">{viewMap[activeTab]}</div>
- </div>
- );
-}
+"use client"; + +import { TabBar } from "./tabbar"; +import { MediaView } from "./views/media"; +import { useMediaPanelStore, Tab } from "./store"; +import { TextView } from "./views/text"; + +export function MediaPanel() { + const { activeTab } = useMediaPanelStore(); + + const viewMap: Record<Tab, React.ReactNode> = { + media: <MediaView />, + audio: ( + <div className="p-4 text-muted-foreground">Audio view coming soon...</div> + ), + text: <TextView />, + stickers: ( + <div className="p-4 text-muted-foreground"> + Stickers view coming soon... + </div> + ), + effects: ( + <div className="p-4 text-muted-foreground"> + Effects view coming soon... + </div> + ), + transitions: ( + <div className="p-4 text-muted-foreground"> + Transitions view coming soon... + </div> + ), + captions: ( + <div className="p-4 text-muted-foreground"> + Captions view coming soon... + </div> + ), + filters: ( + <div className="p-4 text-muted-foreground"> + Filters view coming soon... + </div> + ), + adjustment: ( + <div className="p-4 text-muted-foreground"> + Adjustment view coming soon... + </div> + ), + }; + + return ( + <div className="h-full flex flex-col bg-panel rounded-sm overflow-hidden"> + <TabBar /> + <div className="flex-1">{viewMap[activeTab]}</div> + </div> + ); +} diff --git a/apps/web/src/components/editor/media-panel/store.ts b/apps/web/src/components/editor/media-panel/store.ts index 9668f285..0150c39a 100644 --- a/apps/web/src/components/editor/media-panel/store.ts +++ b/apps/web/src/components/editor/media-panel/store.ts @@ -1,73 +1,73 @@ -import {
- CaptionsIcon,
- ArrowLeftRightIcon,
- SparklesIcon,
- StickerIcon,
- MusicIcon,
- VideoIcon,
- BlendIcon,
- SlidersHorizontalIcon,
- LucideIcon,
- TypeIcon,
-} 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: TypeIcon,
- label: "Text",
- },
- stickers: {
- icon: StickerIcon,
- label: "Stickers",
- },
- effects: {
- icon: SparklesIcon,
- label: "Effects",
- },
- transitions: {
- icon: ArrowLeftRightIcon,
- label: "Transitions",
- },
- captions: {
- icon: CaptionsIcon,
- label: "Captions",
- },
- filters: {
- icon: BlendIcon,
- label: "Filters",
- },
- adjustment: {
- icon: SlidersHorizontalIcon,
- label: "Adjustment",
- },
-};
-
-interface MediaPanelStore {
- activeTab: Tab;
- setActiveTab: (tab: Tab) => void;
-}
-
-export const useMediaPanelStore = create<MediaPanelStore>((set) => ({
- activeTab: "media",
- setActiveTab: (tab) => set({ activeTab: tab }),
-}));
+import { + CaptionsIcon, + ArrowLeftRightIcon, + SparklesIcon, + StickerIcon, + MusicIcon, + VideoIcon, + BlendIcon, + SlidersHorizontalIcon, + LucideIcon, + TypeIcon, +} 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: TypeIcon, + label: "Text", + }, + stickers: { + icon: StickerIcon, + label: "Stickers", + }, + effects: { + icon: SparklesIcon, + label: "Effects", + }, + transitions: { + icon: ArrowLeftRightIcon, + label: "Transitions", + }, + captions: { + icon: CaptionsIcon, + label: "Captions", + }, + filters: { + icon: BlendIcon, + label: "Filters", + }, + adjustment: { + icon: SlidersHorizontalIcon, + label: "Adjustment", + }, +}; + +interface MediaPanelStore { + activeTab: Tab; + setActiveTab: (tab: Tab) => void; +} + +export const useMediaPanelStore = create<MediaPanelStore>((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 index b05bee7e..50793243 100644 --- a/apps/web/src/components/editor/media-panel/tabbar.tsx +++ b/apps/web/src/components/editor/media-panel/tabbar.tsx @@ -1,124 +1,124 @@ -"use client";
-
-import { cn } from "@/lib/utils";
-import { Tab, tabs, useMediaPanelStore } from "./store";
-import { Button } from "@/components/ui/button";
-import { ChevronRight, ChevronLeft } from "lucide-react";
-import { useRef, useState, useEffect } from "react";
-
-export function TabBar() {
- const { activeTab, setActiveTab } = useMediaPanelStore();
- const scrollContainerRef = useRef<HTMLDivElement>(null);
- const [isAtEnd, setIsAtEnd] = useState(false);
- const [isAtStart, setIsAtStart] = useState(true);
-
- const scrollToEnd = () => {
- if (scrollContainerRef.current) {
- scrollContainerRef.current.scrollTo({
- left: scrollContainerRef.current.scrollWidth,
- });
- setIsAtEnd(true);
- setIsAtStart(false);
- }
- };
-
- const scrollToStart = () => {
- if (scrollContainerRef.current) {
- scrollContainerRef.current.scrollTo({
- left: 0,
- });
- setIsAtStart(true);
- setIsAtEnd(false);
- }
- };
-
- const checkScrollPosition = () => {
- if (scrollContainerRef.current) {
- const { scrollLeft, scrollWidth, clientWidth } =
- scrollContainerRef.current;
- const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1;
- const isAtStartNow = scrollLeft <= 1;
- setIsAtEnd(isAtEndNow);
- setIsAtStart(isAtStartNow);
- }
- };
-
- // We're using useEffect because we need to sync with external DOM scroll events
- useEffect(() => {
- const container = scrollContainerRef.current;
- if (!container) return;
-
- checkScrollPosition();
- container.addEventListener("scroll", checkScrollPosition);
-
- const resizeObserver = new ResizeObserver(checkScrollPosition);
- resizeObserver.observe(container);
-
- return () => {
- container.removeEventListener("scroll", checkScrollPosition);
- resizeObserver.disconnect();
- };
- }, []);
-
- return (
- <div className="flex">
- <ScrollButton
- direction="left"
- onClick={scrollToStart}
- isVisible={!isAtStart}
- />
- <div
- ref={scrollContainerRef}
- className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full"
- >
- {(Object.keys(tabs) as Tab[]).map((tabKey) => {
- const tab = tabs[tabKey];
- return (
- <div
- className={cn(
- "flex flex-col gap-0.5 items-center cursor-pointer",
- activeTab === tabKey ? "text-primary" : "text-muted-foreground"
- )}
- onClick={() => setActiveTab(tabKey)}
- key={tabKey}
- >
- <tab.icon className="!size-[1.1rem]" />
- <span className="text-[0.65rem]">{tab.label}</span>
- </div>
- );
- })}
- </div>
- <ScrollButton
- direction="right"
- onClick={scrollToEnd}
- isVisible={!isAtEnd}
- />
- </div>
- );
-}
-
-function ScrollButton({
- direction,
- onClick,
- isVisible,
-}: {
- direction: "left" | "right";
- onClick: () => void;
- isVisible: boolean;
-}) {
- if (!isVisible) return null;
-
- const Icon = direction === "left" ? ChevronLeft : ChevronRight;
-
- return (
- <div className="bg-panel-accent w-12 h-full flex items-center justify-center">
- <Button
- size="icon"
- className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10"
- onClick={onClick}
- >
- <Icon className="!size-4 text-foreground" />
- </Button>
- </div>
- );
-}
+"use client"; + +import { cn } from "@/lib/utils"; +import { Tab, tabs, useMediaPanelStore } from "./store"; +import { Button } from "@/components/ui/button"; +import { ChevronRight, ChevronLeft } from "lucide-react"; +import { useRef, useState, useEffect } from "react"; + +export function TabBar() { + const { activeTab, setActiveTab } = useMediaPanelStore(); + const scrollContainerRef = useRef<HTMLDivElement>(null); + const [isAtEnd, setIsAtEnd] = useState(false); + const [isAtStart, setIsAtStart] = useState(true); + + const scrollToEnd = () => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTo({ + left: scrollContainerRef.current.scrollWidth, + }); + setIsAtEnd(true); + setIsAtStart(false); + } + }; + + const scrollToStart = () => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTo({ + left: 0, + }); + setIsAtStart(true); + setIsAtEnd(false); + } + }; + + const checkScrollPosition = () => { + if (scrollContainerRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = + scrollContainerRef.current; + const isAtEndNow = scrollLeft + clientWidth >= scrollWidth - 1; + const isAtStartNow = scrollLeft <= 1; + setIsAtEnd(isAtEndNow); + setIsAtStart(isAtStartNow); + } + }; + + // We're using useEffect because we need to sync with external DOM scroll events + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + checkScrollPosition(); + container.addEventListener("scroll", checkScrollPosition); + + const resizeObserver = new ResizeObserver(checkScrollPosition); + resizeObserver.observe(container); + + return () => { + container.removeEventListener("scroll", checkScrollPosition); + resizeObserver.disconnect(); + }; + }, []); + + return ( + <div className="flex"> + <ScrollButton + direction="left" + onClick={scrollToStart} + isVisible={!isAtStart} + /> + <div + ref={scrollContainerRef} + className="h-12 bg-panel-accent px-3 flex justify-start items-center gap-5 overflow-x-auto scrollbar-x-hidden relative w-full" + > + {(Object.keys(tabs) as Tab[]).map((tabKey) => { + const tab = tabs[tabKey]; + return ( + <div + className={cn( + "flex flex-col gap-0.5 items-center cursor-pointer", + activeTab === tabKey ? "text-primary" : "text-muted-foreground", + )} + onClick={() => setActiveTab(tabKey)} + key={tabKey} + > + <tab.icon className="!size-[1.1rem]" /> + <span className="text-[0.65rem]">{tab.label}</span> + </div> + ); + })} + </div> + <ScrollButton + direction="right" + onClick={scrollToEnd} + isVisible={!isAtEnd} + /> + </div> + ); +} + +function ScrollButton({ + direction, + onClick, + isVisible, +}: { + direction: "left" | "right"; + onClick: () => void; + isVisible: boolean; +}) { + if (!isVisible) return null; + + const Icon = direction === "left" ? ChevronLeft : ChevronRight; + + return ( + <div className="bg-panel-accent w-12 h-full flex items-center justify-center"> + <Button + size="icon" + className="rounded-[0.4rem] w-4 h-7 !bg-foreground/10" + onClick={onClick} + > + <Icon className="!size-4 text-foreground" /> + </Button> + </div> + ); +} diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index 631fe39a..712dd922 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -1,298 +1,298 @@ -"use client";
-
-import { useDragDrop } from "@/hooks/use-drag-drop";
-import { processMediaFiles } from "@/lib/media-processing";
-import { useMediaStore, type MediaItem } from "@/stores/media-store";
-import { Image, Loader2, Music, Plus, Video } from "lucide-react";
-import { useEffect, useRef, useState } from "react";
-import { toast } from "sonner";
-import { Button } from "@/components/ui/button";
-import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay";
-import {
- ContextMenu,
- ContextMenuContent,
- ContextMenuItem,
- ContextMenuTrigger,
-} from "@/components/ui/context-menu";
-import { Input } from "@/components/ui/input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { DraggableMediaItem } from "@/components/ui/draggable-item";
-import { useProjectStore } from "@/stores/project-store";
-import { useTimelineStore } from "@/stores/timeline-store";
-
-export function MediaView() {
- const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
- const { activeProject } = useProjectStore();
- const fileInputRef = useRef<HTMLInputElement>(null);
- const [isProcessing, setIsProcessing] = useState(false);
- const [progress, setProgress] = useState(0);
- const [searchQuery, setSearchQuery] = useState("");
- const [mediaFilter, setMediaFilter] = useState("all");
-
- const processFiles = async (files: FileList | File[]) => {
- if (!files || files.length === 0) return;
- if (!activeProject) {
- toast.error("No active project");
- 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(activeProject.id, 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<HTMLInputElement>) => {
- // When files are selected via file picker, process them
- if (e.target.files) processFiles(e.target.files);
- e.target.value = ""; // Reset input
- };
-
- const handleRemove = async (e: React.MouseEvent, id: string) => {
- // Remove a media item from the store
- e.stopPropagation();
-
- if (!activeProject) {
- toast.error("No active project");
- return;
- }
-
- // Media store now handles cascade deletion automatically
- await removeMediaItem(activeProject.id, id);
- };
-
- const formatDuration = (duration: number) => {
- // Format seconds as mm:ss
- const min = Math.floor(duration / 60);
- const sec = Math.floor(duration % 60);
- return `${min}:${sec.toString().padStart(2, "0")}`;
- };
-
- const [filteredMediaItems, 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 (
- <div className="w-full h-full flex items-center justify-center">
- <img
- src={item.url}
- alt={item.name}
- className="max-w-full max-h-full object-contain"
- loading="lazy"
- />
- </div>
- );
- }
-
- if (item.type === "video") {
- if (item.thumbnailUrl) {
- return (
- <div className="relative w-full h-full">
- <img
- src={item.thumbnailUrl}
- alt={item.name}
- className="w-full h-full object-cover rounded"
- loading="lazy"
- />
- <div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded">
- <Video className="h-6 w-6 text-white drop-shadow-md" />
- </div>
- {item.duration && (
- <div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded">
- {formatDuration(item.duration)}
- </div>
- )}
- </div>
- );
- }
- return (
- <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
- <Video className="h-6 w-6 mb-1" />
- <span className="text-xs">Video</span>
- {item.duration && (
- <span className="text-xs opacity-70">
- {formatDuration(item.duration)}
- </span>
- )}
- </div>
- );
- }
-
- if (item.type === "audio") {
- return (
- <div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20">
- <Music className="h-6 w-6 mb-1" />
- <span className="text-xs">Audio</span>
- {item.duration && (
- <span className="text-xs opacity-70">
- {formatDuration(item.duration)}
- </span>
- )}
- </div>
- );
- }
-
- return (
- <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded">
- <Image className="h-6 w-6" />
- <span className="text-xs mt-1">Unknown</span>
- </div>
- );
- };
-
- return (
- <>
- {/* Hidden file input for uploading media */}
- <input
- ref={fileInputRef}
- type="file"
- accept="image/*,video/*,audio/*"
- multiple
- className="hidden"
- onChange={handleFileChange}
- />
-
- <div
- className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`}
- {...dragProps}
- >
- <div className="p-3 pb-2">
- {/* Search and filter controls */}
- <div className="flex gap-2">
- <Select value={mediaFilter} onValueChange={setMediaFilter}>
- <SelectTrigger className="w-[80px] h-full text-xs">
- <SelectValue />
- </SelectTrigger>
- <SelectContent className="">
- <SelectItem value="all">All</SelectItem>
- <SelectItem value="video">Video</SelectItem>
- <SelectItem value="audio">Audio</SelectItem>
- <SelectItem value="image">Image</SelectItem>
- </SelectContent>
- </Select>
- <Input
- type="text"
- placeholder="Search media..."
- className="min-w-[60px] flex-1 h-full text-xs"
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
- />
- <Button
- variant="outline"
- size="lg"
- onClick={handleFileSelect}
- disabled={isProcessing}
- className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
- >
- {isProcessing ? (
- <Loader2 className="h-4 w-4 animate-spin" />
- ) : (
- <Plus className="h-4 w-4" />
- )}
- </Button>
- </div>
- </div>
-
- <div className="flex-1 overflow-y-auto p-3 pt-0">
- {isDragOver || filteredMediaItems.length === 0 ? (
- <MediaDragOverlay
- isVisible={true}
- isProcessing={isProcessing}
- progress={progress}
- onClick={handleFileSelect}
- isEmptyState={filteredMediaItems.length === 0 && !isDragOver}
- />
- ) : (
- <div
- className="grid gap-2"
- style={{
- gridTemplateColumns: "repeat(auto-fill, 160px)",
- }}
- >
- {/* Render each media item as a draggable button */}
- {filteredMediaItems.map((item) => (
- <ContextMenu key={item.id}>
- <ContextMenuTrigger>
- <DraggableMediaItem
- name={item.name}
- preview={renderPreview(item)}
- dragData={{
- id: item.id,
- type: item.type,
- name: item.name,
- }}
- showPlusOnDrag={false}
- onAddToTimeline={(currentTime) =>
- useTimelineStore
- .getState()
- .addMediaAtTime(item, currentTime)
- }
- rounded={false}
- />
- </ContextMenuTrigger>
- <ContextMenuContent>
- <ContextMenuItem>Export clips</ContextMenuItem>
- <ContextMenuItem
- variant="destructive"
- onClick={(e) => handleRemove(e, item.id)}
- >
- Delete
- </ContextMenuItem>
- </ContextMenuContent>
- </ContextMenu>
- ))}
- </div>
- )}
- </div>
- </div>
- </>
- );
-}
+"use client"; + +import { useDragDrop } from "@/hooks/use-drag-drop"; +import { processMediaFiles } from "@/lib/media-processing"; +import { useMediaStore, type MediaItem } from "@/stores/media-store"; +import { Image, Loader2, Music, Plus, Video } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { MediaDragOverlay } from "@/components/editor/media-panel/drag-overlay"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { DraggableMediaItem } from "@/components/ui/draggable-item"; +import { useProjectStore } from "@/stores/project-store"; +import { useTimelineStore } from "@/stores/timeline-store"; + +export function MediaView() { + const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); + const { activeProject } = useProjectStore(); + const fileInputRef = useRef<HTMLInputElement>(null); + const [isProcessing, setIsProcessing] = useState(false); + const [progress, setProgress] = useState(0); + const [searchQuery, setSearchQuery] = useState(""); + const [mediaFilter, setMediaFilter] = useState("all"); + + const processFiles = async (files: FileList | File[]) => { + if (!files || files.length === 0) return; + if (!activeProject) { + toast.error("No active project"); + 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(activeProject.id, 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<HTMLInputElement>) => { + // When files are selected via file picker, process them + if (e.target.files) processFiles(e.target.files); + e.target.value = ""; // Reset input + }; + + const handleRemove = async (e: React.MouseEvent, id: string) => { + // Remove a media item from the store + e.stopPropagation(); + + if (!activeProject) { + toast.error("No active project"); + return; + } + + // Media store now handles cascade deletion automatically + await removeMediaItem(activeProject.id, id); + }; + + const formatDuration = (duration: number) => { + // Format seconds as mm:ss + const min = Math.floor(duration / 60); + const sec = Math.floor(duration % 60); + return `${min}:${sec.toString().padStart(2, "0")}`; + }; + + const [filteredMediaItems, 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 ( + <div className="w-full h-full flex items-center justify-center"> + <img + src={item.url} + alt={item.name} + className="max-w-full max-h-full object-contain" + loading="lazy" + /> + </div> + ); + } + + if (item.type === "video") { + if (item.thumbnailUrl) { + return ( + <div className="relative w-full h-full"> + <img + src={item.thumbnailUrl} + alt={item.name} + className="w-full h-full object-cover rounded" + loading="lazy" + /> + <div className="absolute inset-0 flex items-center justify-center bg-black/20 rounded"> + <Video className="h-6 w-6 text-white drop-shadow-md" /> + </div> + {item.duration && ( + <div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 rounded"> + {formatDuration(item.duration)} + </div> + )} + </div> + ); + } + return ( + <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"> + <Video className="h-6 w-6 mb-1" /> + <span className="text-xs">Video</span> + {item.duration && ( + <span className="text-xs opacity-70"> + {formatDuration(item.duration)} + </span> + )} + </div> + ); + } + + if (item.type === "audio") { + return ( + <div className="w-full h-full bg-gradient-to-br from-green-500/20 to-emerald-500/20 flex flex-col items-center justify-center text-muted-foreground rounded border border-green-500/20"> + <Music className="h-6 w-6 mb-1" /> + <span className="text-xs">Audio</span> + {item.duration && ( + <span className="text-xs opacity-70"> + {formatDuration(item.duration)} + </span> + )} + </div> + ); + } + + return ( + <div className="w-full h-full bg-muted/30 flex flex-col items-center justify-center text-muted-foreground rounded"> + <Image className="h-6 w-6" /> + <span className="text-xs mt-1">Unknown</span> + </div> + ); + }; + + return ( + <> + {/* Hidden file input for uploading media */} + <input + ref={fileInputRef} + type="file" + accept="image/*,video/*,audio/*" + multiple + className="hidden" + onChange={handleFileChange} + /> + + <div + className={`h-full flex flex-col gap-1 transition-colors relative ${isDragOver ? "bg-accent/30" : ""}`} + {...dragProps} + > + <div className="p-3 pb-2"> + {/* Search and filter controls */} + <div className="flex gap-2"> + <Select value={mediaFilter} onValueChange={setMediaFilter}> + <SelectTrigger className="w-[80px] h-full text-xs"> + <SelectValue /> + </SelectTrigger> + <SelectContent className=""> + <SelectItem value="all">All</SelectItem> + <SelectItem value="video">Video</SelectItem> + <SelectItem value="audio">Audio</SelectItem> + <SelectItem value="image">Image</SelectItem> + </SelectContent> + </Select> + <Input + type="text" + placeholder="Search media..." + className="min-w-[60px] flex-1 h-full text-xs" + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + /> + <Button + variant="outline" + size="lg" + onClick={handleFileSelect} + disabled={isProcessing} + className="flex-none bg-transparent min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center" + > + {isProcessing ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Plus className="h-4 w-4" /> + )} + </Button> + </div> + </div> + + <div className="flex-1 overflow-y-auto p-3 pt-0"> + {isDragOver || filteredMediaItems.length === 0 ? ( + <MediaDragOverlay + isVisible={true} + isProcessing={isProcessing} + progress={progress} + onClick={handleFileSelect} + isEmptyState={filteredMediaItems.length === 0 && !isDragOver} + /> + ) : ( + <div + className="grid gap-2" + style={{ + gridTemplateColumns: "repeat(auto-fill, 160px)", + }} + > + {/* Render each media item as a draggable button */} + {filteredMediaItems.map((item) => ( + <ContextMenu key={item.id}> + <ContextMenuTrigger> + <DraggableMediaItem + name={item.name} + preview={renderPreview(item)} + dragData={{ + id: item.id, + type: item.type, + name: item.name, + }} + showPlusOnDrag={false} + onAddToTimeline={(currentTime) => + useTimelineStore + .getState() + .addMediaAtTime(item, currentTime) + } + rounded={false} + /> + </ContextMenuTrigger> + <ContextMenuContent> + <ContextMenuItem>Export clips</ContextMenuItem> + <ContextMenuItem + variant="destructive" + onClick={(e) => handleRemove(e, item.id)} + > + Delete + </ContextMenuItem> + </ContextMenuContent> + </ContextMenu> + ))} + </div> + )} + </div> + </div> + </> + ); +} diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index 6d0d310b..b47edd8d 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -1,53 +1,53 @@ -import { DraggableMediaItem } from "@/components/ui/draggable-item";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { type TextElement } from "@/types/timeline";
-
-let textData: TextElement = {
- id: "default-text",
- type: "text",
- name: "Default text",
- content: "Default text",
- fontSize: 48,
- fontFamily: "Arial",
- color: "#ffffff",
- backgroundColor: "transparent",
- textAlign: "center" as const,
- fontWeight: "normal" as const,
- fontStyle: "normal" as const,
- textDecoration: "none" as const,
- x: 0,
- y: 0,
- rotation: 0,
- opacity: 1,
- duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
-};
-
-export function TextView() {
- return (
- <div className="p-4">
- <DraggableMediaItem
- name="Default text"
- preview={
- <div className="flex items-center justify-center w-full h-full bg-accent rounded">
- <span className="text-xs select-none">Default text</span>
- </div>
- }
- dragData={{
- id: textData.id,
- type: textData.type,
- name: textData.name,
- content: textData.content,
- }}
- aspectRatio={1}
- onAddToTimeline={(currentTime) =>
- useTimelineStore.getState().addTextAtTime(textData, currentTime)
- }
- showLabel={false}
- />
- </div>
- );
-}
+import { DraggableMediaItem } from "@/components/ui/draggable-item"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { type TextElement } from "@/types/timeline"; + +let textData: TextElement = { + id: "default-text", + type: "text", + name: "Default text", + content: "Default text", + fontSize: 48, + fontFamily: "Arial", + color: "#ffffff", + backgroundColor: "transparent", + textAlign: "center" as const, + fontWeight: "normal" as const, + fontStyle: "normal" as const, + textDecoration: "none" as const, + x: 0, + y: 0, + rotation: 0, + opacity: 1, + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, +}; + +export function TextView() { + return ( + <div className="p-4"> + <DraggableMediaItem + name="Default text" + preview={ + <div className="flex items-center justify-center w-full h-full bg-accent rounded"> + <span className="text-xs select-none">Default text</span> + </div> + } + dragData={{ + id: textData.id, + type: textData.type, + name: textData.name, + content: textData.content, + }} + aspectRatio={1} + onAddToTimeline={(currentTime) => + useTimelineStore.getState().addTextAtTime(textData, currentTime) + } + showLabel={false} + /> + </div> + ); +} |
