feat: enhance canvas functionality with scissors mode and node template updates
- Implemented visual feedback and cursor changes for scissors mode in dark and light themes, improving user interaction during edge manipulation. - Updated node template picker to include new keywords for AI image generation, enhancing searchability. - Renamed and categorized node types for clarity, including updates to asset and prompt nodes. - Added support for video nodes and adjusted related components for improved media handling on the canvas.
This commit is contained in:
79
app/api/pexels-video/route.ts
Normal file
79
app/api/pexels-video/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const ALLOWED_HOSTS = new Set([
|
||||
"videos.pexels.com",
|
||||
"player.vimeo.com",
|
||||
"vod-progressive.pexels.com",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Proxies Pexels/Vimeo MP4 streams so playback works when the browser’s
|
||||
* Referer (e.g. localhost) would be rejected by the CDN.
|
||||
* Forwards Range for seeking; whitelists known video hosts from the Pexels API.
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const raw = request.nextUrl.searchParams.get("u");
|
||||
if (!raw) {
|
||||
return new NextResponse("Missing u", { status: 400 });
|
||||
}
|
||||
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(raw);
|
||||
} catch {
|
||||
return new NextResponse("Invalid URL", { status: 400 });
|
||||
}
|
||||
|
||||
if (target.protocol !== "https:") {
|
||||
return new NextResponse("HTTPS only", { status: 400 });
|
||||
}
|
||||
|
||||
if (!ALLOWED_HOSTS.has(target.hostname)) {
|
||||
return new NextResponse("Host not allowed", { status: 403 });
|
||||
}
|
||||
|
||||
const upstreamHeaders: HeadersInit = {
|
||||
Referer: "https://www.pexels.com/",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:128.0) Gecko/20100101 Firefox/128.0",
|
||||
};
|
||||
const range = request.headers.get("range");
|
||||
if (range) upstreamHeaders.Range = range;
|
||||
const ifRange = request.headers.get("if-range");
|
||||
if (ifRange) upstreamHeaders["If-Range"] = ifRange;
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(target.toString(), {
|
||||
headers: upstreamHeaders,
|
||||
redirect: "follow",
|
||||
});
|
||||
} catch {
|
||||
return new NextResponse("Upstream fetch failed", { status: 502 });
|
||||
}
|
||||
|
||||
const out = new Headers();
|
||||
const copy = [
|
||||
"content-type",
|
||||
"content-length",
|
||||
"accept-ranges",
|
||||
"content-range",
|
||||
"etag",
|
||||
"last-modified",
|
||||
"cache-control",
|
||||
] as const;
|
||||
for (const name of copy) {
|
||||
const v = upstream.headers.get(name);
|
||||
if (v) out.set(name, v);
|
||||
}
|
||||
|
||||
if (!upstream.body) {
|
||||
return new NextResponse(null, { status: upstream.status, headers: out });
|
||||
}
|
||||
|
||||
return new NextResponse(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: out,
|
||||
});
|
||||
}
|
||||
@@ -203,12 +203,54 @@
|
||||
stroke: rgba(189, 195, 199, 0.35);
|
||||
}
|
||||
|
||||
/* Scherenmodus: Scheren-Cursor (Teal, Fallback crosshair) */
|
||||
.react-flow.canvas-scissors-mode .react-flow__pane,
|
||||
.react-flow.canvas-scissors-mode .react-flow__edge-interaction {
|
||||
cursor:
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%230d9488' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='6' cy='6' r='3'/%3E%3Ccircle cx='6' cy='18' r='3'/%3E%3Cpath d='M20 4 8.12 15.88M14.47 14.48 20 20M8.12 8.12 12 12'/%3E%3C/svg%3E")
|
||||
12 12,
|
||||
crosshair;
|
||||
/*
|
||||
* Scherenmodus: Lucide „scissors-line-dashed“, 90° gegen Uhrzeigersinn (in SVG).
|
||||
* Dunkles Flow: helle Outline + heller Glow · helles Flow: dunkle Outline + dunkler Glow.
|
||||
*/
|
||||
.react-flow.dark.canvas-scissors-mode .react-flow__pane,
|
||||
.react-flow.dark.canvas-scissors-mode .react-flow__edge-interaction {
|
||||
cursor: url("/cursors/scissors-cursor-dark-canvas.svg") 12 12, crosshair;
|
||||
}
|
||||
|
||||
.react-flow:not(.dark).canvas-scissors-mode .react-flow__pane,
|
||||
.react-flow:not(.dark).canvas-scissors-mode .react-flow__edge-interaction {
|
||||
cursor: url("/cursors/scissors-cursor-light-canvas.svg") 12 12, crosshair;
|
||||
}
|
||||
|
||||
/* Scherenmodus: Hover auf Verbindung = Aufleuchten (Farben wie Scheren-Cursor) */
|
||||
.react-flow.dark.canvas-scissors-mode .react-flow__edge:not(.temp) .react-flow__edge-path {
|
||||
transition:
|
||||
stroke 0.12s ease,
|
||||
filter 0.12s ease;
|
||||
}
|
||||
|
||||
.react-flow.dark.canvas-scissors-mode
|
||||
.react-flow__edge:not(.temp):hover
|
||||
.react-flow__edge-path {
|
||||
stroke: #ffffff !important;
|
||||
filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.9))
|
||||
drop-shadow(0 0 10px rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
|
||||
.react-flow:not(.dark).canvas-scissors-mode
|
||||
.react-flow__edge:not(.temp)
|
||||
.react-flow__edge-path {
|
||||
transition:
|
||||
stroke 0.12s ease,
|
||||
filter 0.12s ease;
|
||||
}
|
||||
|
||||
.react-flow:not(.dark).canvas-scissors-mode
|
||||
.react-flow__edge:not(.temp):hover
|
||||
.react-flow__edge-path {
|
||||
stroke: #27272a !important;
|
||||
filter: drop-shadow(0 0 2px rgba(39, 39, 42, 0.75))
|
||||
drop-shadow(0 0 9px rgba(39, 39, 42, 0.4));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.react-flow.canvas-scissors-mode .react-flow__edge:not(.temp) .react-flow__edge-path {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const NODE_SEARCH_KEYWORDS: Partial<
|
||||
> = {
|
||||
image: ["image", "photo", "foto"],
|
||||
text: ["text", "typo"],
|
||||
prompt: ["prompt", "ai", "generate"],
|
||||
prompt: ["prompt", "ai", "generate", "ki-bild", "ki", "bild"],
|
||||
note: ["note", "sticky", "notiz"],
|
||||
frame: ["frame", "artboard"],
|
||||
compare: ["compare", "before", "after"],
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
const nodeTemplates = [
|
||||
{ type: "image", label: "Bild", icon: "🖼️", category: "Quelle" },
|
||||
{ type: "asset", label: "Asset", icon: "🛍️", category: "Quelle" },
|
||||
{ type: "asset", label: "FreePik", icon: "🛍️", category: "Quelle" },
|
||||
{ type: "video", label: "Pexels", icon: "🎬", category: "Quelle" },
|
||||
{ type: "text", label: "Text", icon: "📝", category: "Quelle" },
|
||||
{ type: "prompt", label: "Prompt", icon: "✨", category: "Quelle" },
|
||||
{ type: "prompt", label: "KI-Bild", icon: "✨", category: "Quelle" },
|
||||
{ type: "note", label: "Notiz", icon: "📌", category: "Layout" },
|
||||
{ type: "frame", label: "Frame", icon: "🖥️", category: "Layout" },
|
||||
{ type: "group", label: "Gruppe", icon: "📁", category: "Layout" },
|
||||
|
||||
@@ -240,6 +240,49 @@ function isEdgeCuttable(edge: RFEdge): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Abstand in px zwischen Abtastpunkten beim Durchschneiden (kleiner = zuverlässiger bei schnellen Bewegungen). */
|
||||
const SCISSORS_SEGMENT_SAMPLE_STEP_PX = 4;
|
||||
|
||||
function addCuttableEdgeIdAtClientPoint(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
edgesList: RFEdge[],
|
||||
strokeIds: Set<string>,
|
||||
): void {
|
||||
const id = getIntersectedEdgeId({ x: clientX, y: clientY });
|
||||
if (!id) return;
|
||||
const found = edgesList.find((e) => e.id === id);
|
||||
if (found && isEdgeCuttable(found)) strokeIds.add(id);
|
||||
}
|
||||
|
||||
/** Alle Kanten erfassen, deren Hit-Zone die Strecke von (x0,y0) nach (x1,y1) schneidet. */
|
||||
function collectCuttableEdgesAlongScreenSegment(
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
edgesList: RFEdge[],
|
||||
strokeIds: Set<string>,
|
||||
): void {
|
||||
const dx = x1 - x0;
|
||||
const dy = y1 - y0;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist < 0.5) {
|
||||
addCuttableEdgeIdAtClientPoint(x1, y1, edgesList, strokeIds);
|
||||
return;
|
||||
}
|
||||
const steps = Math.max(1, Math.ceil(dist / SCISSORS_SEGMENT_SAMPLE_STEP_PX));
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
addCuttableEdgeIdAtClientPoint(
|
||||
x0 + dx * t,
|
||||
y0 + dy * t,
|
||||
edgesList,
|
||||
strokeIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hasHandleKey(
|
||||
handles: { source?: string; target?: string } | undefined,
|
||||
key: "source" | "target",
|
||||
@@ -1703,13 +1746,29 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const nodeType = event.dataTransfer.getData(
|
||||
const rawData = event.dataTransfer.getData(
|
||||
"application/lemonspace-node-type",
|
||||
);
|
||||
if (!nodeType) {
|
||||
if (!rawData) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Support both plain type string (sidebar) and JSON payload (browser panels)
|
||||
let nodeType: string;
|
||||
let payloadData: Record<string, unknown> | undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawData);
|
||||
if (typeof parsed === "object" && parsed.type) {
|
||||
nodeType = parsed.type;
|
||||
payloadData = parsed.data;
|
||||
} else {
|
||||
nodeType = rawData;
|
||||
}
|
||||
} catch {
|
||||
nodeType = rawData;
|
||||
}
|
||||
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
@@ -1729,7 +1788,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
positionY: position.y,
|
||||
width: defaults.width,
|
||||
height: defaults.height,
|
||||
data: { ...defaults.data, canvasId },
|
||||
data: { ...defaults.data, ...payloadData, canvasId },
|
||||
clientRequestId,
|
||||
}).then((realId) => {
|
||||
syncPendingMoveForClientRequest(clientRequestId, realId);
|
||||
@@ -1798,13 +1857,19 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
setScissorStrokePreview(points);
|
||||
|
||||
const handleMove = (ev: PointerEvent) => {
|
||||
points.push({ x: ev.clientX, y: ev.clientY });
|
||||
const prev = points[points.length - 1]!;
|
||||
const nx = ev.clientX;
|
||||
const ny = ev.clientY;
|
||||
collectCuttableEdgesAlongScreenSegment(
|
||||
prev.x,
|
||||
prev.y,
|
||||
nx,
|
||||
ny,
|
||||
edgesRef.current,
|
||||
strokeIds,
|
||||
);
|
||||
points.push({ x: nx, y: ny });
|
||||
setScissorStrokePreview([...points]);
|
||||
const id = getIntersectedEdgeId({ x: ev.clientX, y: ev.clientY });
|
||||
if (id) {
|
||||
const found = edgesRef.current.find((ed) => ed.id === id);
|
||||
if (found && isEdgeCuttable(found)) strokeIds.add(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import FrameNode from "./nodes/frame-node";
|
||||
import NoteNode from "./nodes/note-node";
|
||||
import CompareNode from "./nodes/compare-node";
|
||||
import AssetNode from "./nodes/asset-node";
|
||||
import VideoNode from "./nodes/video-node";
|
||||
|
||||
/**
|
||||
* Node-Type-Map für React Flow.
|
||||
@@ -25,4 +26,5 @@ export const nodeTypes = {
|
||||
note: NoteNode,
|
||||
compare: CompareNode,
|
||||
asset: AssetNode,
|
||||
video: VideoNode,
|
||||
} as const;
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function AiImageNode({
|
||||
if (!canvasId) throw new Error("Missing canvasId");
|
||||
|
||||
const prompt = nodeData.prompt;
|
||||
if (!prompt) throw new Error("No prompt — use Generate from a Prompt node");
|
||||
if (!prompt) throw new Error("No prompt — use Generate from a KI-Bild node");
|
||||
|
||||
const edges = getEdges();
|
||||
const incomingEdges = edges.filter((e) => e.target === id);
|
||||
@@ -187,7 +187,7 @@ export default function AiImageNode({
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<ImageIcon className="h-10 w-10 opacity-30" />
|
||||
<p className="px-6 text-center text-xs opacity-60">
|
||||
Connect a Prompt node and click Generate
|
||||
Verbinde einen KI-Bild-Knoten und starte die Generierung dort.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -138,7 +138,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
||||
>
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
Asset
|
||||
FreePik
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -167,7 +167,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={data.title ?? "Asset preview"}
|
||||
alt={data.title ?? "FreePik-Vorschau"}
|
||||
className={`h-full w-full object-cover object-right transition-opacity ${
|
||||
isPreviewLoading ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
|
||||
@@ -17,6 +17,7 @@ const RESIZE_CONFIGS: Record<string, ResizeConfig> = {
|
||||
group: { minWidth: 150, minHeight: 100 },
|
||||
image: { minWidth: 140, minHeight: 120, keepAspectRatio: true },
|
||||
asset: { minWidth: 140, minHeight: 208, keepAspectRatio: false },
|
||||
video: { minWidth: 200, minHeight: 120, keepAspectRatio: true },
|
||||
// Chrome 88 + min. Viewport 120 → äußere Mindesthöhe 208 (siehe canvas onNodesChange)
|
||||
"ai-image": { minWidth: 200, minHeight: 208, keepAspectRatio: false },
|
||||
compare: { minWidth: 300, minHeight: 200 },
|
||||
|
||||
255
components/canvas/nodes/video-node.tsx
Normal file
255
components/canvas/nodes/video-node.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Handle, Position, useStore, type NodeProps } from "@xyflow/react";
|
||||
import { useAction, useMutation } from "convex/react";
|
||||
import { Play } from "lucide-react";
|
||||
import BaseNodeWrapper from "./base-node-wrapper";
|
||||
import {
|
||||
VideoBrowserPanel,
|
||||
type VideoBrowserSessionState,
|
||||
} from "@/components/canvas/video-browser-panel";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
|
||||
type VideoNodeData = {
|
||||
canvasId?: string;
|
||||
pexelsId?: number;
|
||||
mp4Url?: string;
|
||||
thumbnailUrl?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
attribution?: {
|
||||
userName: string;
|
||||
userUrl: string;
|
||||
videoUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function VideoNode({
|
||||
id,
|
||||
data,
|
||||
selected,
|
||||
width,
|
||||
height,
|
||||
}: NodeProps) {
|
||||
const d = data as VideoNodeData;
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [browserState, setBrowserState] = useState<VideoBrowserSessionState>({
|
||||
term: "",
|
||||
orientation: "",
|
||||
durationFilter: "all",
|
||||
results: [],
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
const resizeNode = useMutation(api.nodes.resize);
|
||||
const updateData = useMutation(api.nodes.updateData);
|
||||
const refreshPexelsPlayback = useAction(api.pexels.getVideoByPexelsId);
|
||||
|
||||
const edges = useStore((s) => s.edges);
|
||||
const nodes = useStore((s) => s.nodes);
|
||||
|
||||
const linkedSearchTerm = useMemo(() => {
|
||||
const incoming = edges.filter((e) => e.target === id);
|
||||
for (const edge of incoming) {
|
||||
const sourceNode = nodes.find((n) => n.id === edge.source);
|
||||
if (sourceNode?.type !== "text") continue;
|
||||
const content = (sourceNode.data as { content?: string }).content;
|
||||
if (typeof content === "string" && content.trim().length > 0) {
|
||||
return content.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}, [edges, id, nodes]);
|
||||
|
||||
const openVideoBrowser = useCallback(() => {
|
||||
setBrowserState((s) =>
|
||||
linkedSearchTerm
|
||||
? { ...s, term: linkedSearchTerm, results: [], page: 1, totalPages: 1 }
|
||||
: s,
|
||||
);
|
||||
setPanelOpen(true);
|
||||
}, [linkedSearchTerm]);
|
||||
|
||||
const hasVideo = Boolean(d.mp4Url && d.thumbnailUrl);
|
||||
|
||||
const hasAutoSizedRef = useRef(false);
|
||||
const playbackRefreshAttempted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
playbackRefreshAttempted.current = false;
|
||||
}, [d.mp4Url]);
|
||||
|
||||
const handleVideoError = useCallback(() => {
|
||||
const pexelsId = d.pexelsId;
|
||||
if (pexelsId == null || playbackRefreshAttempted.current) return;
|
||||
playbackRefreshAttempted.current = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const fresh = await refreshPexelsPlayback({ pexelsId });
|
||||
await updateData({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: {
|
||||
...d,
|
||||
mp4Url: fresh.mp4Url,
|
||||
width: fresh.width,
|
||||
height: fresh.height,
|
||||
duration: fresh.duration,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
playbackRefreshAttempted.current = false;
|
||||
}
|
||||
})();
|
||||
}, [d, id, refreshPexelsPlayback, updateData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasVideo) return;
|
||||
if (hasAutoSizedRef.current) return;
|
||||
|
||||
const w = d.width;
|
||||
const h = d.height;
|
||||
if (typeof w !== "number" || typeof h !== "number" || w <= 0 || h <= 0)
|
||||
return;
|
||||
|
||||
const currentWidth = typeof width === "number" ? width : 0;
|
||||
const currentHeight = typeof height === "number" ? height : 0;
|
||||
if (currentWidth <= 0 || currentHeight <= 0) return;
|
||||
|
||||
if (currentWidth !== 320 || currentHeight !== 180) {
|
||||
hasAutoSizedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
hasAutoSizedRef.current = true;
|
||||
const aspectRatio = w / h;
|
||||
const targetWidth = 320;
|
||||
const targetHeight = Math.round(targetWidth / aspectRatio);
|
||||
|
||||
void resizeNode({
|
||||
nodeId: id as Id<"nodes">,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
});
|
||||
}, [d.width, d.height, hasVideo, height, id, resizeNode, width]);
|
||||
|
||||
const showPreview = hasVideo && d.thumbnailUrl;
|
||||
|
||||
const playbackSrc =
|
||||
d.mp4Url != null && d.mp4Url.length > 0
|
||||
? `/api/pexels-video?u=${encodeURIComponent(d.mp4Url)}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper nodeType="video" selected={selected}>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="h-3! w-3! border-2! border-background! bg-primary!"
|
||||
/>
|
||||
|
||||
<div className="flex h-full min-h-0 w-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
Pexels
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openVideoBrowser}
|
||||
className={`nodrag h-6 rounded px-2 text-xs transition-colors ${
|
||||
hasVideo
|
||||
? "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
}`}
|
||||
>
|
||||
{hasVideo ? "Change" : "Browse Videos"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content: flex-1 + min-h-0 keeps media inside the node; avoid aspect-ratio here (grid overflow). */}
|
||||
{showPreview ? (
|
||||
<>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden bg-muted/30">
|
||||
<video
|
||||
key={d.mp4Url}
|
||||
src={playbackSrc}
|
||||
poster={d.thumbnailUrl}
|
||||
className="nodrag h-full w-full object-cover"
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
onError={handleVideoError}
|
||||
/>
|
||||
|
||||
{typeof d.duration === "number" && d.duration > 0 && (
|
||||
<div className="pointer-events-none absolute top-1.5 right-1.5 rounded bg-black/60 px-1.5 py-0.5 text-xs font-medium text-white tabular-nums">
|
||||
{formatDuration(d.duration)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Attribution */}
|
||||
{d.attribution ? (
|
||||
<div className="flex shrink-0 flex-col gap-1 px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
by {d.attribution.userName}
|
||||
</span>
|
||||
<a
|
||||
href={d.attribution.videoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shrink-0 text-[10px] text-muted-foreground underline hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="shrink-0 px-3 py-2" />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-col items-center justify-center gap-3 px-4 py-8 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Play className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium">No video selected</p>
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
Browse free stock videos from Pexels
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Video browser modal */}
|
||||
{panelOpen && d.canvasId ? (
|
||||
<VideoBrowserPanel
|
||||
nodeId={id}
|
||||
canvasId={d.canvasId}
|
||||
initialState={browserState}
|
||||
onStateChange={setBrowserState}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="h-3! w-3! border-2! border-background! bg-primary!"
|
||||
/>
|
||||
</BaseNodeWrapper>
|
||||
);
|
||||
}
|
||||
564
components/canvas/video-browser-panel.tsx
Normal file
564
components/canvas/video-browser-panel.tsx
Normal file
@@ -0,0 +1,564 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent,
|
||||
type PointerEvent,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useAction, useMutation } from "convex/react";
|
||||
import { X, Search, Loader2, AlertCircle, Play, Pause } from "lucide-react";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Id } from "@/convex/_generated/dataModel";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { PexelsVideo, PexelsVideoFile } from "@/lib/pexels-types";
|
||||
import { pickPreviewVideoFile, pickVideoFile } from "@/lib/pexels-types";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
type Orientation = "" | "landscape" | "portrait" | "square";
|
||||
type DurationFilter = "all" | "short" | "medium" | "long";
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function pexelsVideoProxySrc(mp4Url: string): string {
|
||||
return `/api/pexels-video?u=${encodeURIComponent(mp4Url)}`;
|
||||
}
|
||||
|
||||
export interface VideoBrowserSessionState {
|
||||
term: string;
|
||||
orientation: Orientation;
|
||||
durationFilter: DurationFilter;
|
||||
results: PexelsVideo[];
|
||||
page: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
nodeId: string;
|
||||
canvasId: string;
|
||||
onClose: () => void;
|
||||
initialState?: VideoBrowserSessionState;
|
||||
onStateChange?: (state: VideoBrowserSessionState) => void;
|
||||
}
|
||||
|
||||
export function VideoBrowserPanel({
|
||||
nodeId,
|
||||
canvasId,
|
||||
onClose,
|
||||
initialState,
|
||||
onStateChange,
|
||||
}: Props) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [term, setTerm] = useState(initialState?.term ?? "");
|
||||
const [debouncedTerm, setDebouncedTerm] = useState(initialState?.term ?? "");
|
||||
const [orientation, setOrientation] = useState<Orientation>(
|
||||
initialState?.orientation ?? "",
|
||||
);
|
||||
const [durationFilter, setDurationFilter] = useState<DurationFilter>(
|
||||
initialState?.durationFilter ?? "all",
|
||||
);
|
||||
const [results, setResults] = useState<PexelsVideo[]>(
|
||||
initialState?.results ?? [],
|
||||
);
|
||||
const [page, setPage] = useState(initialState?.page ?? 1);
|
||||
const [totalPages, setTotalPages] = useState(initialState?.totalPages ?? 1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [selectingVideoId, setSelectingVideoId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [previewingVideoId, setPreviewingVideoId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const searchVideos = useAction(api.pexels.searchVideos);
|
||||
const popularVideos = useAction(api.pexels.popularVideos);
|
||||
const updateData = useMutation(api.nodes.updateData);
|
||||
const resizeNode = useMutation(api.nodes.resize);
|
||||
const shouldSkipInitialSearchRef = useRef(
|
||||
Boolean(initialState?.results?.length),
|
||||
);
|
||||
const requestSequenceRef = useRef(0);
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const isSelecting = selectingVideoId !== null;
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
return () => setIsMounted(false);
|
||||
}, []);
|
||||
|
||||
// Debounce
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setDebouncedTerm(term), 400);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [term]);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewingVideoId(null);
|
||||
}, [debouncedTerm, orientation, durationFilter, page]);
|
||||
|
||||
// Escape
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
// Session state sync
|
||||
useEffect(() => {
|
||||
if (!onStateChange) return;
|
||||
onStateChange({ term, orientation, durationFilter, results, page, totalPages });
|
||||
}, [durationFilter, onStateChange, orientation, page, results, term, totalPages]);
|
||||
|
||||
const getDurationParams = useCallback(
|
||||
(filter: DurationFilter): { minDuration?: number; maxDuration?: number } => {
|
||||
switch (filter) {
|
||||
case "short":
|
||||
return { maxDuration: 30 };
|
||||
case "medium":
|
||||
return { minDuration: 30, maxDuration: 60 };
|
||||
case "long":
|
||||
return { minDuration: 60 };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const runSearch = useCallback(
|
||||
async (searchTerm: string, requestedPage: number) => {
|
||||
const seq = ++requestSequenceRef.current;
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const isSearch = searchTerm.trim().length > 0;
|
||||
const durationParams = getDurationParams(durationFilter);
|
||||
|
||||
const response = isSearch
|
||||
? await searchVideos({
|
||||
query: searchTerm,
|
||||
orientation: orientation || undefined,
|
||||
page: requestedPage,
|
||||
perPage: 20,
|
||||
...durationParams,
|
||||
})
|
||||
: await popularVideos({
|
||||
page: requestedPage,
|
||||
perPage: 20,
|
||||
});
|
||||
|
||||
if (seq !== requestSequenceRef.current) return;
|
||||
|
||||
const videos = response.videos ?? [];
|
||||
setResults(videos);
|
||||
setPage(requestedPage);
|
||||
|
||||
// Estimate total pages from next_page presence
|
||||
const estimatedTotal = response.total_results ?? videos.length * requestedPage;
|
||||
const perPage = response.per_page ?? 20;
|
||||
setTotalPages(Math.max(1, Math.ceil(estimatedTotal / perPage)));
|
||||
|
||||
if (scrollAreaRef.current) scrollAreaRef.current.scrollTop = 0;
|
||||
} catch (error) {
|
||||
if (seq !== requestSequenceRef.current) return;
|
||||
console.error("Pexels video search error", error);
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "Search failed",
|
||||
);
|
||||
} finally {
|
||||
if (seq === requestSequenceRef.current) setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[searchVideos, popularVideos, orientation, durationFilter, getDurationParams],
|
||||
);
|
||||
|
||||
// Trigger search
|
||||
useEffect(() => {
|
||||
if (shouldSkipInitialSearchRef.current) {
|
||||
shouldSkipInitialSearchRef.current = false;
|
||||
return;
|
||||
}
|
||||
void runSearch(debouncedTerm, 1);
|
||||
}, [debouncedTerm, orientation, durationFilter, runSearch]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (video: PexelsVideo) => {
|
||||
if (isSelecting) return;
|
||||
setSelectingVideoId(video.id);
|
||||
let file: PexelsVideoFile;
|
||||
try {
|
||||
file = pickVideoFile(video.video_files);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Videoformat nicht verfügbar",
|
||||
);
|
||||
setSelectingVideoId(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateData({
|
||||
nodeId: nodeId as Id<"nodes">,
|
||||
data: {
|
||||
pexelsId: video.id,
|
||||
mp4Url: file.link,
|
||||
thumbnailUrl: video.image,
|
||||
width: video.width,
|
||||
height: video.height,
|
||||
duration: video.duration,
|
||||
attribution: {
|
||||
userName: video.user.name,
|
||||
userUrl: video.user.url,
|
||||
videoUrl: video.url,
|
||||
},
|
||||
canvasId,
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-resize to match aspect ratio
|
||||
const aspectRatio =
|
||||
video.width > 0 && video.height > 0
|
||||
? video.width / video.height
|
||||
: 16 / 9;
|
||||
const targetWidth = 320;
|
||||
const targetHeight = Math.round(targetWidth / aspectRatio);
|
||||
await resizeNode({
|
||||
nodeId: nodeId as Id<"nodes">,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to select video", error);
|
||||
} finally {
|
||||
setSelectingVideoId(null);
|
||||
}
|
||||
},
|
||||
[canvasId, isSelecting, nodeId, onClose, resizeNode, updateData],
|
||||
);
|
||||
|
||||
const handlePreviousPage = useCallback(() => {
|
||||
if (isLoading || page <= 1) return;
|
||||
void runSearch(debouncedTerm, page - 1);
|
||||
}, [debouncedTerm, isLoading, page, runSearch]);
|
||||
|
||||
const handleNextPage = useCallback(() => {
|
||||
if (isLoading || page >= totalPages) return;
|
||||
void runSearch(debouncedTerm, page + 1);
|
||||
}, [debouncedTerm, isLoading, page, runSearch, totalPages]);
|
||||
|
||||
const modal = useMemo(
|
||||
() => (
|
||||
<div
|
||||
className="nowheel nodrag nopan fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
onWheelCapture={(event) => event.stopPropagation()}
|
||||
onPointerDownCapture={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="nowheel nodrag nopan relative flex max-h-[80vh] w-[720px] flex-col overflow-hidden rounded-xl border bg-background shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onWheelCapture={(event) => event.stopPropagation()}
|
||||
onPointerDownCapture={(event) => event.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Browse Pexels videos"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b px-5 py-4">
|
||||
<h2 className="text-sm font-semibold">Browse Pexels Videos</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Close video browser"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search + Filters */}
|
||||
<div className="flex shrink-0 flex-col gap-3 border-b px-5 py-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search videos..."
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
className="pl-9"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Format:</span>
|
||||
{(["", "landscape", "portrait", "square"] as const).map((o) => (
|
||||
<button
|
||||
key={o || "all"}
|
||||
type="button"
|
||||
onClick={() => setOrientation(o)}
|
||||
className={`rounded px-2 py-0.5 text-xs transition-colors ${
|
||||
orientation === o
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
{o === "" ? "Alle" : o === "landscape" ? "Quer" : o === "portrait" ? "Hoch" : "Quadrat"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Dauer:</span>
|
||||
{(
|
||||
[
|
||||
["all", "Alle"],
|
||||
["short", "<30s"],
|
||||
["medium", "30–60s"],
|
||||
["long", ">60s"],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setDurationFilter(key)}
|
||||
className={`rounded px-2 py-0.5 text-xs transition-colors ${
|
||||
durationFilter === key
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div
|
||||
ref={scrollAreaRef}
|
||||
className="nowheel nodrag nopan flex-1 overflow-y-auto p-5"
|
||||
onWheelCapture={(event) => event.stopPropagation()}
|
||||
>
|
||||
{isLoading && results.length === 0 ? (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{Array.from({ length: 12 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="aspect-video animate-pulse rounded-lg bg-muted"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center text-muted-foreground">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<p className="text-sm text-foreground">Search failed</p>
|
||||
<p className="max-w-md text-xs">{errorMessage}</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void runSearch(debouncedTerm, page)}
|
||||
>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center text-muted-foreground">
|
||||
<Search className="h-8 w-8" />
|
||||
<p className="text-sm">
|
||||
{term.trim() ? "Keine Videos gefunden" : "Videos werden geladen..."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{results.map((video) => {
|
||||
const isSelectingThis = selectingVideoId === video.id;
|
||||
const previewFile = pickPreviewVideoFile(video.video_files);
|
||||
const previewSrc = previewFile
|
||||
? pexelsVideoProxySrc(previewFile.link)
|
||||
: null;
|
||||
const isPreview = previewingVideoId === video.id;
|
||||
const stopBubbling = (e: PointerEvent | MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={video.id}
|
||||
className="group relative aspect-video overflow-hidden rounded-lg border-2 border-transparent bg-muted transition-all hover:border-primary focus-within:border-primary"
|
||||
title={`${video.user.name} — ${formatDuration(video.duration)}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSelecting}
|
||||
onClick={() => void handleSelect(video)}
|
||||
aria-busy={isSelectingThis}
|
||||
aria-label={`Video von ${video.user.name} auswählen`}
|
||||
className={`absolute inset-0 z-0 rounded-[inherit] border-0 bg-transparent p-0 transition-colors outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed ${
|
||||
isPreview ? "pointer-events-none" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<span className="sr-only">Auswählen</span>
|
||||
</button>
|
||||
{isPreview && previewSrc ? (
|
||||
<video
|
||||
key={previewSrc}
|
||||
src={previewSrc}
|
||||
className="nodrag absolute inset-0 z-15 h-full w-full object-cover"
|
||||
controls
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
autoPlay
|
||||
preload="metadata"
|
||||
onPointerDownCapture={stopBubbling}
|
||||
/>
|
||||
) : (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={video.image}
|
||||
alt=""
|
||||
className="pointer-events-none absolute inset-0 z-1 h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
{previewSrc ? (
|
||||
<button
|
||||
type="button"
|
||||
className="nodrag pointer-events-auto absolute bottom-1 left-1 z-20 flex h-8 w-8 items-center justify-center rounded-md bg-black/65 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
|
||||
aria-label={isPreview ? "Vorschau beenden" : "Vorschau abspielen"}
|
||||
onPointerDown={stopBubbling}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPreviewingVideoId((cur) =>
|
||||
cur === video.id ? null : video.id,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{isPreview ? (
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5 fill-current" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{isPreview ? (
|
||||
<button
|
||||
type="button"
|
||||
className="nodrag pointer-events-auto absolute top-1 right-1 z-20 rounded-md bg-black/65 px-2 py-1 text-[10px] font-medium text-white backdrop-blur-sm hover:bg-black/80"
|
||||
onPointerDown={stopBubbling}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void handleSelect(video);
|
||||
}}
|
||||
>
|
||||
Auswählen
|
||||
</button>
|
||||
) : null}
|
||||
<div className="pointer-events-none absolute bottom-1 right-1 z-12 rounded bg-black/60 px-1 py-0.5 text-[9px] font-medium text-white tabular-nums">
|
||||
{formatDuration(video.duration)}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 z-11 bg-black/0 transition-colors group-hover:bg-black/10" />
|
||||
{isSelectingThis ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-25 flex flex-col items-center justify-center gap-1 bg-black/55 text-[11px] text-white">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Anwenden...
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex shrink-0 flex-col gap-3 border-t px-5 py-3">
|
||||
{results.length > 0 ? (
|
||||
<div className="flex items-center justify-center gap-2" aria-live="polite">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={isLoading || page <= 1}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Seite {page} von {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={isLoading || page >= totalPages}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
|
||||
Laden...
|
||||
</>
|
||||
) : (
|
||||
"Weiter"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Videos by{" "}
|
||||
<a
|
||||
href="https://www.pexels.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline transition-colors hover:text-foreground"
|
||||
>
|
||||
Pexels
|
||||
</a>
|
||||
. Free to use, attribution appreciated.
|
||||
</p>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{results.length > 0 ? `${results.length} Videos` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
[
|
||||
debouncedTerm,
|
||||
durationFilter,
|
||||
errorMessage,
|
||||
handleNextPage,
|
||||
handlePreviousPage,
|
||||
handleSelect,
|
||||
isLoading,
|
||||
isSelecting,
|
||||
onClose,
|
||||
orientation,
|
||||
page,
|
||||
results,
|
||||
runSearch,
|
||||
previewingVideoId,
|
||||
selectingVideoId,
|
||||
term,
|
||||
totalPages,
|
||||
],
|
||||
);
|
||||
|
||||
if (!isMounted) return null;
|
||||
return createPortal(modal, document.body);
|
||||
}
|
||||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -19,6 +19,7 @@ import type * as helpers from "../helpers.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as nodes from "../nodes.js";
|
||||
import type * as openrouter from "../openrouter.js";
|
||||
import type * as pexels from "../pexels.js";
|
||||
import type * as polar from "../polar.js";
|
||||
import type * as storage from "../storage.js";
|
||||
|
||||
@@ -40,6 +41,7 @@ declare const fullApi: ApiFromModules<{
|
||||
http: typeof http;
|
||||
nodes: typeof nodes;
|
||||
openrouter: typeof openrouter;
|
||||
pexels: typeof pexels;
|
||||
polar: typeof polar;
|
||||
storage: typeof storage;
|
||||
}>;
|
||||
|
||||
164
convex/pexels.ts
Normal file
164
convex/pexels.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
"use node";
|
||||
|
||||
import { v } from "convex/values";
|
||||
import { action } from "./_generated/server";
|
||||
|
||||
/** Canonical API base (legacy /videos/ ohne /v1/ ist deprecated laut Pexels-Doku). */
|
||||
const PEXELS_VIDEO_API = "https://api.pexels.com/v1/videos";
|
||||
|
||||
interface PexelsVideoFile {
|
||||
id: number;
|
||||
quality: "hd" | "sd" | "uhd" | "hls";
|
||||
file_type: string;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
link: string;
|
||||
}
|
||||
|
||||
interface PexelsVideo {
|
||||
id: number;
|
||||
width: number;
|
||||
height: number;
|
||||
url: string;
|
||||
image: string;
|
||||
duration: number;
|
||||
user: { id: number; name: string; url: string };
|
||||
video_files: PexelsVideoFile[];
|
||||
}
|
||||
|
||||
function pickPlayableVideoFile(files: PexelsVideoFile[]): PexelsVideoFile {
|
||||
const playable = files.filter((f) => {
|
||||
if (f.quality === "hls") return false;
|
||||
const url = f.link.toLowerCase();
|
||||
if (url.includes(".m3u8")) return false;
|
||||
return url.includes(".mp4");
|
||||
});
|
||||
if (playable.length === 0) {
|
||||
throw new Error("No progressive MP4 in Pexels video_files");
|
||||
}
|
||||
return (
|
||||
playable.find((f) => f.quality === "hd") ??
|
||||
playable.find((f) => f.quality === "uhd") ??
|
||||
playable.find((f) => f.quality === "sd") ??
|
||||
playable[0]
|
||||
);
|
||||
}
|
||||
|
||||
/** Frische MP4-URL (Signing kann ablaufen) — gleiche Auswahl wie beim ersten Pick. */
|
||||
export const getVideoByPexelsId = action({
|
||||
args: { pexelsId: v.number() },
|
||||
handler: async (_ctx, { pexelsId }) => {
|
||||
const apiKey = process.env.PEXELS_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("PEXELS_API_KEY not set");
|
||||
}
|
||||
|
||||
const res = await fetch(`${PEXELS_VIDEO_API}/${pexelsId}`, {
|
||||
headers: { Authorization: apiKey },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Pexels API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const video = (await res.json()) as PexelsVideo;
|
||||
const file = pickPlayableVideoFile(video.video_files);
|
||||
return {
|
||||
mp4Url: file.link,
|
||||
width: video.width,
|
||||
height: video.height,
|
||||
duration: video.duration,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const searchVideos = action({
|
||||
args: {
|
||||
query: v.string(),
|
||||
orientation: v.optional(
|
||||
v.union(
|
||||
v.literal("landscape"),
|
||||
v.literal("portrait"),
|
||||
v.literal("square"),
|
||||
),
|
||||
),
|
||||
minDuration: v.optional(v.number()),
|
||||
maxDuration: v.optional(v.number()),
|
||||
page: v.optional(v.number()),
|
||||
perPage: v.optional(v.number()),
|
||||
},
|
||||
handler: async (_ctx, args) => {
|
||||
const apiKey = process.env.PEXELS_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("PEXELS_API_KEY not set");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
query: args.query,
|
||||
per_page: String(args.perPage ?? 20),
|
||||
page: String(args.page ?? 1),
|
||||
...(args.orientation && { orientation: args.orientation }),
|
||||
...(args.minDuration != null && {
|
||||
min_duration: String(args.minDuration),
|
||||
}),
|
||||
...(args.maxDuration != null && {
|
||||
max_duration: String(args.maxDuration),
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await fetch(`${PEXELS_VIDEO_API}/search?${params}`, {
|
||||
headers: { Authorization: apiKey },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Pexels API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
videos: PexelsVideo[];
|
||||
total_results: number;
|
||||
next_page?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
};
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
export const popularVideos = action({
|
||||
args: {
|
||||
page: v.optional(v.number()),
|
||||
perPage: v.optional(v.number()),
|
||||
},
|
||||
handler: async (_ctx, args) => {
|
||||
const apiKey = process.env.PEXELS_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("PEXELS_API_KEY not set");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
per_page: String(args.perPage ?? 20),
|
||||
page: String(args.page ?? 1),
|
||||
});
|
||||
|
||||
const res = await fetch(`${PEXELS_VIDEO_API}/popular?${params}`, {
|
||||
headers: { Authorization: apiKey },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Pexels API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
videos: PexelsVideo[];
|
||||
total_results?: number;
|
||||
next_page?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
};
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
@@ -15,7 +15,7 @@ export const CANVAS_NODE_TEMPLATES = [
|
||||
},
|
||||
{
|
||||
type: "prompt",
|
||||
label: "Prompt",
|
||||
label: "KI-Bild",
|
||||
width: 320,
|
||||
height: 220,
|
||||
defaultData: { prompt: "", model: "", aspectRatio: "1:1" },
|
||||
|
||||
@@ -101,6 +101,7 @@ const SOURCE_NODE_GLOW_RGB: Record<string, readonly [number, number, number]> =
|
||||
text: [13, 148, 136],
|
||||
note: [13, 148, 136],
|
||||
asset: [13, 148, 136],
|
||||
video: [13, 148, 136],
|
||||
group: [100, 116, 139],
|
||||
frame: [249, 115, 22],
|
||||
compare: [100, 116, 139],
|
||||
@@ -185,6 +186,7 @@ export const NODE_HANDLE_MAP: Record<
|
||||
note: { source: undefined, target: undefined },
|
||||
compare: { source: "compare-out", target: "left" },
|
||||
asset: { source: undefined, target: undefined },
|
||||
video: { source: undefined, target: undefined },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -208,6 +210,7 @@ export const NODE_DEFAULTS: Record<
|
||||
note: { width: 208, height: 100, data: { content: "" } },
|
||||
compare: { width: 500, height: 380, data: {} },
|
||||
asset: { width: 260, height: 240, data: {} },
|
||||
video: { width: 320, height: 180, data: {} },
|
||||
};
|
||||
|
||||
type MediaNodeKind = "asset" | "image";
|
||||
|
||||
81
lib/pexels-types.ts
Normal file
81
lib/pexels-types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export interface PexelsVideoFile {
|
||||
id: number;
|
||||
/** Pexels liefert u. a. `hls` mit `.m3u8` — nicht für `<video src>`. */
|
||||
quality: "hd" | "sd" | "uhd" | "hls";
|
||||
file_type: string;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface PexelsVideo {
|
||||
id: number;
|
||||
width: number;
|
||||
height: number;
|
||||
url: string;
|
||||
image: string;
|
||||
duration: number;
|
||||
user: {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
video_files: PexelsVideoFile[];
|
||||
}
|
||||
|
||||
export interface VideoNodeData {
|
||||
canvasId?: string;
|
||||
pexelsId?: number;
|
||||
mp4Url?: string;
|
||||
thumbnailUrl?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
attribution?: {
|
||||
userName: string;
|
||||
userUrl: string;
|
||||
videoUrl: string;
|
||||
};
|
||||
}
|
||||
|
||||
function isProgressiveMp4Candidate(f: PexelsVideoFile): boolean {
|
||||
if (f.quality === "hls") return false;
|
||||
const url = f.link.toLowerCase();
|
||||
if (url.includes(".m3u8")) return false;
|
||||
return url.includes(".mp4");
|
||||
}
|
||||
|
||||
/**
|
||||
* Progressive MP4 für HTML5-`<video>` — niemals HLS/m3u8 (API setzt dort teils fälschlich `video/mp4`).
|
||||
*/
|
||||
export function pickVideoFile(files: PexelsVideoFile[]): PexelsVideoFile {
|
||||
const playable = files.filter(isProgressiveMp4Candidate);
|
||||
if (playable.length === 0) {
|
||||
throw new Error("Kein MP4-Download von Pexels verfügbar (nur HLS?).");
|
||||
}
|
||||
return (
|
||||
playable.find((f) => f.quality === "hd") ??
|
||||
playable.find((f) => f.quality === "uhd") ??
|
||||
playable.find((f) => f.quality === "sd") ??
|
||||
playable[0]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kleinste sinnvolle MP4 für Raster-Vorschau (bevorzugt SD, sonst kleinste Auflösung).
|
||||
*/
|
||||
export function pickPreviewVideoFile(files: PexelsVideoFile[]): PexelsVideoFile | null {
|
||||
const playable = files.filter(isProgressiveMp4Candidate);
|
||||
if (playable.length === 0) return null;
|
||||
const sd = playable.filter((f) => f.quality === "sd");
|
||||
const pool = sd.length > 0 ? sd : playable;
|
||||
return pool.reduce((best, f) => {
|
||||
const a = (f.width || 0) * (f.height || 0);
|
||||
const b = (best.width || 0) * (best.height || 0);
|
||||
if (a === 0 && b === 0) return f;
|
||||
if (a === 0) return best;
|
||||
if (b === 0) return f;
|
||||
return a < b ? f : best;
|
||||
});
|
||||
}
|
||||
23
public/cursors/scissors-cursor-dark-canvas.svg
Normal file
23
public/cursors/scissors-cursor-dark-canvas.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<!-- Lucide scissors-line-dashed, rotated -90deg; light strokes for dark canvas -->
|
||||
<g transform="rotate(-90 12 12)" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g stroke="rgba(255,255,255,0.6)" stroke-width="2.85" opacity="0.95">
|
||||
<path d="M5.42 9.42 8 12"/>
|
||||
<circle cx="4" cy="8" r="2" fill="none"/>
|
||||
<path d="m14 6-8.58 8.58"/>
|
||||
<circle cx="4" cy="16" r="2" fill="none"/>
|
||||
<path d="M10.8 14.8 14 18"/>
|
||||
<path d="M16 12h-2"/>
|
||||
<path d="M22 12h-2"/>
|
||||
</g>
|
||||
<g stroke="#ffffff" stroke-width="1.08">
|
||||
<path d="M5.42 9.42 8 12"/>
|
||||
<circle cx="4" cy="8" r="2" fill="none"/>
|
||||
<path d="m14 6-8.58 8.58"/>
|
||||
<circle cx="4" cy="16" r="2" fill="none"/>
|
||||
<path d="M10.8 14.8 14 18"/>
|
||||
<path d="M16 12h-2"/>
|
||||
<path d="M22 12h-2"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 947 B |
23
public/cursors/scissors-cursor-light-canvas.svg
Normal file
23
public/cursors/scissors-cursor-light-canvas.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<!-- Lucide scissors-line-dashed, rotated -90deg; dark strokes for light canvas -->
|
||||
<g transform="rotate(-90 12 12)" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g stroke="rgba(24,24,27,0.55)" stroke-width="2.85" opacity="0.95">
|
||||
<path d="M5.42 9.42 8 12"/>
|
||||
<circle cx="4" cy="8" r="2" fill="none"/>
|
||||
<path d="m14 6-8.58 8.58"/>
|
||||
<circle cx="4" cy="16" r="2" fill="none"/>
|
||||
<path d="M10.8 14.8 14 18"/>
|
||||
<path d="M16 12h-2"/>
|
||||
<path d="M22 12h-2"/>
|
||||
</g>
|
||||
<g stroke="#27272a" stroke-width="1.08">
|
||||
<path d="M5.42 9.42 8 12"/>
|
||||
<circle cx="4" cy="8" r="2" fill="none"/>
|
||||
<path d="m14 6-8.58 8.58"/>
|
||||
<circle cx="4" cy="16" r="2" fill="none"/>
|
||||
<path d="M10.8 14.8 14 18"/>
|
||||
<path d="M16 12h-2"/>
|
||||
<path d="M22 12h-2"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 945 B |
Reference in New Issue
Block a user