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

@@ -59,11 +59,15 @@ export function AssetBrowserPanel({
const [totalPages, setTotalPages] = useState(initialState?.totalPages ?? 1);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectingAssetKey, setSelectingAssetKey] = useState<string | null>(null);
const searchFreepik = useAction(api.freepik.search);
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 = selectingAssetKey !== null;
useEffect(() => {
setIsMounted(true);
@@ -102,6 +106,7 @@ export function AssetBrowserPanel({
const runSearch = useCallback(
async (searchTerm: string, type: AssetType, requestedPage: number) => {
const cleanedTerm = searchTerm.trim();
const requestSequence = ++requestSequenceRef.current;
if (!cleanedTerm) {
setResults([]);
setErrorMessage(null);
@@ -121,16 +126,29 @@ export function AssetBrowserPanel({
limit: 20,
});
if (requestSequence !== requestSequenceRef.current) {
return;
}
setResults(response.results);
setTotalPages(response.totalPages);
setPage(response.currentPage);
if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTop = 0;
}
} catch (error) {
if (requestSequence !== requestSequenceRef.current) {
return;
}
console.error("Freepik search error", error);
setErrorMessage(
error instanceof Error ? error.message : "Freepik search failed",
);
} finally {
setIsLoading(false);
if (requestSequence === requestSequenceRef.current) {
setIsLoading(false);
}
}
},
[searchFreepik],
@@ -147,38 +165,47 @@ export function AssetBrowserPanel({
const handleSelect = useCallback(
async (asset: FreepikResult) => {
await updateData({
nodeId: nodeId as Id<"nodes">,
data: {
assetId: asset.id,
assetType: asset.assetType,
title: asset.title,
previewUrl: asset.previewUrl,
if (isSelecting) return;
const assetKey = `${asset.assetType}-${asset.id}`;
setSelectingAssetKey(assetKey);
try {
await updateData({
nodeId: nodeId as Id<"nodes">,
data: {
assetId: asset.id,
assetType: asset.assetType,
title: asset.title,
previewUrl: asset.previewUrl,
intrinsicWidth: asset.intrinsicWidth,
intrinsicHeight: asset.intrinsicHeight,
url: asset.previewUrl,
sourceUrl: asset.sourceUrl,
license: asset.license,
authorName: asset.authorName,
orientation: asset.orientation,
canvasId,
},
});
const targetSize = computeMediaNodeSize("asset", {
intrinsicWidth: asset.intrinsicWidth,
intrinsicHeight: asset.intrinsicHeight,
url: asset.previewUrl,
sourceUrl: asset.sourceUrl,
license: asset.license,
authorName: asset.authorName,
orientation: asset.orientation,
canvasId,
},
});
});
const targetSize = computeMediaNodeSize("asset", {
intrinsicWidth: asset.intrinsicWidth,
intrinsicHeight: asset.intrinsicHeight,
orientation: asset.orientation,
});
await resizeNode({
nodeId: nodeId as Id<"nodes">,
width: targetSize.width,
height: targetSize.height,
});
onClose();
await resizeNode({
nodeId: nodeId as Id<"nodes">,
width: targetSize.width,
height: targetSize.height,
});
onClose();
} catch (error) {
console.error("Failed to select asset", error);
} finally {
setSelectingAssetKey(null);
}
},
[canvasId, nodeId, onClose, resizeNode, updateData],
[canvasId, isSelecting, nodeId, onClose, resizeNode, updateData],
);
const handlePreviousPage = useCallback(() => {
@@ -204,6 +231,9 @@ export function AssetBrowserPanel({
onClick={(event) => event.stopPropagation()}
onWheelCapture={(event) => event.stopPropagation()}
onPointerDownCapture={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Browse Freepik assets"
>
<div className="flex shrink-0 items-center justify-between border-b px-5 py-4">
<h2 className="text-sm font-semibold">Browse Freepik Assets</h2>
@@ -244,6 +274,7 @@ export function AssetBrowserPanel({
</div>
<div
ref={scrollAreaRef}
className="nowheel nodrag nopan flex-1 overflow-y-auto p-5"
onWheelCapture={(event) => event.stopPropagation()}
>
@@ -258,6 +289,14 @@ export function AssetBrowserPanel({
<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, assetType, page)}
>
Try again
</Button>
</div>
) : results.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center text-muted-foreground">
@@ -269,35 +308,49 @@ export function AssetBrowserPanel({
) : (
<>
<div className="grid grid-cols-4 gap-3">
{results.map((asset) => (
<button
key={`${asset.assetType}-${asset.id}`}
onClick={() => void handleSelect(asset)}
className="group relative aspect-square overflow-hidden rounded-lg border-2 border-transparent bg-muted transition-all hover:border-primary focus:border-primary focus:outline-none"
title={asset.title}
type="button"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={asset.previewUrl}
alt={asset.title}
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
/>
<div className="absolute inset-x-1 top-1 flex items-start justify-between gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Badge variant="secondary" className="h-4 px-1.5 py-0 text-[10px]">
{asset.assetType}
</Badge>
<Badge
variant={asset.license === "freemium" ? "outline" : "destructive"}
className="h-4 px-1.5 py-0 text-[10px]"
>
{asset.license}
</Badge>
</div>
<div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/20" />
</button>
))}
{results.map((asset) => {
const assetKey = `${asset.assetType}-${asset.id}`;
const isSelectingThisAsset = selectingAssetKey === assetKey;
return (
<button
key={assetKey}
onClick={() => void handleSelect(asset)}
className="group relative aspect-square overflow-hidden rounded-lg border-2 border-transparent bg-muted transition-all hover:border-primary focus:border-primary focus:outline-none"
title={asset.title}
type="button"
disabled={isSelecting}
aria-busy={isSelectingThisAsset}
aria-label={`Select asset: ${asset.title}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={asset.previewUrl}
alt={asset.title}
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
/>
<div className="absolute inset-x-1 top-1 flex items-start justify-between gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Badge variant="secondary" className="h-4 px-1.5 py-0 text-[10px]">
{asset.assetType}
</Badge>
<Badge
variant={asset.license === "freemium" ? "outline" : "destructive"}
className="h-4 px-1.5 py-0 text-[10px]"
>
{asset.license}
</Badge>
</div>
<div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/20" />
{isSelectingThisAsset ? (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 bg-black/55 text-[11px] text-white">
<Loader2 className="h-4 w-4 animate-spin" />
Applying...
</div>
) : null}
</button>
);
})}
</div>
</>
@@ -306,7 +359,7 @@ export function AssetBrowserPanel({
<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">
<div className="flex items-center justify-center gap-2" aria-live="polite">
<Button
variant="outline"
size="sm"
@@ -363,10 +416,14 @@ export function AssetBrowserPanel({
handleNextPage,
handlePreviousPage,
handleSelect,
debouncedTerm,
isLoading,
isSelecting,
onClose,
page,
results,
runSearch,
selectingAssetKey,
term,
totalPages,
],