feat(media): add Convex media archive with backfill and mixed-media library
This commit is contained in:
133
components/canvas/__tests__/asset-browser-panel.test.tsx
Normal file
133
components/canvas/__tests__/asset-browser-panel.test.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
182
components/canvas/__tests__/video-browser-panel.test.tsx
Normal file
182
components/canvas/__tests__/video-browser-panel.test.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -15,6 +15,16 @@ describe("media-preview-utils", () => {
|
||||
expect(ids).toEqual(["preview-1", "orig-1", "orig-2"]);
|
||||
});
|
||||
|
||||
it("collects only available storage ids for mixed archive items", () => {
|
||||
const ids = collectMediaStorageIdsForResolution([
|
||||
{ previewUrl: "https://cdn.example.com/preview-only.jpg" },
|
||||
{ previewStorageId: "preview-2" },
|
||||
{ storageId: "orig-2" },
|
||||
]);
|
||||
|
||||
expect(ids).toEqual(["preview-2", "orig-2"]);
|
||||
});
|
||||
|
||||
it("resolves preview url first and falls back to original url", () => {
|
||||
const previewFirst = resolveMediaPreviewUrl(
|
||||
{ storageId: "orig-1", previewStorageId: "preview-1" },
|
||||
@@ -35,4 +45,32 @@ describe("media-preview-utils", () => {
|
||||
|
||||
expect(fallbackToOriginal).toBe("https://cdn.example.com/original.jpg");
|
||||
});
|
||||
|
||||
it("resolves direct remote preview URLs before storage map", () => {
|
||||
const directPreview = resolveMediaPreviewUrl(
|
||||
{
|
||||
previewUrl: "https://cdn.example.com/direct-preview.webp",
|
||||
storageId: "orig-1",
|
||||
previewStorageId: "preview-1",
|
||||
},
|
||||
{
|
||||
"preview-1": "https://cdn.example.com/preview.webp",
|
||||
"orig-1": "https://cdn.example.com/original.jpg",
|
||||
},
|
||||
);
|
||||
|
||||
expect(directPreview).toBe("https://cdn.example.com/direct-preview.webp");
|
||||
});
|
||||
|
||||
it("falls back to direct remote original URLs when storage ids are missing", () => {
|
||||
const previewUrl = resolveMediaPreviewUrl(
|
||||
{
|
||||
kind: "video",
|
||||
originalUrl: "https://cdn.example.com/video.mp4",
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(previewUrl).toBe("https://cdn.example.com/video.mp4");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { AlertCircle, ImageIcon, Loader2 } from "lucide-react";
|
||||
import { AlertCircle, Box, ImageIcon, Loader2, Video } from "lucide-react";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
@@ -25,16 +25,22 @@ const MIN_LIMIT = 1;
|
||||
const MAX_LIMIT = 500;
|
||||
|
||||
export type MediaLibraryMetadataItem = {
|
||||
storageId: Id<"_storage">;
|
||||
kind: "image" | "video" | "asset";
|
||||
source: "upload" | "ai-image" | "ai-video" | "freepik-asset" | "pexels-video";
|
||||
storageId?: Id<"_storage">;
|
||||
previewStorageId?: Id<"_storage">;
|
||||
previewUrl?: string;
|
||||
originalUrl?: string;
|
||||
sourceUrl?: string;
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
sourceCanvasId: Id<"canvases">;
|
||||
sourceNodeId: Id<"nodes">;
|
||||
durationSeconds?: number;
|
||||
sourceCanvasId?: Id<"canvases">;
|
||||
sourceNodeId?: Id<"nodes">;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
@@ -49,6 +55,7 @@ export type MediaLibraryDialogProps = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
limit?: number;
|
||||
kindFilter?: "image" | "video" | "asset";
|
||||
pickCtaLabel?: string;
|
||||
};
|
||||
|
||||
@@ -68,26 +75,79 @@ function formatDimensions(width: number | undefined, height: number | undefined)
|
||||
return `${width} x ${height}px`;
|
||||
}
|
||||
|
||||
function formatMediaMeta(item: MediaLibraryItem): string {
|
||||
if (item.kind === "video") {
|
||||
if (typeof item.durationSeconds === "number" && Number.isFinite(item.durationSeconds)) {
|
||||
return `${Math.max(1, Math.round(item.durationSeconds))}s`;
|
||||
}
|
||||
return "Videodatei";
|
||||
}
|
||||
|
||||
return formatDimensions(item.width, item.height) ?? "Groesse unbekannt";
|
||||
}
|
||||
|
||||
function getItemKey(item: MediaLibraryItem): string {
|
||||
if (item.storageId) {
|
||||
return item.storageId;
|
||||
}
|
||||
|
||||
if (item.originalUrl) {
|
||||
return `url:${item.originalUrl}`;
|
||||
}
|
||||
|
||||
if (item.previewUrl) {
|
||||
return `preview:${item.previewUrl}`;
|
||||
}
|
||||
|
||||
if (item.sourceUrl) {
|
||||
return `source:${item.sourceUrl}`;
|
||||
}
|
||||
|
||||
return `${item.kind}:${item.createdAt}:${item.filename ?? "unnamed"}`;
|
||||
}
|
||||
|
||||
function getItemLabel(item: MediaLibraryItem): string {
|
||||
if (item.filename) {
|
||||
return item.filename;
|
||||
}
|
||||
|
||||
if (item.kind === "video") {
|
||||
return "Unbenanntes Video";
|
||||
}
|
||||
|
||||
if (item.kind === "asset") {
|
||||
return "Unbenanntes Asset";
|
||||
}
|
||||
|
||||
return "Unbenanntes Bild";
|
||||
}
|
||||
|
||||
export function MediaLibraryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onPick,
|
||||
title = "Mediathek",
|
||||
description = "Waehle ein Bild aus deiner LemonSpace-Mediathek.",
|
||||
description,
|
||||
limit,
|
||||
kindFilter,
|
||||
pickCtaLabel = "Auswaehlen",
|
||||
}: MediaLibraryDialogProps) {
|
||||
const normalizedLimit = useMemo(() => normalizeLimit(limit), [limit]);
|
||||
const metadata = useAuthQuery(
|
||||
api.dashboard.listMediaLibrary,
|
||||
open ? { limit: normalizedLimit } : "skip",
|
||||
open
|
||||
? {
|
||||
limit: normalizedLimit,
|
||||
...(kindFilter ? { kindFilter } : {}),
|
||||
}
|
||||
: "skip",
|
||||
);
|
||||
const resolveUrls = useMutation(api.storage.batchGetUrlsForUserMedia);
|
||||
|
||||
const [urlMap, setUrlMap] = useState<Record<string, string | undefined>>({});
|
||||
const [isResolvingUrls, setIsResolvingUrls] = useState(false);
|
||||
const [urlError, setUrlError] = useState<string | null>(null);
|
||||
const [pendingPickStorageId, setPendingPickStorageId] = useState<Id<"_storage"> | null>(null);
|
||||
const [pendingPickItemKey, setPendingPickItemKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
@@ -155,17 +215,22 @@ export function MediaLibraryDialog({
|
||||
const isMetadataLoading = open && metadata === undefined;
|
||||
const isInitialLoading = isMetadataLoading || (metadata !== undefined && isResolvingUrls);
|
||||
const isPreviewMode = typeof onPick !== "function";
|
||||
const effectiveDescription =
|
||||
description ??
|
||||
(kindFilter === "image"
|
||||
? "Waehle ein Bild aus deiner LemonSpace-Mediathek."
|
||||
: "Durchsuche deine Medien aus Uploads, KI-Generierung und Archivquellen.");
|
||||
|
||||
async function handlePick(item: MediaLibraryItem): Promise<void> {
|
||||
if (!onPick || pendingPickStorageId) {
|
||||
if (!onPick || pendingPickItemKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingPickStorageId(item.storageId);
|
||||
setPendingPickItemKey(getItemKey(item));
|
||||
try {
|
||||
await onPick(item);
|
||||
} finally {
|
||||
setPendingPickStorageId(null);
|
||||
setPendingPickItemKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +239,7 @@ export function MediaLibraryDialog({
|
||||
<DialogContent className="max-h-[85vh] sm:max-w-5xl" showCloseButton>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
<DialogDescription>{effectiveDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-[320px] overflow-y-auto pr-1">
|
||||
@@ -201,42 +266,58 @@ export function MediaLibraryDialog({
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Keine Medien vorhanden</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sobald du Bilder hochlaedst oder generierst, erscheinen sie hier.
|
||||
Sobald du Medien hochlaedst oder generierst, erscheinen sie hier.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{items.map((item) => {
|
||||
const dimensions = formatDimensions(item.width, item.height);
|
||||
const isPickingThis = pendingPickStorageId === item.storageId;
|
||||
const itemKey = getItemKey(item);
|
||||
const isPickingThis = pendingPickItemKey === itemKey;
|
||||
const itemLabel = getItemLabel(item);
|
||||
const metaLabel = formatMediaMeta(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.storageId}
|
||||
key={itemKey}
|
||||
className="group flex flex-col overflow-hidden rounded-lg border bg-card"
|
||||
>
|
||||
<div className="relative aspect-square bg-muted/50">
|
||||
{item.url ? (
|
||||
{item.url && item.kind === "video" ? (
|
||||
<video
|
||||
src={item.url}
|
||||
className="h-full w-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
) : item.url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.filename ?? "Mediathek-Bild"}
|
||||
alt={itemLabel}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
||||
<ImageIcon className="h-6 w-6" />
|
||||
{item.kind === "video" ? (
|
||||
<Video className="h-6 w-6" />
|
||||
) : item.kind === "asset" ? (
|
||||
<Box className="h-6 w-6" />
|
||||
) : (
|
||||
<ImageIcon className="h-6 w-6" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1 p-2">
|
||||
<p className="truncate text-xs font-medium" title={item.filename}>
|
||||
{item.filename ?? "Unbenanntes Bild"}
|
||||
<p className="truncate text-xs font-medium" title={itemLabel}>
|
||||
{itemLabel}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{dimensions ?? "Groesse unbekannt"}
|
||||
{metaLabel}
|
||||
</p>
|
||||
|
||||
{isPreviewMode ? (
|
||||
@@ -247,7 +328,7 @@ export function MediaLibraryDialog({
|
||||
size="sm"
|
||||
className="mt-2 h-7"
|
||||
onClick={() => void handlePick(item)}
|
||||
disabled={Boolean(pendingPickStorageId)}
|
||||
disabled={Boolean(pendingPickItemKey)}
|
||||
>
|
||||
{isPickingThis ? (
|
||||
<>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
type MediaPreviewReference<TStorageId extends string = string> = {
|
||||
storageId: TStorageId;
|
||||
kind?: "image" | "video" | "asset";
|
||||
storageId?: TStorageId;
|
||||
previewStorageId?: TStorageId;
|
||||
previewUrl?: string;
|
||||
originalUrl?: string;
|
||||
sourceUrl?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export function collectMediaStorageIdsForResolution<TStorageId extends string>(
|
||||
@@ -25,6 +30,10 @@ export function resolveMediaPreviewUrl(
|
||||
item: MediaPreviewReference,
|
||||
urlMap: Record<string, string | undefined>,
|
||||
): string | undefined {
|
||||
if (item.previewUrl) {
|
||||
return item.previewUrl;
|
||||
}
|
||||
|
||||
if (item.previewStorageId) {
|
||||
const previewUrl = urlMap[item.previewStorageId];
|
||||
if (previewUrl) {
|
||||
@@ -32,5 +41,21 @@ export function resolveMediaPreviewUrl(
|
||||
}
|
||||
}
|
||||
|
||||
if (item.originalUrl) {
|
||||
return item.originalUrl;
|
||||
}
|
||||
|
||||
if (item.sourceUrl) {
|
||||
return item.sourceUrl;
|
||||
}
|
||||
|
||||
if (item.url) {
|
||||
return item.url;
|
||||
}
|
||||
|
||||
if (!item.storageId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return urlMap[item.storageId];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user