From 8db3bead13441a5882d8689dfc6a9f09c82375d9 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Mar 2026 16:47:22 +0100 Subject: feat: masks, properties refactor, shaders, storage migrations, and more --- .../editor/panels/assets/views/assets.tsx | 153 +++++++++++++-------- 1 file changed, 93 insertions(+), 60 deletions(-) (limited to 'apps/web/src/components/editor/panels/assets/views/assets.tsx') diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index 3b35c022..e17e4d4a 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -2,11 +2,11 @@ import Image from "next/image"; import { useMemo, useState } from "react"; -import { toast } from "sonner"; -import { PanelView } from "@/components/editor/panels/assets/views/base-view"; +import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; 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 { toast } from "sonner"; import { ContextMenu, ContextMenuContent, @@ -28,8 +28,15 @@ import { 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 { invokeAction } from "@/lib/actions"; import { processMediaAssets } from "@/lib/media/processing"; +import { showMediaUploadToast } from "@/lib/media/upload-toast"; +import { + SelectableItem, + SelectableSurface, + useSelection, + useSelectionScope, +} from "@/lib/selection"; import { buildElementFromMedia } from "@/lib/timeline/element-utils"; import { type MediaSortKey, @@ -37,7 +44,8 @@ import { type MediaViewMode, useAssetsPanelStore, } from "@/stores/assets-panel-store"; -import type { MediaAsset } from "@/types/assets"; +import { MASKABLE_ELEMENT_TYPES } from "@/lib/timeline"; +import type { MediaAsset } from "@/lib/media/types"; import { cn } from "@/utils/ui"; import { CloudUploadIcon, @@ -52,8 +60,8 @@ import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; export function MediaView() { const editor = useEditor(); - const mediaFiles = editor.media.getAssets(); - const activeProject = editor.project.getActive(); + const mediaFiles = useEditor((e) => e.media.getAssets()); + const activeProject = useEditor((e) => e.project.getActive()); const { mediaViewMode, @@ -64,15 +72,11 @@ export function MediaView() { mediaSortOrder, setMediaSort, } = useAssetsPanelStore(); - const { highlightedId, registerElement } = useRevealItem( - highlightMediaId, - clearHighlight, - ); const [isProcessing, setIsProcessing] = useState(false); const [progress, setProgress] = useState(0); - const processFiles = async ({ files }: { files: FileList }) => { + const processFiles = async ({ files }: { files: File[] }) => { if (!files || files.length === 0) return; if (!activeProject) { toast.error("No active project"); @@ -82,20 +86,28 @@ export function MediaView() { setIsProcessing(true); setProgress(0); try { - const processedAssets = await processMediaAssets({ - files, - onProgress: (progress: { progress: number }) => - setProgress(progress.progress), + await showMediaUploadToast({ + filesCount: files.length, + promise: async () => { + 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, + }); + } + return { + uploadedCount: processedAssets.length, + assetNames: processedAssets.map((asset) => asset.name), + }; + }, }); - 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); @@ -109,23 +121,18 @@ export function MediaView() { onFilesSelected: (files) => processFiles({ files }), }); - const handleRemove = async ({ + const handleRemove = ({ event, - id, + ids, }: { event: React.MouseEvent; - id: string; + ids: string[]; }) => { event.stopPropagation(); - if (!activeProject) { - toast.error("No active project"); - return; - } - - await editor.media.removeMediaAsset({ + invokeAction("remove-media-assets", { projectId: activeProject.metadata.id, - id, + assetIds: ids, }); }; @@ -172,6 +179,9 @@ export function MediaView() { return filtered; }, [mediaFiles, mediaSortBy, mediaSortOrder]); + const orderedMediaIds = useMemo(() => { + return filteredMediaItems.map((item) => item.id); + }, [filteredMediaItems]); return ( <> @@ -191,6 +201,7 @@ export function MediaView() { /> } className={cn(isDragOver && "bg-accent/30")} + contentClassName="h-full" {...dragProps} > {isDragOver || filteredMediaItems.length === 0 ? ( @@ -201,29 +212,38 @@ export function MediaView() { onClick={openFilePicker} /> ) : ( - + + + + )} ); } +function MediaScopeRegistrar() { + useSelectionScope(); + return null; +} + function MediaAssetDraggable({ item, preview, - isHighlighted, variant, isRounded, }: { item: MediaAsset; preview: React.ReactNode; - isHighlighted: boolean; variant: "card" | "compact"; isRounded?: boolean; }) { @@ -261,7 +281,7 @@ function MediaAssetDraggable({ mediaType: item.type, name: item.name, ...(item.type !== "audio" && { - targetElementTypes: ["video", "image"] as const, + targetElementTypes: [...MASKABLE_ELEMENT_TYPES], }), }} shouldShowPlusOnDrag={false} @@ -270,7 +290,6 @@ function MediaAssetDraggable({ } variant={variant} isRounded={isRounded} - isHighlighted={isHighlighted} /> ); } @@ -282,18 +301,31 @@ function MediaItemWithContextMenu({ }: { item: MediaAsset; children: React.ReactNode; - onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; + onRemove: ({ + event, + ids, + }: { + event: React.MouseEvent; + ids: string[]; + }) => void; }) { + const { isSelected, selectedIds } = useSelection(); + const idsToDelete = isSelected(item.id) ? selectedIds : [item.id]; + const deleteLabel = + idsToDelete.length > 1 ? `Delete ${idsToDelete.length} items` : "Delete"; + return ( - {children} + {children} Export clips onRemove({ event, id: item.id })} + onClick={(event: React.MouseEvent) => + onRemove({ event, ids: idsToDelete }) + } > - Delete + {deleteLabel} @@ -304,27 +336,29 @@ function MediaItemList({ items, mode, onRemove, - highlightedId, - registerElement, }: { items: MediaAsset[]; mode: MediaViewMode; - onRemove: ({ event, id }: { event: React.MouseEvent; id: string }) => void; - highlightedId: string | null; - registerElement: (id: string, element: HTMLElement | null) => void; + onRemove: ({ + event, + ids, + }: { + event: React.MouseEvent; + ids: string[]; + }) => void; }) { const isGrid = mode === "grid"; return (
{items.map((item) => ( -
registerElement(item.id, element)}> - + + - -
+ + ))}
); } -export function formatDuration({ duration }: { duration: number }) { +function formatDuration({ duration }: { duration: number }) { const min = Math.floor(duration / 60); const sec = Math.floor(duration % 60); return `${min}:${sec.toString().padStart(2, "0")}`; @@ -406,7 +439,7 @@ function MediaPreview({ if (item.type === "image") { return ( -
+