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:
@@ -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,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user