diff --git a/README.md b/README.md index 476d168..1361efb 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,8 @@ content. Each placement has an authored position on its collection or folder canvas. Folders are not assets. The application consists of a React/Vite client, a Bun/Hono API backed by -Postgres and Drizzle, plus an AWS Lambda image pipeline that processes S3 -uploads asynchronously through SQS. Collection and folder badges show descendant asset counts: +Postgres and Drizzle, plus AWS Lambda image workers that process S3 uploads +asynchronously through independent SQS queues. Collection and folder badges show descendant asset counts: images and notes count, folders do not. The client uses XYFlow for collection canvas rendering and interaction while Aska's API remains the source of truth for node identity, hierarchy, and position. diff --git a/SST_DEPLOYMENT.md b/SST_DEPLOYMENT.md index 8c0eb59..5ffd214 100644 --- a/SST_DEPLOYMENT.md +++ b/SST_DEPLOYMENT.md @@ -6,7 +6,7 @@ One stage creates one isolated AWS copy: ```text stage dev API Gateway -> Hono Lambda - private S3 assets bucket -> SQS -> image-processing Lambda + private S3 assets bucket -> two SQS queues -> variants and palette Lambdas private S3 client bucket -> CloudFront -> React/Vite client dead-letter queue, IAM permissions, and stage-specific SST secrets ``` @@ -25,10 +25,10 @@ another API, bucket, queues, and Lambdas. | Direct package commands | Your laptop | No AWS event chain | Unit tests and isolated debugging only | Both SST modes are real end-to-end AWS flows. With live development, an image -uploaded from the browser goes to the real S3 bucket, creates a real SQS -message, and invokes the image pipeline running locally. With a normal deploy, -the same pipeline runs in AWS instead. Both test the actual permissions, event -shape, queue flow, and callback path. +uploaded from the browser goes to the real S3 bucket, creates one message in +each image-processing queue, and invokes the variants and palette workers. With +a normal deploy, those same workers run in AWS instead. Both test the actual +permissions, event shape, queue flow, and callback path. ## One-time `dev` setup @@ -180,7 +180,8 @@ this. 3. Upload an image in the browser. It follows this real path: ```text - browser -> API -> S3 ingest/ -> SQS -> local image Lambda -> API callback + browser -> API -> S3 ingest/ -> variants SQS + palette SQS + -> local worker callbacks -> API ``` When you stop `sst dev`, its AWS Lambda proxies can no longer reach your diff --git a/client/src/api/collection/hooks.ts b/client/src/api/collection/hooks.ts index e62fa4d..a0b792c 100644 --- a/client/src/api/collection/hooks.ts +++ b/client/src/api/collection/hooks.ts @@ -436,31 +436,23 @@ async function readImageDimensions(file: File): Promise<{ } } -async function makeOptimisticImageNode( +function makeOptimisticImageNode( file: File, index: number, -): Promise< - Extract & { - previewObjectUrl: string; - } -> { +): Extract & { + previewObjectUrl: string; +} { const previewObjectUrl = URL.createObjectURL(file); const id = `image-uploading-${Date.now()}-${index}-${Math.random().toString(36).slice(2)}`; - let dimensions: { width: number; height: number }; - - try { - dimensions = await readImageDimensions(file); - } catch { - // Keep the upload usable if the browser cannot decode a local preview. - dimensions = { width: 1, height: 1 }; - } return { id, type: "image", url: previewObjectUrl, - width: dimensions.width, - height: dimensions.height, + // Render immediately. The real dimensions replace this provisional square + // while the upload request is being prepared. + width: 1, + height: 1, title: file.name || null, alt: null, sourceLabel: null, @@ -485,12 +477,6 @@ function revokeOptimisticImageUrls( } } -function revokeOptimisticImageUrlsLater( - images: Array<{ previewObjectUrl?: string }> | undefined, -) { - window.setTimeout(() => revokeOptimisticImageUrls(images), 30_000); -} - function updateOptimisticImage( queryClient: ReturnType, workspaceSlug: string, @@ -1034,7 +1020,9 @@ export function useUploadLocalImages( collectionSlug, parentFolderPath, ); - await queryClient.cancelQueries({ queryKey: contentsKey }); + // The local preview is the completion point for this interaction. Do not + // wait for an in-flight refetch before placing it on the canvas. + void queryClient.cancelQueries({ queryKey: contentsKey }); const previousContents = queryClient.getQueryData(contentsKey); @@ -1045,9 +1033,7 @@ export function useUploadLocalImages( "workspace", workspaceSlug, ]); - const optimisticImages = await Promise.all( - files.map(makeOptimisticImageNode), - ); + const optimisticImages = files.map(makeOptimisticImageNode); const positions = reserveNodePositions( previousContents?.nodes ?? [], optimisticImages, @@ -1082,11 +1068,33 @@ export function useUploadLocalImages( optimisticImages.length, ); - const processingImages: Promise[] = []; + const imageDimensions = await Promise.all( + files.map(async (file) => { + try { + return await readImageDimensions(file); + } catch { + return { width: 1, height: 1 }; + } + }), + ); + for (const [index, dimensions] of imageDimensions.entries()) { + const optimisticImage = optimisticImages[index]!; + optimisticImage.width = dimensions.width; + optimisticImage.height = dimensions.height; + updateOptimisticImage( + queryClient, + workspaceSlug, + collectionSlug, + parentFolderPath, + optimisticImage.id, + dimensions, + ); + } + + const images: CollectionImageNode[] = []; const multiple = files.length > 1; const label = multiple ? `${files.length} images` : "1 image"; const toastId = toast.loading(`Uploading ${label}...`); - let cancelled = false; try { for (const [index, file] of files.entries()) { @@ -1098,6 +1106,8 @@ export function useUploadLocalImages( fileName: file.name || "clipboard-image.png", contentType: file.type, sizeBytes: file.size, + width: optimisticImage.width, + height: optimisticImage.height, parentFolderPath, position: optimisticImage.position ?? undefined, }, @@ -1122,81 +1132,51 @@ export function useUploadLocalImages( }, ); - updateOptimisticImage( - queryClient, - workspaceSlug, - collectionSlug, - parentFolderPath, - optimisticImage.id, - { uploadStatus: "processing", uploadProgress: 100 }, - ); - - processingImages.push( - waitForProcessedImage(() => - fetchImageUploadStatus( - workspaceSlug, - collectionSlug, - uploadData.upload.id, - ), - ).then((processedImage) => { - const image = { - ...processedImage, - clientId: optimisticImage.clientId, - position: optimisticImage.position, - }; - - if (cancelled) return image; - - queryClient.setQueryData( - contentsKey, - (current) => { - if (!current) return current; - - return { + const image = { + ...uploadData.upload.image, + clientId: optimisticImage.clientId, + position: optimisticImage.position, + localPreviewUrl: optimisticImage.previewObjectUrl, + }; + images.push(image); + queryClient.setQueryData( + contentsKey, + (current) => + !current + ? current + : { ...current, nodes: current.nodes.map((node) => node.id === optimisticImage.id ? image : node, ), - }; - }, - ); - - const preview: FolderChildPreview = { - assetId: image.id, - type: "image", - url: image.url, - blurDataURL: image.blurDataURL, - }; - addPreviewToCollection( - queryClient, - workspaceSlug, - collectionSlug, - preview, - ); - addPreviewToParentFolder( - queryClient, - workspaceSlug, - collectionSlug, - parentFolderPath, - preview, - 0, - ); - return image; - }), + }, + ); + const preview: FolderChildPreview = { + assetId: image.id, + type: "image", + url: image.url, + blurDataURL: image.blurDataURL, + }; + addPreviewToCollection( + queryClient, + workspaceSlug, + collectionSlug, + preview, + ); + addPreviewToParentFolder( + queryClient, + workspaceSlug, + collectionSlug, + parentFolderPath, + preview, + 0, ); } - const images = await Promise.all(processingImages); - - revokeOptimisticImageUrlsLater(optimisticImages); reconcileCollectionCaches(queryClient, workspaceSlug, collectionSlug); toast.success(`${label} uploaded`, { id: toastId }); return { images, parentFolderPath }; } catch (error) { - cancelled = true; - void Promise.allSettled(processingImages).then(() => { - reconcileCollectionCaches(queryClient, workspaceSlug, collectionSlug); - }); toast.error("Upload failed", { id: toastId }); revokeOptimisticImageUrls(optimisticImages); queryClient.setQueryData(contentsKey, previousContents); @@ -1228,15 +1208,15 @@ export function useUploadInboxImages(workspaceSlug: string) { mutationFn: async ({ files }: { files: File[] }) => { const inboxKey = collectionQueryKeys.inbox(workspaceSlug); const workspaceKey = ["workspace", workspaceSlug] as const; - await queryClient.cancelQueries({ queryKey: inboxKey }); + // Keep the modal-to-canvas handoff immediate; cancelling a refetch can + // happen in parallel with the optimistic cache update. + void queryClient.cancelQueries({ queryKey: inboxKey }); const previousInbox = queryClient.getQueryData(inboxKey); const previousWorkspace = queryClient.getQueryData(workspaceKey); - const optimisticImages = await Promise.all( - files.map(makeOptimisticImageNode), - ); + const optimisticImages = files.map(makeOptimisticImageNode); queryClient.setQueryData(inboxKey, (current) => { if (!current) return current; @@ -1252,11 +1232,31 @@ export function useUploadInboxImages(workspaceSlug: string) { (count) => count + files.length, ); - const processingImages: Promise[] = []; + const imageDimensions = await Promise.all( + files.map(async (file) => { + try { + return await readImageDimensions(file); + } catch { + return { width: 1, height: 1 }; + } + }), + ); + for (const [index, dimensions] of imageDimensions.entries()) { + const optimisticImage = optimisticImages[index]!; + optimisticImage.width = dimensions.width; + optimisticImage.height = dimensions.height; + updateOptimisticInboxImage( + queryClient, + workspaceSlug, + optimisticImage.id, + dimensions, + ); + } + + const images: CollectionImageNode[] = []; const multiple = files.length > 1; const label = multiple ? `${files.length} images` : "1 image"; const toastId = toast.loading(`Uploading ${label}...`); - let cancelled = false; try { for (const [index, file] of files.entries()) { @@ -1265,6 +1265,8 @@ export function useUploadInboxImages(workspaceSlug: string) { fileName: file.name || "clipboard-image.png", contentType: file.type, sizeBytes: file.size, + width: optimisticImage.width, + height: optimisticImage.height, }); await uploadFileToPresignedUrl( @@ -1283,50 +1285,27 @@ export function useUploadInboxImages(workspaceSlug: string) { ); }, ); - updateOptimisticInboxImage( - queryClient, - workspaceSlug, - optimisticImage.id, - { uploadStatus: "processing", uploadProgress: 100 }, - ); - - processingImages.push( - waitForProcessedImage(() => - fetchInboxImageUploadStatus(workspaceSlug, uploadData.upload.id), - ).then((processedImage) => { - const image = { - ...processedImage, - clientId: optimisticImage.clientId, - }; - - if (cancelled) return image; - - queryClient.setQueryData( - inboxKey, - (current) => { - if (!current) return current; - - return { - ...current, - nodes: current.nodes.map((node) => - node.id === optimisticImage.id ? image : node, - ), - }; + const image = { + ...uploadData.upload.image, + clientId: optimisticImage.clientId, + localPreviewUrl: optimisticImage.previewObjectUrl, + }; + images.push(image); + queryClient.setQueryData(inboxKey, (current) => + !current + ? current + : { + ...current, + nodes: current.nodes.map((node) => + node.id === optimisticImage.id ? image : node, + ), }, - ); - return image; - }), ); } - const images = await Promise.all(processingImages); - toast.success(`${label} uploaded`, { id: toastId }); - revokeOptimisticImageUrlsLater(optimisticImages); return { images }; } catch (error) { - cancelled = true; - void Promise.allSettled(processingImages); toast.error("Upload failed", { id: toastId }); revokeOptimisticImageUrls(optimisticImages); queryClient.setQueryData(inboxKey, previousInbox); diff --git a/client/src/api/collection/types.ts b/client/src/api/collection/types.ts index 0ebd7db..deb741c 100644 --- a/client/src/api/collection/types.ts +++ b/client/src/api/collection/types.ts @@ -96,6 +96,8 @@ export type CollectionImageNode = { id: string; type: "image"; url: string; + /** Browser-only preview retained while the uploaded original is decoded. */ + localPreviewUrl?: string; originalUrl?: string; originalWidth?: number; originalHeight?: number; @@ -108,6 +110,8 @@ export type CollectionImageNode = { isFavorite: boolean; blurDataURL?: string | null; dominantColors?: string[]; + variantStatus?: "processing" | "completed" | "failed"; + paletteStatus?: "processing" | "completed" | "failed"; uploadStatus?: "uploading" | "processing"; uploadProgress?: number; clientId?: string; @@ -136,6 +140,8 @@ export type CreateImageUploadInput = { fileName: string; contentType: string; sizeBytes: number; + width: number; + height: number; title?: string; alt?: string; parentFolderPath?: string; @@ -150,6 +156,7 @@ export type CreateImageUploadResponse = { headers: Record; expiresAt: string; maxSizeBytes: number; + image: CollectionImageNode; }; }; diff --git a/client/src/components/app-shell/app-sidebar.tsx b/client/src/components/app-shell/app-sidebar.tsx index 179655c..dfe2613 100644 --- a/client/src/components/app-shell/app-sidebar.tsx +++ b/client/src/components/app-shell/app-sidebar.tsx @@ -4,6 +4,7 @@ import { NavProjects } from "@/components/app-shell/nav-projects"; import { NavSecondary } from "@/components/app-shell/nav-secondary"; import { NavUser } from "@/components/app-shell/nav-user"; import { WorkspaceSwitcher } from "@/components/app-shell/workspace-switcher"; +import { Skeleton } from "@/components/ui/skeleton"; import { Sidebar, SidebarContent, @@ -19,7 +20,7 @@ import { SettingsIcon, StarIcon, } from "lucide-react"; -import { useSession } from "@/lib/auth-client"; +import type { AuthState } from "@/lib/auth-flow"; import { useWorkspace } from "@/api/workspace"; import { useCollectionContents, useMarkInboxSeen } from "@/api/collection"; import { @@ -29,7 +30,11 @@ import { import { openSettings } from "@/lib/settings-dialog"; export function AppSidebar(props: React.ComponentProps) { - const { data: session } = useSession(); + const authState = useRouterState({ + select: (state) => + state.matches.find((match) => match.routeId === "/$workspaceSlug") + ?.context as AuthState | undefined, + }); const pathname = useRouterState({ select: (state) => state.location.pathname, }); @@ -169,8 +174,25 @@ export function AppSidebar(props: React.ComponentProps) { - {session?.user ? : null} + {authState?.session.user ? ( + + ) : ( + + )} ); } + +function SidebarUserSkeleton() { + return ( +
+ +
+ + +
+ +
+ ); +} diff --git a/client/src/components/board/board-upload-zone.tsx b/client/src/components/board/board-upload-zone.tsx index a4cd6f7..ce5635d 100644 --- a/client/src/components/board/board-upload-zone.tsx +++ b/client/src/components/board/board-upload-zone.tsx @@ -1,6 +1,9 @@ -import React, { useState } from "react"; -import { ImagePlusIcon, LoaderCircleIcon } from "lucide-react"; +import React, { useCallback, useState } from "react"; +import { ImagePlusIcon } from "lucide-react"; +import type { BoardInsertionPlacement } from "@/api/collection"; +import { getBoardPointerPosition } from "@/components/canvas/board-pointer-position"; import { SUPPORTED_IMAGE_MIME_TYPE_SET } from "@/constants"; +import { useTransientStore } from "@/store"; import { cn, parseHttpUrl } from "@/lib/utils"; import { useBoardAssetActions } from "./use-board-asset-actions"; @@ -8,13 +11,23 @@ export function BoardUploadZone({ workspaceSlug, collectionPath, target = "collection", + boardKey, children, }: { workspaceSlug: string; collectionPath: string; target?: "collection" | "inbox"; + boardKey?: string; children: React.ReactNode; }) { + const getPlacement = useCallback((): BoardInsertionPlacement | undefined => { + if (!boardKey) return undefined; + + const { boardVisibleBounds } = useTransientStore.getState(); + const position = getBoardPointerPosition(boardKey); + const visibleBounds = boardVisibleBounds[boardKey]; + return position || visibleBounds ? { position, visibleBounds } : undefined; + }, [boardKey]); const { createTextNote, importRemoteUrl, @@ -25,6 +38,7 @@ export function BoardUploadZone({ workspaceSlug, collectionPath, target, + getPlacement, }); const [isDraggingImage, setIsDraggingImage] = useState(false); @@ -99,12 +113,6 @@ export function BoardUploadZone({ Drop images to upload - {statusText ? ( -
- - {statusText} -
- ) : null} {isPending ? ( {statusText} diff --git a/client/src/components/board/cards/image-asset-card.tsx b/client/src/components/board/cards/image-asset-card.tsx index cb8e4e2..6e135ca 100644 --- a/client/src/components/board/cards/image-asset-card.tsx +++ b/client/src/components/board/cards/image-asset-card.tsx @@ -46,6 +46,7 @@ export function ImageAssetCard({ > -
- {asset.title && {asset.title}} +
+ {asset.title && ( + {asset.title} + )} {asset.sourceLabel && ( <> {asset.title && ( @@ -87,10 +90,10 @@ export function ImageAssetCard({ target="_blank" rel="noopener noreferrer" onClick={(event) => event.stopPropagation()} - className="inline-flex items-center gap-1 transition-colors duration-100 ease-[cubic-bezier(0.16,1,0.3,1)] hover:text-sidebar-foreground/70" + className="inline-flex min-w-0 items-center gap-1 transition-colors duration-100 ease-[cubic-bezier(0.16,1,0.3,1)] hover:text-sidebar-foreground/70" > - - {asset.sourceLabel} + + {asset.sourceLabel} )} diff --git a/client/src/components/board/cards/note-asset-card.tsx b/client/src/components/board/cards/note-asset-card.tsx index 7658603..c701d05 100644 --- a/client/src/components/board/cards/note-asset-card.tsx +++ b/client/src/components/board/cards/note-asset-card.tsx @@ -6,6 +6,15 @@ import { cn } from "@/lib/utils"; import { hasSelectionModifier } from "@/lib/selection"; import type { NoteAsset } from "@/types/asset"; +const BARE_URL_RE = /(^|[^(\[])(https?:\/\/[^\s<"'>)\]]+)/gi; + +function linkifyBareUrls(text: string): string { + return text.replace( + BARE_URL_RE, + (_, before, url) => `${before}[${url}](${url})`, + ); +} + const MD_COMPONENTS: Components = { h1: ({ className, ...props }) => (

( ), @@ -123,7 +134,9 @@ export function NoteMarkdown({ }) { return (
- {content} + + {linkifyBareUrls(content)} +
); } @@ -204,7 +217,10 @@ export function NoteAssetCard({ effectiveOnOpen(); }} > -
+
{hasOverflow ? ( diff --git a/client/src/components/board/image-asset-viewer/image-metadata.tsx b/client/src/components/board/image-asset-viewer/image-metadata.tsx index 0bf3ef6..c5a2746 100644 --- a/client/src/components/board/image-asset-viewer/image-metadata.tsx +++ b/client/src/components/board/image-asset-viewer/image-metadata.tsx @@ -1,5 +1,11 @@ import { Fragment } from "react"; +import { toast } from "sonner"; import type { ImageAsset } from "@/types/asset"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; @@ -62,13 +68,24 @@ export function ImageMetadata({ asset }: { asset: ImageAsset }) { {dominantColors.length > 0 ? (
{dominantColors.map((color) => ( - + + { + navigator.clipboard.writeText(color.toUpperCase()); + toast.success(`Copied ${color.toUpperCase()}`); + }} + aria-label={`Dominant color ${color}`} + className="cursor-pointer" + > + + + + {color.toUpperCase()} + + ))}
) : ( diff --git a/client/src/components/board/use-board-asset-actions.ts b/client/src/components/board/use-board-asset-actions.ts index f9c782a..d192a8b 100644 --- a/client/src/components/board/use-board-asset-actions.ts +++ b/client/src/components/board/use-board-asset-actions.ts @@ -21,11 +21,13 @@ export function useBoardAssetActions({ collectionPath, target = "collection", placement, + getPlacement, }: { workspaceSlug: string; collectionPath: string; target?: BoardAssetTarget; placement?: BoardInsertionPlacement; + getPlacement?: () => BoardInsertionPlacement | undefined; }) { const [collectionSlug = "", ...folderSegments] = collectionPath .split("/") @@ -71,6 +73,7 @@ export function useBoardAssetActions({ if (imageFiles.length === 0) return; try { + const insertionPlacement = getPlacement?.() ?? placement; if (target === "inbox") { await uploadInboxImages.mutateAsync({ files: imageFiles, @@ -79,19 +82,23 @@ export function useBoardAssetActions({ await uploadLocalImages.mutateAsync({ files: imageFiles, parentFolderPath, - placement, + placement: insertionPlacement, }); } - toast.success( - imageFiles.length === 1 ? "Image uploaded" : "Images uploaded", - ); } catch (err) { toast.error( err instanceof Error ? err.message : "Unable to upload images.", ); } }, - [parentFolderPath, placement, target, uploadInboxImages, uploadLocalImages], + [ + getPlacement, + parentFolderPath, + placement, + target, + uploadInboxImages, + uploadLocalImages, + ], ); const importRemoteUrl = useCallback( @@ -100,6 +107,7 @@ export function useBoardAssetActions({ if (!url) return; try { + const insertionPlacement = getPlacement?.() ?? placement; if (target === "inbox") { await createInboxRemoteImage.mutateAsync({ url, @@ -108,7 +116,7 @@ export function useBoardAssetActions({ await createRemoteImage.mutateAsync({ url, parentFolderPath, - placement, + placement: insertionPlacement, }); } toast.success("Image imported"); @@ -121,6 +129,7 @@ export function useBoardAssetActions({ [ createInboxRemoteImage, createRemoteImage, + getPlacement, parentFolderPath, placement, target, @@ -132,6 +141,7 @@ export function useBoardAssetActions({ if (!content.trim()) return; try { + const insertionPlacement = getPlacement?.() ?? placement; if (target === "inbox") { await createInboxNote.mutateAsync({ content, @@ -140,7 +150,7 @@ export function useBoardAssetActions({ await createNote.mutateAsync({ content, parentFolderPath, - placement, + placement: insertionPlacement, }); } toast.success("Note created"); @@ -150,7 +160,14 @@ export function useBoardAssetActions({ ); } }, - [createInboxNote, createNote, parentFolderPath, placement, target], + [ + createInboxNote, + createNote, + getPlacement, + parentFolderPath, + placement, + target, + ], ); const addClipboardAsset = useCallback( diff --git a/client/src/components/canvas/board-pointer-position.ts b/client/src/components/canvas/board-pointer-position.ts new file mode 100644 index 0000000..57ac47c --- /dev/null +++ b/client/src/components/canvas/board-pointer-position.ts @@ -0,0 +1,16 @@ +import type { BoardPosition } from "@/api/collection"; + +// Pointer movement is high-frequency. Keep it outside React/Zustand state so +// paste can read the latest location without re-rendering the canvas. +const positions = new Map(); + +export function setBoardPointerPosition( + boardKey: string, + position: BoardPosition, +) { + positions.set(boardKey, position); +} + +export function getBoardPointerPosition(boardKey: string) { + return positions.get(boardKey); +} diff --git a/client/src/components/canvas/canvas.tsx b/client/src/components/canvas/canvas.tsx index 687c1f6..dc77f35 100644 --- a/client/src/components/canvas/canvas.tsx +++ b/client/src/components/canvas/canvas.tsx @@ -44,6 +44,7 @@ import { toast } from "sonner"; import { formatPlatformShortcut } from "@/lib/platform"; import { makeBoardKey } from "./canvas-key"; +import { setBoardPointerPosition } from "./board-pointer-position"; import { BOARD_CARD_WIDTH, arrangeNodesInGrid, @@ -723,7 +724,15 @@ function CanvasSurface({ ref={boardRef} className="relative h-full min-h-0 w-full bg-transparent" onPointerDownCapture={marquee.onPointerDownCapture} - onPointerMoveCapture={marquee.onPointerMoveCapture} + onPointerMoveCapture={(event) => { + marquee.onPointerMoveCapture(event); + setBoardPointerPosition( + boardKey, + roundPosition( + screenToFlowPosition({ x: event.clientX, y: event.clientY }), + ), + ); + }} onPointerUpCapture={marquee.onPointerUpCapture} onPointerCancelCapture={marquee.onPointerCancelCapture} onClickCapture={(event) => { diff --git a/client/src/components/ui/progressive-image.tsx b/client/src/components/ui/progressive-image.tsx index ad47c51..c7b8323 100644 --- a/client/src/components/ui/progressive-image.tsx +++ b/client/src/components/ui/progressive-image.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, type ComponentProps } from "react"; +import { useCallback, useEffect, useState, type ComponentProps } from "react"; import { motion, type MotionStyle } from "motion/react"; import { cn } from "@/lib/utils"; @@ -7,6 +7,8 @@ type ProgressiveImageProps = Omit< "src" | "style" > & { src: string; + /** A local Blob preview to keep visible while `src` finishes decoding. */ + fallbackSrc?: string; blurDataURL?: string | null; placeholderClassName?: string; style?: MotionStyle; @@ -26,6 +28,7 @@ function rememberDecodedSource(src: string) { export function ProgressiveImage({ src, + fallbackSrc, blurDataURL, alt = "", className, @@ -40,6 +43,15 @@ export function ProgressiveImage({ decodedSources.has(src) ? src : null, ); const isDecoded = decodedSrc === src || decodedSources.has(src); + const showFallback = Boolean(fallbackSrc) && !isDecoded; + + useEffect(() => { + if (!isDecoded || !fallbackSrc?.startsWith("blob:")) return; + + // Let the decoded remote image paint once before releasing the local Blob. + const frame = requestAnimationFrame(() => URL.revokeObjectURL(fallbackSrc)); + return () => cancelAnimationFrame(frame); + }, [fallbackSrc, isDecoded]); const handleLoad = useCallback>( (event) => { @@ -66,14 +78,15 @@ export function ProgressiveImage({ >( (event) => { onError?.(event); - setDecodedSrc(src); + // A failed signed URL should not hide the still-valid local preview. + if (!fallbackSrc) setDecodedSrc(src); }, - [onError, src], + [fallbackSrc, onError, src], ); return ( <> - {blurDataURL && !isDecoded ? ( + {blurDataURL && !showFallback && !isDecoded ? ( <> ) : null} + {fallbackSrc ? ( +