289 lines
9.6 KiB
TypeScript
289 lines
9.6 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type MouseEvent,
|
|
} from "react";
|
|
import { Position, useStore, type Node, type NodeProps } from "@xyflow/react";
|
|
import { ExternalLink, ImageIcon } from "lucide-react";
|
|
import BaseNodeWrapper from "./base-node-wrapper";
|
|
import {
|
|
AssetBrowserPanel,
|
|
useAssetBrowserTarget,
|
|
type AssetBrowserSessionState,
|
|
} from "@/components/canvas/asset-browser-panel";
|
|
import type { Id } from "@/convex/_generated/dataModel";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { resolveMediaAspectRatio } from "@/lib/canvas-utils";
|
|
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
|
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
|
|
|
type AssetNodeData = {
|
|
assetId?: number;
|
|
assetType?: "photo" | "vector" | "icon";
|
|
title?: string;
|
|
previewUrl?: string;
|
|
intrinsicWidth?: number;
|
|
intrinsicHeight?: number;
|
|
url?: string;
|
|
sourceUrl?: string;
|
|
license?: "freemium" | "premium";
|
|
authorName?: string;
|
|
orientation?: string;
|
|
canvasId?: string;
|
|
_status?: string;
|
|
_statusMessage?: string;
|
|
};
|
|
|
|
export type AssetNodeType = Node<AssetNodeData, "asset">;
|
|
|
|
export default function AssetNode({ id, data, selected, width, height }: NodeProps<AssetNodeType>) {
|
|
const { targetNodeId, openForNode, close: closeAssetBrowser } =
|
|
useAssetBrowserTarget();
|
|
const panelOpen = targetNodeId === id;
|
|
const [loadedPreviewUrl, setLoadedPreviewUrl] = useState<string | null>(null);
|
|
const [failedPreviewUrl, setFailedPreviewUrl] = useState<string | null>(null);
|
|
const [browserState, setBrowserState] = useState<AssetBrowserSessionState>({
|
|
term: "",
|
|
assetType: "photo",
|
|
results: [],
|
|
page: 1,
|
|
totalPages: 1,
|
|
});
|
|
const { queueNodeResize } = useCanvasSync();
|
|
|
|
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 openAssetBrowser = useCallback(() => {
|
|
setBrowserState((s) =>
|
|
linkedSearchTerm
|
|
? { ...s, term: linkedSearchTerm, results: [], page: 1, totalPages: 1 }
|
|
: s,
|
|
);
|
|
openForNode(id);
|
|
}, [id, linkedSearchTerm, openForNode]);
|
|
|
|
const hasAsset = typeof data.assetId === "number";
|
|
const previewUrl = data.url ?? data.previewUrl;
|
|
const isPreviewLoading = Boolean(
|
|
previewUrl && previewUrl !== loadedPreviewUrl && previewUrl !== failedPreviewUrl,
|
|
);
|
|
const previewLoadError = Boolean(previewUrl && previewUrl === failedPreviewUrl);
|
|
|
|
const hasAutoSizedRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (!hasAsset) return;
|
|
if (hasAutoSizedRef.current) return;
|
|
const targetAspectRatio = resolveMediaAspectRatio(
|
|
data.intrinsicWidth,
|
|
data.intrinsicHeight,
|
|
data.orientation,
|
|
);
|
|
const minimumNodeHeight = 208;
|
|
const baseNodeWidth = 260;
|
|
const targetWidth = Math.max(baseNodeWidth, Math.round(minimumNodeHeight * targetAspectRatio));
|
|
const targetHeight = Math.round(targetWidth / targetAspectRatio);
|
|
const targetSize = {
|
|
width: targetWidth,
|
|
height: targetHeight,
|
|
};
|
|
const currentWidth = typeof width === "number" ? width : 0;
|
|
const currentHeight = typeof height === "number" ? height : 0;
|
|
const hasMeasuredSize = currentWidth > 0 && currentHeight > 0;
|
|
if (!hasMeasuredSize) {
|
|
return;
|
|
}
|
|
|
|
const isAtTargetSize = currentWidth === targetSize.width && currentHeight === targetSize.height;
|
|
const isAtDefaultSeedSize = currentWidth === 260 && currentHeight === 240;
|
|
const shouldRunInitialAutoSize = isAtDefaultSeedSize && !isAtTargetSize;
|
|
|
|
if (!shouldRunInitialAutoSize) {
|
|
hasAutoSizedRef.current = true;
|
|
return;
|
|
}
|
|
|
|
hasAutoSizedRef.current = true;
|
|
void queueNodeResize({
|
|
nodeId: id as Id<"nodes">,
|
|
width: targetSize.width,
|
|
height: targetSize.height,
|
|
});
|
|
}, [
|
|
data.intrinsicHeight,
|
|
data.intrinsicWidth,
|
|
data.orientation,
|
|
hasAsset,
|
|
height,
|
|
id,
|
|
queueNodeResize,
|
|
width,
|
|
]);
|
|
|
|
const stopNodeClickPropagation = (event: MouseEvent<HTMLAnchorElement>) => {
|
|
event.stopPropagation();
|
|
};
|
|
|
|
const showPreview = Boolean(hasAsset && previewUrl);
|
|
|
|
return (
|
|
<BaseNodeWrapper
|
|
nodeType="asset"
|
|
selected={selected}
|
|
status={data._status}
|
|
statusMessage={data._statusMessage}
|
|
>
|
|
<CanvasHandle
|
|
nodeId={id}
|
|
nodeType="asset"
|
|
type="target"
|
|
position={Position.Left}
|
|
className="h-3! w-3! border-2! border-background! bg-primary!"
|
|
/>
|
|
|
|
<div
|
|
className={`grid h-full min-h-0 w-full ${
|
|
showPreview
|
|
? "grid-rows-[auto_minmax(0,1fr)_auto]"
|
|
: "grid-rows-[auto_minmax(0,1fr)]"
|
|
}`}
|
|
>
|
|
<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">
|
|
FreePik
|
|
</span>
|
|
<Button
|
|
size="sm"
|
|
variant={hasAsset ? "ghost" : "default"}
|
|
className="nodrag h-6 px-2 text-xs"
|
|
onClick={openAssetBrowser}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
type="button"
|
|
>
|
|
{hasAsset ? "Change" : "Browse Assets"}
|
|
</Button>
|
|
</div>
|
|
|
|
{showPreview ? (
|
|
<>
|
|
<div className="relative min-h-0 overflow-hidden bg-muted/30">
|
|
{isPreviewLoading ? (
|
|
<div className="absolute inset-0 z-10 flex animate-pulse items-center justify-center bg-muted/60 text-[11px] text-muted-foreground">
|
|
Loading preview...
|
|
</div>
|
|
) : null}
|
|
{previewLoadError ? (
|
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-muted/70 text-[11px] text-muted-foreground">
|
|
Preview unavailable
|
|
</div>
|
|
) : null}
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={previewUrl}
|
|
alt={data.title ?? "FreePik-Vorschau"}
|
|
className={`h-full w-full object-contain transition-opacity ${
|
|
isPreviewLoading ? "opacity-0" : "opacity-100"
|
|
}`}
|
|
draggable={false}
|
|
onLoad={() => {
|
|
setLoadedPreviewUrl(previewUrl ?? null);
|
|
setFailedPreviewUrl((current) =>
|
|
current === (previewUrl ?? null) ? null : current,
|
|
);
|
|
}}
|
|
onError={() => {
|
|
setFailedPreviewUrl(previewUrl ?? null);
|
|
}}
|
|
/>
|
|
<Badge variant="secondary" className="absolute top-2 left-2 h-4 py-0 text-[10px]">
|
|
{data.assetType ?? "asset"}
|
|
</Badge>
|
|
{data.license ? (
|
|
<Badge
|
|
variant={data.license === "freemium" ? "outline" : "destructive"}
|
|
className="absolute top-2 right-2 h-4 py-0 text-[10px]"
|
|
>
|
|
{data.license}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1 px-3 py-2">
|
|
<p className="truncate text-xs font-medium" title={data.title ?? "Untitled"}>
|
|
{data.title ?? "Untitled"}
|
|
</p>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="truncate text-[10px] text-muted-foreground">
|
|
by {data.authorName ?? "Freepik"}
|
|
</span>
|
|
{data.sourceUrl ? (
|
|
<a
|
|
href={data.sourceUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex shrink-0 items-center gap-0.5 text-[10px] text-muted-foreground transition-colors hover:text-foreground"
|
|
onClick={stopNodeClickPropagation}
|
|
>
|
|
freepik.com
|
|
<ExternalLink className="h-2.5 w-2.5" />
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<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">
|
|
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-medium">No asset selected</p>
|
|
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
|
Browse millions of Freepik resources
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{panelOpen && data.canvasId ? (
|
|
<AssetBrowserPanel
|
|
nodeId={id}
|
|
canvasId={data.canvasId}
|
|
initialState={browserState}
|
|
onStateChange={setBrowserState}
|
|
onClose={closeAssetBrowser}
|
|
/>
|
|
) : null}
|
|
|
|
<CanvasHandle
|
|
nodeId={id}
|
|
nodeType="asset"
|
|
type="source"
|
|
position={Position.Right}
|
|
className="h-3! w-3! border-2! border-background! bg-primary!"
|
|
/>
|
|
</BaseNodeWrapper>
|
|
);
|
|
}
|