summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/panels/assets/views/assets.tsx
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-25 16:47:22 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:48:22 +0200
commit8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch)
treee9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/components/editor/panels/assets/views/assets.tsx
parent39ea298a9ccba3104c549c0ca079c231981811f5 (diff)
downloadopencut-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.tsx153
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}