feat(media): add Convex media archive with backfill and mixed-media library

This commit is contained in:
2026-04-10 15:15:44 +02:00
parent ddb2412349
commit a1df097f9c
26 changed files with 2664 additions and 122 deletions

View File

@@ -0,0 +1,133 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildFreepikAssetDedupeKey } from "@/lib/media-archive";
const mocks = vi.hoisted(() => ({
searchFreepik: vi.fn(async () => ({ results: [], totalPages: 1, currentPage: 1 })),
upsertMedia: vi.fn(async () => undefined),
getNode: vi.fn(() => ({ id: "node-1", data: {} })),
queueNodeDataUpdate: vi.fn(async () => undefined),
queueNodeResize: vi.fn(async () => undefined),
}));
vi.mock("convex/react", () => ({
useAction: () => mocks.searchFreepik,
useMutation: () => mocks.upsertMedia,
}));
vi.mock("@xyflow/react", () => ({
useReactFlow: () => ({
getNode: mocks.getNode,
}),
}));
vi.mock("@/components/canvas/canvas-sync-context", () => ({
useCanvasSync: () => ({
queueNodeDataUpdate: mocks.queueNodeDataUpdate,
queueNodeResize: mocks.queueNodeResize,
status: { isOffline: false },
}),
}));
import { AssetBrowserPanel } from "@/components/canvas/asset-browser-panel";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("AssetBrowserPanel", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
mocks.searchFreepik.mockClear();
mocks.upsertMedia.mockClear();
mocks.getNode.mockClear();
mocks.queueNodeDataUpdate.mockClear();
mocks.queueNodeResize.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
});
}
container?.remove();
container = null;
root = null;
});
it("upserts selected Freepik asset into media archive", async () => {
const onClose = vi.fn();
const asset = {
id: 123,
title: "Forest texture",
assetType: "photo" as const,
previewUrl: "https://cdn.freepik.test/preview.jpg",
intrinsicWidth: 1600,
intrinsicHeight: 900,
sourceUrl: "https://www.freepik.com/asset/123",
license: "freemium" as const,
authorName: "Alice",
orientation: "landscape",
};
await act(async () => {
root?.render(
<AssetBrowserPanel
nodeId="node-1"
canvasId="canvas-1"
onClose={onClose}
initialState={{
term: "forest",
assetType: "photo",
results: [asset],
page: 1,
totalPages: 1,
}}
/>,
);
});
const selectButton = document.querySelector('[aria-label="Select asset: Forest texture"]');
if (!(selectButton instanceof HTMLButtonElement)) {
throw new Error("Asset select button not found");
}
await act(async () => {
selectButton.click();
});
expect(mocks.upsertMedia).toHaveBeenCalledTimes(1);
expect(mocks.upsertMedia).toHaveBeenCalledWith({
input: {
kind: "asset",
source: "freepik-asset",
dedupeKey: buildFreepikAssetDedupeKey("photo", 123),
title: "Forest texture",
previewUrl: "https://cdn.freepik.test/preview.jpg",
originalUrl: "https://cdn.freepik.test/preview.jpg",
sourceUrl: "https://www.freepik.com/asset/123",
providerAssetId: "123",
width: 1600,
height: 900,
metadata: {
provider: "freepik",
assetId: 123,
assetType: "photo",
license: "freemium",
authorName: "Alice",
orientation: "landscape",
},
firstSourceCanvasId: "canvas-1",
firstSourceNodeId: "node-1",
},
});
});
});

View File

