feat: enhance asset browser panel with improved asset selection and loading states

- Added state management for asset selection to prevent multiple simultaneous selections.
- Implemented request sequence tracking to ensure accurate loading state handling during asset searches.
- Enhanced error handling and user feedback for asset loading failures.
- Updated UI elements to improve accessibility and user experience during asset browsing.
This commit is contained in:
Matthias
2026-03-27 21:26:29 +01:00
parent bc3bbf9d69
commit 8e4e2fcac1
9 changed files with 278 additions and 155 deletions

View File

@@ -2,7 +2,6 @@
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type MouseEvent,
@@ -42,7 +41,8 @@ export type AssetNodeType = Node<AssetNodeData, "asset">;
export default function AssetNode({ id, data, selected, width, height }: NodeProps<AssetNodeType>) {
const [panelOpen, setPanelOpen] = useState(false);
const [handleTop, setHandleTop] = useState<number | undefined>(undefined);
const [loadedPreviewUrl, setLoadedPreviewUrl] = useState<string | null>(null);
const [failedPreviewUrl, setFailedPreviewUrl] = useState<string | null>(null);
const [browserState, setBrowserState] = useState<AssetBrowserSessionState>({
term: "",
assetType: "photo",
@@ -51,19 +51,25 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
totalPages: 1,
});
const resizeNode = useMutation(api.nodes.resize);
const contentRef = useRef<HTMLDivElement | null>(null);
const mediaRef = useRef<HTMLDivElement | null>(null);
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 aspectRatio = resolveMediaAspectRatio(
data.intrinsicWidth,
data.intrinsicHeight,
data.orientation,
);
const hasAutoSizedRef = useRef(false);
useEffect(() => {
if (!hasAsset) return;
if (hasAutoSizedRef.current) return;
hasAutoSizedRef.current = true;
const targetSize = computeMediaNodeSize("asset", {
intrinsicWidth: data.intrinsicWidth,
@@ -91,39 +97,6 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
width,
]);
useLayoutEffect(() => {
if (!hasAsset || !contentRef.current || !mediaRef.current) return;
const contentEl = contentRef.current;
const mediaEl = mediaRef.current;
let frameId: number | undefined;
const updateHandleTop = () => {
if (frameId !== undefined) {
cancelAnimationFrame(frameId);
}
frameId = requestAnimationFrame(() => {
const contentRect = contentEl.getBoundingClientRect();
const mediaRect = mediaEl.getBoundingClientRect();
const nextTop = mediaRect.top - contentRect.top + mediaRect.height / 2;
setHandleTop(nextTop);
});
};
updateHandleTop();
const observer = new ResizeObserver(updateHandleTop);
observer.observe(contentEl);
observer.observe(mediaEl);
return () => {
observer.disconnect();
if (frameId !== undefined) {
cancelAnimationFrame(frameId);
}
};
}, [aspectRatio, hasAsset]);
const stopNodeClickPropagation = (event: MouseEvent<HTMLAnchorElement>) => {
event.stopPropagation();
};
@@ -140,10 +113,9 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
type="target"
position={Position.Left}
className="h-3! w-3! border-2! border-background! bg-primary!"
style={{ top: hasAsset && handleTop ? `${handleTop}px` : "50%" }}
/>
<div ref={contentRef} className="w-full">
<div className="w-full">
<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
@@ -162,16 +134,36 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
{hasAsset && previewUrl ? (
<div className="flex flex-col gap-0">
<div
ref={mediaRef}
className="relative overflow-hidden bg-muted/30"
style={{ aspectRatio }}
>
{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 ?? "Asset preview"}
className="h-full w-full object-contain"
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"}
@@ -238,7 +230,6 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
type="source"
position={Position.Right}
className="h-3! w-3! border-2! border-background! bg-primary!"
style={{ top: hasAsset && handleTop ? `${handleTop}px` : "50%" }}
/>
</BaseNodeWrapper>
);