summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/media-panel
diff options
context:
space:
mode:
authorHayden Bleasel <hello@haydenbleasel.com>2025-07-24 14:41:34 -0700
committerGitHub <noreply@github.com>2025-07-25 00:41:34 +0300
commit5931ddb77aac23ffe1b82d9d7c03afde3e671109 (patch)
tree3e2ead9cc6e13c01132a78c21b64f2be7b00e215 /apps/web/src/components/editor/media-panel
parent7b840e9b4e198212eb34861c21469b2215157914 (diff)
downloadopencut-5931ddb77aac23ffe1b82d9d7c03afde3e671109.tar.gz
feat: add ultracite (#452)
* Init ultracite * Update scripts and biome.jsonc * Update biome.jsonc * Update biome.jsonc * Update biome.jsonc * Run format
Diffstat (limited to 'apps/web/src/components/editor/media-panel')
-rw-r--r--apps/web/src/components/editor/media-panel/drag-overlay.tsx114
-rw-r--r--apps/web/src/components/editor/media-panel/index.tsx108
-rw-r--r--apps/web/src/components/editor/media-panel/store.ts146
-rw-r--r--apps/web/src/components/editor/media-panel/tabbar.tsx248
-rw-r--r--apps/web/src/components/editor/media-panel/views/audio.tsx36
-rw-r--r--apps/web/src/components/editor/media-panel/views/media.tsx596
-rw-r--r--apps/web/src/components/editor/media-panel/views/text.tsx106
7 files changed, 677 insertions, 677 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..e1fd257e 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 90ac84f0..2c743618 100644
--- a/apps/web/src/components/editor/media-panel/index.tsx
+++ b/apps/web/src/components/editor/media-panel/index.tsx
@@ -1,54 +1,54 @@
-"use client";
-
-import { TabBar } from "./tabbar";
-import { MediaView } from "./views/media";
-import { useMediaPanelStore, Tab } from "./store";
-import { TextView } from "./views/text";
-import { AudioView } from "./views/audio";
-
-export function MediaPanel() {
- const { activeTab } = useMediaPanelStore();
-
- const viewMap: Record<Tab, React.ReactNode> = {
- media: <MediaView />,
- audio: <AudioView />,
- 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";
+import { AudioView } from "./views/audio";
+
+export function MediaPanel() {
+ const { activeTab } = useMediaPanelStore();
+
+ const viewMap: Record<Tab, React.ReactNode> = {
+ media: <MediaView />,
+ audio: <AudioView />,
+ 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..c661f51e 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..bce84a46 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/audio.tsx b/apps/web/src/components/editor/media-panel/views/audio.tsx
index 7ddf465c..ba64d749 100644
--- a/apps/web/src/components/editor/media-panel/views/audio.tsx
+++ b/apps/web/src/components/editor/media-panel/views/audio.tsx
@@ -1,18 +1,18 @@
-"use client";
-
-import { Input } from "@/components/ui/input";
-import { useState } from "react";
-
-export function AudioView() {
- const [search, setSearch] = useState("");
- return (
- <div className="h-full flex flex-col gap-2 p-4">
- <Input
- placeholder="Search songs and artists"
- value={search}
- onChange={(e) => setSearch(e.target.value)}
- />
- <div className="flex flex-col gap-2"></div>
- </div>
- );
-}
+"use client";
+
+import { Input } from "@/components/ui/input";
+import { useState } from "react";
+
+export function AudioView() {
+ const [search, setSearch] = useState("");
+ return (
+ <div className="h-full flex flex-col gap-2 p-4">
+ <Input
+ placeholder="Search songs and artists"
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ />
+ <div className="flex flex-col gap-2" />
+ </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 0189720f..66d0d00d 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-9 text-xs">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <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-9 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 h-9"
- >
- {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-9 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <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-9 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 h-9"
+ >
+ {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..0686190e 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";
+
+const textData: TextElement = {
+ id: "default-text",
+ type: "text",
+ name: "Default text",
+ content: "Default text",
+ fontSize: 48,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ backgroundColor: "transparent",
+ textAlign: "center" as const,
+ fontWeight: "normal" as const,
+ fontStyle: "normal" as const,
+ textDecoration: "none" as const,
+ x: 0,
+ y: 0,
+ rotation: 0,
+ opacity: 1,
+ duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+};
+
+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>
+ );
+}