@@ -8,6 +8,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Id } from "@/convex/_generated/dataModel";
import { CANVAS_NODE_DND_MIME } from "@/lib/canvas-connection-policy";
import { NODE_DEFAULTS } from "@/lib/canvas-utils";
import {
emitDashboardSnapshotCacheInvalidationSignal,
invalidateDashboardSnapshotForLastSignedInUser,
} from "@/lib/dashboard-snapshot-cache";
import { toast } from "@/lib/toast";
import { useCanvasDrop } from "@/components/canvas/use-canvas-drop";
import { createCompressedImagePreview } from "@/components/canvas/canvas-media-utils";
@@ -28,6 +32,11 @@ vi.mock("@/components/canvas/canvas-media-utils", () => ({
})),
}));
vi.mock("@/lib/dashboard-snapshot-cache", () => ({
invalidateDashboardSnapshotForLastSignedInUser: vi.fn(),
emitDashboardSnapshotCacheInvalidationSignal: vi.fn(),
}));
const latestHandlersRef: {
current: ReturnType<typeof useCanvasDrop> | null;
} = { current: null };
@@ -245,6 +254,62 @@ describe("useCanvasDrop", () => {
width: 1600,
height: 900,
});
expect(invalidateDashboardSnapshotForLastSignedInUser).toHaveBeenCalledTimes(1);
expect(emitDashboardSnapshotCacheInvalidationSignal).toHaveBeenCalledTimes(1);
});
it("registers dropped image media when node creation fails", async () => {
const registerUploadedImageMedia = vi.fn(async () => ({ ok: true as const }));
const runCreateNodeOnlineOnly = vi.fn(async () => {
throw new Error("create failed");
});
const syncPendingMoveForClientRequest = vi.fn(async () => undefined);
const file = new File(["image-bytes"], "photo.png", { type: "image/png" });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<HookHarness
registerUploadedImageMedia={registerUploadedImageMedia}
runCreateNodeOnlineOnly={runCreateNodeOnlineOnly}
syncPendingMoveForClientRequest={syncPendingMoveForClientRequest}
/>,
);
});
await act(async () => {
await latestHandlersRef.current?.onDrop({
preventDefault: vi.fn(),
clientX: 240,
clientY: 180,
dataTransfer: {
getData: vi.fn(() => ""),
files: [file],
},
} as unknown as React.DragEvent);
});
await act(async () => {
await Promise.resolve();
});
expect(syncPendingMoveForClientRequest).not.toHaveBeenCalled();
expect(registerUploadedImageMedia).toHaveBeenCalledWith({
canvasId: "canvas-1",
storageId: "storage-1",
filename: "photo.png",
mimeType: "image/png",
width: 1600,
height: 900,
});
expect(registerUploadedImageMedia).not.toHaveBeenCalledWith(
expect.objectContaining({ nodeId: expect.anything() }),
);
expect(invalidateDashboardSnapshotForLastSignedInUser).toHaveBeenCalledTimes(1);
expect(emitDashboardSnapshotCacheInvalidationSignal).toHaveBeenCalledTimes(1);
});
it("creates a node from a JSON payload drop", async () => {

View File

@@ -0,0 +1,182 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildPexelsVideoDedupeKey } from "@/lib/media-archive";
vi.mock("react", async () => {
const actual = await vi.importActual<typeof import("react")>("react");
let promotedInitialFalseState = false;
return {
...actual,
useState<T>(initial: T | (() => T)) {
if (!promotedInitialFalseState && initial === false) {
promotedInitialFalseState = true;
return actual.useState(true as T);
}
return actual.useState(initial);
},
};
});
const mocks = vi.hoisted(() => ({
searchVideos: vi.fn(async () => ({ videos: [], total_results: 0, per_page: 20 })),
popularVideos: vi.fn(async () => ({ videos: [], total_results: 0, per_page: 20 })),
upsertMedia: vi.fn(async () => undefined),
getNode: vi.fn(() => ({ id: "node-1", data: {} })),
queueNodeDataUpdate: vi.fn(async () => undefined),
queueNodeResize: vi.fn(async () => undefined),
}));
vi.mock("convex/react", () => ({
useAction: (() => {
let callIndex = 0;
return () => {
callIndex += 1;
return callIndex === 1 ? mocks.searchVideos : mocks.popularVideos;
};
})(),
useMutation: () => mocks.upsertMedia,
}));
vi.mock("@xyflow/react", () => ({
useReactFlow: () => ({
getNode: mocks.getNode,
}),
}));
vi.mock("@/components/canvas/canvas-sync-context", () => ({
useCanvasSync: () => ({
queueNodeDataUpdate: mocks.queueNodeDataUpdate,
queueNodeResize: mocks.queueNodeResize,
status: { isOffline: false },
}),
}));
import { VideoBrowserPanel } from "@/components/canvas/video-browser-panel";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("VideoBrowserPanel", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
mocks.searchVideos.mockClear();
mocks.popularVideos.mockClear();
mocks.upsertMedia.mockClear();
mocks.getNode.mockClear();
mocks.queueNodeDataUpdate.mockClear();
mocks.queueNodeResize.mockClear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount();
});
}
container?.remove();
container = null;
root = null;
});
it("upserts selected Pexels video into media archive", async () => {
const onClose = vi.fn();
const video = {
id: 987,
width: 1920,
height: 1080,
url: "https://www.pexels.com/video/987/",
image: "https://images.pexels.test/987.jpeg",
duration: 42,
user: {
id: 777,
name: "Filmmaker",
url: "https://www.pexels.com/@filmmaker",
},
video_files: [
{
id: 501,
quality: "hd" as const,
file_type: "video/mp4",
width: 1920,
height: 1080,
fps: 30,
link: "https://player.pexels.test/987-hd.mp4",
},
],
};
await act(async () => {
root?.render(
<VideoBrowserPanel
nodeId="node-1"
canvasId="canvas-1"
onClose={onClose}
initialState={{
term: "nature",
orientation: "",
durationFilter: "all",
results: [video],
page: 1,
totalPages: 1,
}}
/>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
const selectButton = Array.from(document.querySelectorAll("button")).find((button) =>
button.getAttribute("aria-label")?.includes("auswählen"),
);
if (!(selectButton instanceof HTMLButtonElement)) {
throw new Error("Video select button not found");
}
await act(async () => {
selectButton.click();
});
expect(mocks.upsertMedia).toHaveBeenCalledTimes(1);
expect(mocks.upsertMedia).toHaveBeenCalledWith({
input: {
kind: "video",
source: "pexels-video",
dedupeKey: buildPexelsVideoDedupeKey(987),
providerAssetId: "987",
originalUrl: "https://player.pexels.test/987-hd.mp4",
previewUrl: "https://images.pexels.test/987.jpeg",
sourceUrl: "https://www.pexels.com/video/987/",
width: 1920,
height: 1080,
durationSeconds: 42,
metadata: {
provider: "pexels",
videoId: 987,
userId: 777,
userName: "Filmmaker",
userUrl: "https://www.pexels.com/@filmmaker",
selectedFile: {
id: 501,
quality: "hd",
fileType: "video/mp4",
width: 1920,
height: 1080,
fps: 30,
},
},
firstSourceCanvasId: "canvas-1",
firstSourceNodeId: "node-1",
},
});
});
});

View File

@@ -10,7 +10,7 @@ import {
useState,
} from "react";
import { createPortal } from "react-dom";
import { useAction } from "convex/react";
import { useAction, useMutation } from "convex/react";
import { useReactFlow } from "@xyflow/react";
import { X, Search, Loader2, AlertCircle } from "lucide-react";
import { api } from "@/convex/_generated/api";
@@ -21,6 +21,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { computeMediaNodeSize } from "@/lib/canvas-utils";
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
import { buildFreepikAssetDedupeKey } from "@/lib/media-archive";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import { toast } from "@/lib/toast";
@@ -92,6 +93,7 @@ export function AssetBrowserPanel({
const [selectingAssetKey, setSelectingAssetKey] = useState<string | null>(null);
const searchFreepik = useAction(api.freepik.search);
const upsertMedia = useMutation(api.media.upsert);
const { getNode } = useReactFlow();
const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
const shouldSkipInitialSearchRef = useRef(Boolean(initialState?.results?.length));
@@ -234,6 +236,36 @@ export function AssetBrowserPanel({
width: targetSize.width,
height: targetSize.height,
});
try {
await upsertMedia({
input: {
kind: "asset",
source: "freepik-asset",
dedupeKey: buildFreepikAssetDedupeKey(asset.assetType, asset.id),
title: asset.title,
originalUrl: asset.previewUrl,
previewUrl: asset.previewUrl,
sourceUrl: asset.sourceUrl,
providerAssetId: String(asset.id),
width: asset.intrinsicWidth,
height: asset.intrinsicHeight,
metadata: {
provider: "freepik",
assetId: asset.id,
assetType: asset.assetType,
license: asset.license,
authorName: asset.authorName,
orientation: asset.orientation,
},
firstSourceCanvasId: canvasId as Id<"canvases">,
firstSourceNodeId: nodeId as Id<"nodes">,
},
});
} catch (mediaError) {
console.error("Failed to upsert Freepik media item", mediaError);
}
onClose();
} catch (error) {
console.error("Failed to select asset", error);
@@ -241,7 +273,7 @@ export function AssetBrowserPanel({
setSelectingAssetKey(null);
}
},
[canvasId, getNode, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline],
[canvasId, getNode, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline, upsertMedia],
);
const handlePreviousPage = useCallback(() => {

View File

@@ -376,6 +376,11 @@ export default function ImageNode({
return;
}
if (item.kind !== "image" || !item.storageId) {
toast.error(t('canvas.uploadFailed'), "Nur Bilddateien mit Storage-ID koennen uebernommen werden.");
return;
}
setMediaLibraryPhase("applying");
setPendingMediaLibraryStorageId(item.storageId);
@@ -644,6 +649,7 @@ export default function ImageNode({
open={isMediaLibraryOpen}
onOpenChange={setIsMediaLibraryOpen}
onPick={handlePickFromMediaLibrary}
kindFilter="image"
pickCtaLabel="Uebernehmen"
/>
</>

View File

@@ -10,7 +10,7 @@ import {
type PointerEvent,
} from "react";
import { createPortal } from "react-dom";
import { useAction } from "convex/react";
import { useAction, useMutation } from "convex/react";
import { useReactFlow } from "@xyflow/react";
import { X, Search, Loader2, AlertCircle, Play, Pause } from "lucide-react";
import { api } from "@/convex/_generated/api";
@@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button";
import type { PexelsVideo, PexelsVideoFile } from "@/lib/pexels-types";
import { pickPreviewVideoFile, pickVideoFile } from "@/lib/pexels-types";
import { preserveNodeFavorite } from "@/lib/canvas-node-favorite";
import { buildPexelsVideoDedupeKey } from "@/lib/media-archive";
import { toast } from "@/lib/toast";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
@@ -85,6 +86,7 @@ export function VideoBrowserPanel({
const searchVideos = useAction(api.pexels.searchVideos);
const popularVideos = useAction(api.pexels.popularVideos);
const upsertMedia = useMutation(api.media.upsert);
const { getNode } = useReactFlow();
const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
const shouldSkipInitialSearchRef = useRef(
@@ -253,6 +255,43 @@ export function VideoBrowserPanel({
width: targetWidth,
height: targetHeight,
});
try {
await upsertMedia({
input: {
kind: "video",
source: "pexels-video",
dedupeKey: buildPexelsVideoDedupeKey(video.id),
providerAssetId: String(video.id),
originalUrl: file.link,
previewUrl: video.image,
sourceUrl: video.url,
width: video.width,
height: video.height,
durationSeconds: video.duration,
metadata: {
provider: "pexels",
videoId: video.id,
userId: video.user.id,
userName: video.user.name,
userUrl: video.user.url,
selectedFile: {
id: file.id,
quality: file.quality,
fileType: file.file_type,
width: file.width,
height: file.height,
fps: file.fps,
},
},
firstSourceCanvasId: canvasId as Id<"canvases">,
firstSourceNodeId: nodeId as Id<"nodes">,
},
});
} catch (mediaError) {
console.error("Failed to upsert Pexels media item", mediaError);
}
onClose();
} catch (error) {
console.error("Failed to select video", error);
@@ -260,7 +299,7 @@ export function VideoBrowserPanel({
setSelectingVideoId(null);
}
},
[canvasId, getNode, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline],
[canvasId, getNode, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline, upsertMedia],
);
const handlePreviousPage = useCallback(() => {