diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-25 16:47:22 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:48:22 +0200 |
| commit | 8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch) | |
| tree | e9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/components/editor/panels/assets/views/assets.tsx | |
| parent | 39ea298a9ccba3104c549c0ca079c231981811f5 (diff) | |
| download | opencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz | |
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/components/editor/panels/assets/views/assets.tsx')
| -rw-r--r-- | apps/web/src/components/editor/panels/assets/views/assets.tsx | 153 |
1 files changed, 93 insertions, 60 deletions
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} /> ) : ( - <MediaItemList - items={filteredMediaItems} - mode={mediaViewMode} - onRemove={handleRemove} - highlightedId={highlightedId} - registerElement={registerElement} - /> + <SelectableSurface + ariaLabel="Assets" + orderedIds={orderedMediaIds} + revealId={highlightMediaId} + onRevealComplete={clearHighlight} + > + <MediaScopeRegistrar /> + <MediaItemList + items={filteredMediaItems} + mode={mediaViewMode} + onRemove={handleRemove} + /> + </SelectableSurface> )} </PanelView> </> ); } +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 ( <ContextMenu> - <ContextMenuTrigger>{children}</ContextMenuTrigger> + <ContextMenuTrigger asChild>{children}</ContextMenuTrigger> <ContextMenuContent> <ContextMenuItem>Export clips</ContextMenuItem> <ContextMenuItem variant="destructive" - onClick={(event) => onRemove({ event, id: item.id })} + onClick={(event: React.MouseEvent<HTMLDivElement>) => + onRemove({ event, ids: idsToDelete }) + } > - Delete + {deleteLabel} </ContextMenuItem> </ContextMenuContent> </ContextMenu> @@ -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 ( <div - className={cn(isGrid ? "grid gap-4" : "flex flex-col gap-1")} + className={cn(isGrid ? "grid gap-4" : "flex flex-col gap-1.5")} style={ isGrid ? { gridTemplateColumns: "repeat(auto-fill, 7rem)" } : undefined } > {items.map((item) => ( - <div key={item.id} ref={(element) => registerElement(item.id, element)}> - <MediaItemWithContextMenu item={item} onRemove={onRemove}> + <MediaItemWithContextMenu item={item} onRemove={onRemove} key={item.id}> + <SelectableItem className={cn(!isGrid && "w-full")} id={item.id}> <MediaAssetDraggable item={item} preview={ @@ -335,16 +369,15 @@ function MediaItemList({ } variant={isGrid ? "card" : "compact"} isRounded={isGrid ? false : undefined} - isHighlighted={highlightedId === item.id} /> - </MediaItemWithContextMenu> - </div> + </SelectableItem> + </MediaItemWithContextMenu> ))} </div> ); } -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 ( - <div className="relative flex size-full items-center justify-center"> + <div className="relative flex size-full items-center justify-center bg-muted"> <Image src={item.url ?? ""} alt={item.name} |
