Skip to content

day309 Grida Canvas - UX / Photos Library#467

Merged
softmarshmallow merged 13 commits intomainfrom
canary
Dec 12, 2025
Merged

day309 Grida Canvas - UX / Photos Library#467
softmarshmallow merged 13 commits intomainfrom
canary

Conversation

@softmarshmallow
Copy link
Copy Markdown
Member

@softmarshmallow softmarshmallow commented Dec 11, 2025

  • search photos
  • random photo
  • browse by topics
  • infinite scrolling
day-309-grida-canvas-ux-photos-browser.mp4

Summary by CodeRabbit

  • New Features

    • Unified Library panel with tabs for Icons and Photos
    • Unsplash-powered Photos browser: search, topic filters, random discovery, infinite scroll
    • Insert and drag-and-drop support for icons and photos with user feedback
  • Improvements

    • Enhanced Icons browser: improved search, vendor filtering via pill UI, and loading indicators
    • Better feedback flows for insert/drag actions
  • Chores

    • Added Unsplash integration and remote image host support

✏️ Tip: You can customize this high-level summary in your review settings.

@vercel
Copy link
Copy Markdown

vercel Bot commented Dec 11, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
docs Ready Ready Preview Comment Dec 12, 2025 8:09pm
grida Error Error Dec 12, 2025 8:09pm
5 Skipped Deployments
Project Deployment Preview Comments Updated (UTC)
code Ignored Ignored Dec 12, 2025 8:09pm
legacy Ignored Ignored Dec 12, 2025 8:09pm
backgrounds Skipped Skipped Dec 12, 2025 8:09pm
blog Skipped Skipped Dec 12, 2025 8:09pm
viewer Skipped Skipped Dec 12, 2025 8:09pm

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Dec 11, 2025

Caution

Review failed

The pull request is closed.

Walkthrough

Adds a unified Library UI that combines Icons and Unsplash-backed Photos browsing/insert/drag behavior, new UI primitives (Pills, SearchInput, LoadingIndicator), typed data-transfer payloads for images/SVGs, server-side Unsplash fetch utilities, and updates to floating-window demos and drop handling.

Changes

Cohort / File(s) Summary
Library & Browsers
editor/grida-canvas-hosted/library/library.tsx, editor/grida-canvas-hosted/library/photos-browser.tsx
New Library component with Icons and Photos tabs; PhotosBrowser provides virtualized two-column grid, infinite scroll, topics, debounced search, onInsert and onDragStart handlers.
Icon library & API
editor/grida-canvas-hosted/library/lib-icons.ts, editor/grida-canvas-hosted/library/icons-browser.tsx
New lib-icons module with types and fetch utilities; IconsBrowser refactored to use pills, SearchInput, LoadingIndicator, and added onDragStart support; several internal exports moved/removed and IconsBrowserItem re-exported from lib-icons.
Photos API (server-side)
editor/grida-canvas-hosted/library/lib-photos-actions.ts
New Unsplash integration: client init, fetch random/search/topic photos, normalization, filter out premium/plus assets, and exported typed PhotoAsset/PhotoActionState APIs.
UI primitives
editor/grida-canvas-hosted/library/components/loading-indicator.tsx, editor/grida-canvas-hosted/library/components/pills.tsx, editor/grida-canvas-hosted/library/components/search-input.tsx
New LoadingIndicator, Pill/PillsList with optional thumbnails and scroll overlays, and SearchInput component with leading search icon.
Data transfer & drop handling
editor/grida-canvas/data-transfer.ts, editor/grida-canvas-react/use-data-transfer.ts
Typed DataTransferPayload union for svg and image, added decode function; drop handler now decodes payload via datatransfer.decode and supports image payload insertion (creates rect node, image fill, sizes).
Playground / Floating windows
editor/grida-canvas-hosted/playground/playground.tsx, editor/app/(dev)/ui/floating-window/page.tsx
Replaced inline IconsBrowser usage with Library in playground; floating-window demo updated to import new icons/photos paths, add Photos floating window/trigger, and new onInsert handlers that toast selection.
Misc — logos, config, deps
editor/components/logos/unsplash.tsx, editor/next.config.ts, editor/package.json
Added UnsplashLogoIcon SVG component, loosened Next.js image remotePatterns to *.unsplash.com, and added unsplash-js dependency.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Library as Library UI
    participant Photos as PhotosBrowser
    participant API as lib-photos-actions (server)
    participant DataTransfer as datatransfer
    participant Editor as Canvas / use-data-transfer

    User->>Library: Open Photos tab
    Library->>Photos: mount with onInsert/onDragStart
    Photos->>API: fetchPhotosAction(mode/search/random)
    API->>Unsplash: HTTP request
    Unsplash-->>API: Response
    API-->>Photos: normalized PhotoActionState
    Photos->>Photos: render virtualized grid

    User->>Photos: Drag photo
    Photos->>DataTransfer: encode({type:"image",name,src,width,height})
    User->>Editor: Drop payload
    Editor->>DataTransfer: decode(payload)
    Editor->>Editor: create rect node + image fill (size, transforms)
    Editor-->>User: show toast / insertion result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • Pay extra attention to:
    • photos-browser.tsx — virtualization, ResizeObserver, infinite scroll, debounce and state transitions.
    • lib-photos-actions.ts — Unsplash client init, normalization, error paths, and premium/plus filtering.
    • lib-icons.ts and icons-browser.tsx — moved types, export changes, and drag-start wiring.
    • use-data-transfer.ts and data-transfer.ts — encode/decode typing and backward compatibility with existing drop payloads.
    • Integration in library.tsx — editor command usage, image node creation, and drag-drop payload shape consistency.

Possibly related PRs

Suggested labels

ux, canvas/io

🐰 I hopped through code and found a treat,
Icons and Unsplash now happily meet,
Drag a flower, drop a star,
A library blooms both near and far,
Canvas whispers — artwork, complete. 🎨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.38% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check ❓ Inconclusive The title is vague and uses non-descriptive terms like 'day309' and 'UX' that don't clearly convey the main change. Consider a more specific title that clearly describes the primary change, e.g., 'Add Photos Library browser with Unsplash integration' or 'Implement photos browsing in Library component'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ccbac33 and c6572f8.

📒 Files selected for processing (2)
  • editor/components/logos/unsplash.tsx (1 hunks)
  • editor/grida-canvas-hosted/library/lib-photos-actions.ts (1 hunks)

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@vercel vercel Bot temporarily deployed to Preview – blog December 12, 2025 18:45 Inactive
@vercel vercel Bot temporarily deployed to Preview – viewer December 12, 2025 18:49 Inactive
@vercel vercel Bot temporarily deployed to Preview – backgrounds December 12, 2025 18:50 Inactive
@vercel vercel Bot temporarily deployed to Preview – blog December 12, 2025 18:50 Inactive
@softmarshmallow softmarshmallow changed the title Daily RC day309 Grida Canvas - UX / Photos Library Dec 12, 2025
@softmarshmallow softmarshmallow marked this pull request as ready for review December 12, 2025 19:09
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
editor/grida-canvas-react/use-data-transfer.ts (1)

581-673: Fix ondrop reliability: deps, decode failure handling, and fetch non-2xx handling.

This path can currently throw (decode) and can report success on failed HTTP responses; also instance is used but not in the deps array.

   const ondrop = useCallback(
     (event: React.DragEvent<HTMLDivElement>) => {
       event.preventDefault();

       const knwondata = event.dataTransfer.getData("x-grida-data-transfer");
       if (knwondata) {
-        const data = datatransfer.decode(knwondata);
+        let data: datatransfer.DataTransferPayload;
+        try {
+          data = datatransfer.decode(knwondata);
+        } catch {
+          toast.error("Invalid drag payload");
+          return;
+        }
         switch (data.type) {
-          case "svg":
+          case "svg": {
             const { name, src } = data;
-            const task = fetch(src, {
+            const task = fetch(src, {
               cache: "no-store",
-            }).then((res) =>
-              res.text().then((text) => {
-                insertSVG(name, text, event);
-              })
-            );
+            }).then(async (res) => {
+              if (!res.ok) throw new Error(`Failed to fetch SVG (${res.status})`);
+              const text = await res.text();
+              insertSVG(name, text, event);
+            });

             toast.promise(task, {
               loading: "Loading...",
               success: "Inserted",
               error: "Failed to insert SVG",
             });
             break;
+          }
           case "image": {
             const { name, src, width, height } = data;
             const task = (async () => {
               const imageRef = await instance.createImageAsync(src);
               const [x, y] = instance.camera.clientPointToCanvasPoint([
                 event.clientX,
                 event.clientY,
               ]);
               const node = instance.commands.createRectangleNode();
               node.$.position = "absolute";
               node.$.name = name || "Photo";
               node.$.left = x;
               node.$.top = y;
               node.$.width = width || imageRef.width;
               node.$.height = height || imageRef.height;
               node.$.fill_paints = [
                 {
                   type: "image",
                   src: imageRef.url,
                   fit: "cover",
                   transform: cmath.transform.identity,
                   filters: cg.def.IMAGE_FILTERS,
                   blend_mode: cg.def.BLENDMODE,
                   opacity: 1,
                   active: true,
                 } satisfies cg.ImagePaint,
               ];
             })();

             toast.promise(task, {
               loading: "Loading image...",
               success: "Image inserted",
               error: "Failed to insert image",
             });
             break;
           }
         // ...
       }
       // ...
     },
-    [insertFromFile, insertSVG]
+    [insertFromFile, insertSVG, instance]
   );

(If you want this even safer: validate src scheme/host before fetching/creating images.)

🧹 Nitpick comments (12)
editor/package.json (1)

221-221: Pin unsplash-js more tightly (and confirm key handling stays server-side).

^7.0.20 can float; consider pinning (or at least ~7.0.20) if you want to reduce surprise API changes.

Also please verify any Unsplash access key usage is server-only (e.g., Route Handler / Server Action) and not embedded into client bundles.

-    "unsplash-js": "^7.0.20",
+    "unsplash-js": "~7.0.20",
editor/grida-canvas-hosted/library/components/loading-indicator.tsx (1)

12-22: Looks good; consider hiding from a11y tree when loading is false.

If Progress announces itself, you may want aria-hidden={!loading} (or conditional render) instead of opacity-only hiding.

editor/grida-canvas-hosted/library/components/search-input.tsx (1)

19-39: Use cn() for className composition instead of template string.

 import React from "react";
+import { cn } from "@/components/lib/utils";
 // ...
 export function SearchInput({
 // ...
 }: SearchInputProps) {
   return (
-    <InputGroup className={`h-7 ${className || ""}`}>
+    <InputGroup className={cn("h-7", className)}>
       <InputGroupAddon align="inline-start" className="ps-2">
         <SearchIcon className="size-3" />
       </InputGroupAddon>
editor/grida-canvas-hosted/library/library.tsx (2)

15-39: LGTM! Clean TabButton component.

The TabButton component is well-structured with appropriate accessibility and styling. The value prop is defined but unused - consider removing it if it's not needed for future identification purposes.

 function TabButton({
-  value,
   active,
   onClick,
   children,
 }: {
-  value: string;
   active: boolean;
   onClick: () => void;
   children: React.ReactNode;
 }) {

88-105: Silent failure when no image URL is available for drag.

When imageUrl is falsy, the drag operation silently fails without any data transfer. Consider logging a warning or providing fallback behavior to help debug issues.

   const handlePhotoDragStart = useCallback(
     (photo: PhotoAsset, event: React.DragEvent<HTMLElement>) => {
       const imageUrl = photo.urls.regular || photo.urls.full || photo.urls.raw;
       if (imageUrl) {
         event.dataTransfer.setData(
           datatransfer.key,
           datatransfer.encode({
             type: "image",
             name: photo.alt || "Photo",
             src: imageUrl,
             width: photo.width,
             height: photo.height,
           })
         );
+      } else {
+        console.warn("Photo drag started but no valid URL available", photo.id);
       }
     },
     []
   );
editor/grida-canvas-hosted/library/components/pills.tsx (2)

26-53: Consider adding type="button" for explicit button behavior.

While not strictly required, explicitly setting type="button" prevents accidental form submissions if this component is ever used inside a form.

     <button
+      type="button"
       data-has-thumbnail={!!thumbnail}
       className={cn(

40-49: Add error handling for Image component.

The component is properly set up as a Client Component ('use client'), which allows using the onError handler. Since thumbnail URLs may fail to load, consider adding an onError handler to display a fallback state or gracefully handle load failures. Example approach: track fallback state using useState to avoid infinite retry loops.

editor/grida-canvas-hosted/library/lib-icons.ts (2)

77-131: Consider stricter typing for API response normalization.

The normalizeIcons function uses any[] for the raw list parameter. While this is pragmatic for handling varied API responses, consider defining a loose input type to document expected fields and improve IDE support.

+type RawIconItem = {
+  download?: string;
+  vendor?: string;
+  host?: string;
+  family?: string;
+  name?: string;
+  title?: string;
+  id?: string;
+  variant?: string;
+  style?: string;
+  size?: number;
+  properties?: {
+    style?: string;
+    variant?: string;
+    size?: number;
+  };
+  tags?: string[];
+  keywords?: string[];
+};

 const normalizeIcons = (
-  rawList: any[],
+  rawList: RawIconItem[],
   allowedVendors?: Set<IconVendorId>
 ): IconsBrowserItem[] => {

168-202: Consider adding request timeout for icon fetching.

The fetchIcons function uses cache: "no-store" which is appropriate for fresh data, but there's no timeout handling. Long-running requests could block the UI.

 export async function fetchIcons({
   vendor,
   variants,
   allowedVendors,
+  signal,
 }: {
   vendor?: IconVendorId | null;
   variants?: IconVariantFilters;
   allowedVendors?: Iterable<IconVendorId>;
+  signal?: AbortSignal;
 }): Promise<IconsBrowserItem[]> {
   const params = new URLSearchParams();
   // ...
-  const res = await fetch(url, { cache: "no-store" });
+  const res = await fetch(url, { cache: "no-store", signal });

This allows callers to implement timeout behavior via AbortController.

editor/grida-canvas-hosted/library/icons-browser.tsx (2)

82-99: Consider memoizing allowedVendors set.

The allowedVendors set is recreated on every fetchIcons call. While the impact is minimal, it could be memoized outside the callback since it only depends on vendors.

+  const allowedVendors = useMemo(
+    () => new Set(vendors.map((v) => v.vendor)),
+    [vendors]
+  );
+
   const fetchIcons = useCallback(async () => {
     if (!vendorsLoaded) return;
     setLoading(true);
     setError(null);
     try {
-      const allowedVendors = new Set(vendors.map((v) => v.vendor));
       const list = await fetchIconsFromApi({
         vendor: filters.vendor,
         variants: filters.variants,
         allowedVendors,
       });
       setIcons(list);
     } catch (e) {
       setError(e instanceof Error ? e.message : "Failed to load icons");
     } finally {
       setLoading(false);
     }
-  }, [filters.vendor, filters.variants, vendorsLoaded, vendors]);
+  }, [filters.vendor, filters.variants, vendorsLoaded, allowedVendors]);

367-373: handleInsert correctly handles async onInsert.

The callback properly awaits the onInsert promise. However, there's no loading feedback or error handling during insertion. Consider adding try-catch if insertion errors should be surfaced to the user.

editor/grida-canvas-hosted/library/lib-photos-actions.ts (1)

186-198: Pagination metadata may be misleading after filtering.

The total and totalPages reflect API totals before filtering premium photos. After filterFreePhotos, the actual results.length could be significantly less than perPage, and the true total of free photos is unknown. Consider adding a comment or documenting this limitation for consumers.

The current comment on lines 186-187 acknowledges this, which is good. Consumers should be aware that pagination may appear inconsistent.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d19851d and ccbac33.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (15)
  • editor/app/(dev)/ui/floating-window/page.tsx (3 hunks)
  • editor/components/logos/unsplash.tsx (1 hunks)
  • editor/grida-canvas-hosted/library/components/loading-indicator.tsx (1 hunks)
  • editor/grida-canvas-hosted/library/components/pills.tsx (1 hunks)
  • editor/grida-canvas-hosted/library/components/search-input.tsx (1 hunks)
  • editor/grida-canvas-hosted/library/icons-browser.tsx (10 hunks)
  • editor/grida-canvas-hosted/library/lib-icons.ts (1 hunks)
  • editor/grida-canvas-hosted/library/lib-photos-actions.ts (1 hunks)
  • editor/grida-canvas-hosted/library/library.tsx (1 hunks)
  • editor/grida-canvas-hosted/library/photos-browser.tsx (1 hunks)
  • editor/grida-canvas-hosted/playground/playground.tsx (6 hunks)
  • editor/grida-canvas-react/use-data-transfer.ts (3 hunks)
  • editor/grida-canvas/data-transfer.ts (1 hunks)
  • editor/next.config.ts (1 hunks)
  • editor/package.json (1 hunks)
🧰 Additional context used
📓 Path-based instructions (5)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Use TypeScript 5 as the main language for most apps
Use Lucide or Radix Icons for icons

Files:

  • editor/grida-canvas-hosted/library/components/loading-indicator.tsx
  • editor/grida-canvas/data-transfer.ts
  • editor/grida-canvas-hosted/library/library.tsx
  • editor/next.config.ts
  • editor/grida-canvas-react/use-data-transfer.ts
  • editor/grida-canvas-hosted/library/components/search-input.tsx
  • editor/app/(dev)/ui/floating-window/page.tsx
  • editor/components/logos/unsplash.tsx
  • editor/grida-canvas-hosted/library/components/pills.tsx
  • editor/grida-canvas-hosted/library/photos-browser.tsx
  • editor/grida-canvas-hosted/library/lib-photos-actions.ts
  • editor/grida-canvas-hosted/library/lib-icons.ts
  • editor/grida-canvas-hosted/library/icons-browser.tsx
  • editor/grida-canvas-hosted/playground/playground.tsx
**/*.{tsx,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use React.js 19 for web UI development

Files:

  • editor/grida-canvas-hosted/library/components/loading-indicator.tsx
  • editor/grida-canvas-hosted/library/library.tsx
  • editor/grida-canvas-hosted/library/components/search-input.tsx
  • editor/app/(dev)/ui/floating-window/page.tsx
  • editor/components/logos/unsplash.tsx
  • editor/grida-canvas-hosted/library/components/pills.tsx
  • editor/grida-canvas-hosted/library/photos-browser.tsx
  • editor/grida-canvas-hosted/library/icons-browser.tsx
  • editor/grida-canvas-hosted/playground/playground.tsx
**/*.{ts,tsx,css}

📄 CodeRabbit inference engine (AGENTS.md)

Use Tailwind CSS 4 for styling

Files:

  • editor/grida-canvas-hosted/library/components/loading-indicator.tsx
  • editor/grida-canvas/data-transfer.ts
  • editor/grida-canvas-hosted/library/library.tsx
  • editor/next.config.ts
  • editor/grida-canvas-react/use-data-transfer.ts
  • editor/grida-canvas-hosted/library/components/search-input.tsx
  • editor/app/(dev)/ui/floating-window/page.tsx
  • editor/components/logos/unsplash.tsx
  • editor/grida-canvas-hosted/library/components/pills.tsx
  • editor/grida-canvas-hosted/library/photos-browser.tsx
  • editor/grida-canvas-hosted/library/lib-photos-actions.ts
  • editor/grida-canvas-hosted/library/lib-icons.ts
  • editor/grida-canvas-hosted/library/icons-browser.tsx
  • editor/grida-canvas-hosted/playground/playground.tsx
**/components/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use Shadcn UI for reusable UI components

Files:

  • editor/grida-canvas-hosted/library/components/loading-indicator.tsx
  • editor/grida-canvas-hosted/library/components/search-input.tsx
  • editor/components/logos/unsplash.tsx
  • editor/grida-canvas-hosted/library/components/pills.tsx
{editor,apps}/**/app/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use Next.js 15 as the web framework

Files:

  • editor/app/(dev)/ui/floating-window/page.tsx
🧠 Learnings (9)
📚 Learning: 2025-12-01T00:21:48.564Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-01T00:21:48.564Z
Learning: Applies to **/components/**/*.{ts,tsx} : Use Shadcn UI for reusable UI components

Applied to files:

  • editor/grida-canvas-hosted/library/components/loading-indicator.tsx
  • editor/grida-canvas-hosted/library/components/search-input.tsx
  • editor/grida-canvas-hosted/library/icons-browser.tsx
  • editor/grida-canvas-hosted/playground/playground.tsx
📚 Learning: 2025-12-01T00:22:19.083Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: crates/grida-canvas-wasm/AGENTS.md:0-0
Timestamp: 2025-12-01T00:22:19.083Z
Learning: Applies to crates/grida-canvas-wasm/**/main.rs : Update `grida-canvas-wasm.d.ts` TypeScript definitions file when new APIs are introduced via `main.rs`

Applied to files:

  • editor/grida-canvas/data-transfer.ts
  • editor/grida-canvas-react/use-data-transfer.ts
  • editor/grida-canvas-hosted/library/components/pills.tsx
  • editor/grida-canvas-hosted/library/lib-icons.ts
  • editor/grida-canvas-hosted/playground/playground.tsx
📚 Learning: 2025-12-01T00:21:48.564Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-01T00:21:48.564Z
Learning: Applies to editor/lib/**/*.{ts,tsx} : Keep /lib modules in the /editor directory strictly designed, non-opinionated, reusable, and stable for promotion to /packages directory

Applied to files:

  • editor/grida-canvas-hosted/library/library.tsx
  • editor/grida-canvas-hosted/library/lib-icons.ts
📚 Learning: 2025-12-01T00:22:56.899Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: editor/app/(tools)/tools/halftone/AGENTS.md:0-0
Timestamp: 2025-12-01T00:22:56.899Z
Learning: Applies to editor/app/(tools)/tools/halftone/app/(tools)/tools/halftone/_page.tsx : Cache ImageData and dimensions in refs (imageDataRef, sizeRef) for efficient exports

Applied to files:

  • editor/grida-canvas-react/use-data-transfer.ts
📚 Learning: 2025-12-01T00:22:56.899Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: editor/app/(tools)/tools/halftone/AGENTS.md:0-0
Timestamp: 2025-12-01T00:22:56.899Z
Learning: Applies to editor/app/(tools)/tools/halftone/app/(tools)/tools/halftone/_page.tsx : Use React hooks for state management (imageSrc, shape, grid, maxRadius, gamma, jitter, opacity, color, customShapeImage, imageDataRef, sizeRef)

Applied to files:

  • editor/grida-canvas-react/use-data-transfer.ts
  • editor/app/(dev)/ui/floating-window/page.tsx
  • editor/grida-canvas-hosted/library/components/pills.tsx
  • editor/grida-canvas-hosted/library/photos-browser.tsx
  • editor/grida-canvas-hosted/library/icons-browser.tsx
  • editor/grida-canvas-hosted/playground/playground.tsx
📚 Learning: 2025-12-01T00:22:56.899Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: editor/app/(tools)/tools/halftone/AGENTS.md:0-0
Timestamp: 2025-12-01T00:22:56.899Z
Learning: Applies to editor/app/(tools)/tools/halftone/app/(tools)/tools/halftone/_page.tsx : Custom images used as halftone shapes should be loaded as HTMLImageElement for efficient canvas rendering and preserve original image colors in as-is mode

Applied to files:

  • editor/grida-canvas-react/use-data-transfer.ts
📚 Learning: 2025-12-01T00:22:56.899Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: editor/app/(tools)/tools/halftone/AGENTS.md:0-0
Timestamp: 2025-12-01T00:22:56.899Z
Learning: Applies to editor/app/(tools)/tools/halftone/app/(tools)/tools/halftone/_page.tsx : When adding new shape types, update the Shape type union, add cases in drawShape() function, add cases in shapeToSVG() function, and add SelectItem in UI

Applied to files:

  • editor/app/(dev)/ui/floating-window/page.tsx
  • editor/grida-canvas-hosted/library/components/pills.tsx
📚 Learning: 2025-12-01T00:21:48.564Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-01T00:21:48.564Z
Learning: Applies to **/*.{ts,tsx} : Use Lucide or Radix Icons for icons

Applied to files:

  • editor/grida-canvas-hosted/library/lib-icons.ts
  • editor/grida-canvas-hosted/library/icons-browser.tsx
  • editor/grida-canvas-hosted/playground/playground.tsx
📚 Learning: 2025-12-01T00:21:48.564Z
Learnt from: CR
Repo: gridaco/grida PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-01T00:21:48.564Z
Learning: Applies to **/*.{tsx,jsx} : Use React.js 19 for web UI development

Applied to files:

  • editor/grida-canvas-hosted/library/icons-browser.tsx
🧬 Code graph analysis (4)
editor/grida-canvas-hosted/library/components/search-input.tsx (1)
editor/components/ui/input-group.tsx (3)
  • InputGroup (164-164)
  • InputGroupAddon (165-165)
  • InputGroupInput (168-168)
editor/app/(dev)/ui/floating-window/page.tsx (4)
editor/grida-canvas-hosted/library/icons-browser.tsx (2)
  • IconsBrowserItem (48-48)
  • IconsBrowser (341-528)
editor/grida-canvas-hosted/library/photos-browser.tsx (1)
  • PhotosBrowser (417-417)
editor/components/floating-window/floating-window.tsx (3)
  • FloatingWindowTrigger (83-85)
  • FloatingWindowBody (74-81)
  • FloatingWindowRoot (47-59)
editor/components/floating-window/primitives.tsx (3)
  • FloatingWindowTrigger (425-464)
  • FloatingWindowBody (514-530)
  • FloatingWindowRoot (153-356)
editor/grida-canvas-hosted/library/photos-browser.tsx (4)
editor/grida-canvas-hosted/library/lib-photos-actions.ts (4)
  • PhotoAsset (17-41)
  • PhotoTopic (52-58)
  • fetchPhotosAction (264-338)
  • fetchPhotoTopics (245-262)
editor/grida-canvas-hosted/library/components/loading-indicator.tsx (1)
  • LoadingIndicator (12-24)
editor/grida-canvas-hosted/library/components/search-input.tsx (1)
  • SearchInput (19-41)
editor/grida-canvas-hosted/library/components/pills.tsx (2)
  • PillsList (61-72)
  • Pill (17-54)
editor/grida-canvas-hosted/playground/playground.tsx (2)
editor/components/floating-window/primitives.tsx (4)
  • useFloatingWindowControls (394-416)
  • FloatingWindowClose (473-512)
  • FloatingWindowBody (514-530)
  • FloatingWindowTrigger (425-464)
editor/grida-canvas-hosted/library/library.tsx (1)
  • Library (47-188)
🔇 Additional comments (33)
editor/app/(dev)/ui/floating-window/page.tsx (1)

76-218: Library panes + new Photos floating window integration looks clean.

The useMemo panes keep the render blocks readable, and the new window is wired consistently with the existing ones.

editor/next.config.ts (1)

47-51: No action needed. Next.js 15.3.6 fully supports glob-style wildcard patterns in remotePatterns.hostname. The pattern "*.unsplash.com" correctly matches single-level subdomains (e.g., images.unsplash.com, plus.unsplash.com) and will not silently break image loading.

editor/grida-canvas-hosted/library/library.tsx (2)

107-152: Good error handling pattern in photo insertion.

The async IIFE pattern with try-catch and toast.promise provides proper error handling and user feedback. The re-throwing pattern correctly preserves error information.


154-187: Well-structured tabbed UI implementation.

The component correctly handles tab state persistence, conditional rendering, and prop passing to child browsers. The flex layout with min-h-0 properly handles overflow in flexbox contexts.

editor/grida-canvas-hosted/library/components/pills.tsx (1)

61-71: Good use of gradient overlays for scroll indication.

The implementation with pointer-events-none and gradient overlays provides a clean visual cue for scrollable content. The hidden ScrollBar is appropriate for this horizontal pills design.

editor/grida-canvas-hosted/playground/playground.tsx (4)

38-38: Good icon choice from Radix Icons.

Using Cross1Icon from @radix-ui/react-icons aligns with the coding guidelines that specify using Lucide or Radix Icons.


368-370: Clean rename from icons-specific to library-generic controls.

The rename from iconsWindowControls to libraryWindowControls properly reflects the broadened scope of the unified Library UI.


481-508: Well-integrated floating window for Library UI.

The FloatingWindowRoot is properly configured with the new "library" windowId, appropriate dimensions, and correctly renders the Library component. The close button implementation with Cross1Icon and sr-only label provides good accessibility.


592-606: Consistent prop type update in SidebarLeft.

The prop rename from iconsWindowControls to libraryWindowControls is correctly reflected in both the destructured props and the TypeScript type definition.

editor/grida-canvas-hosted/library/photos-browser.tsx (8)

101-159: Good pagination and random mode handling.

The loadMore function correctly differentiates between random mode (no pagination) and search/topic modes (with pagination). The guard conditions at the start prevent duplicate requests.


161-182: Infinite scroll with IntersectionObserver implemented correctly.

The effect properly cleans up the observer on unmount and correctly handles the intersection callback with appropriate guards. The rootMargin: "200px" provides good preloading behavior.


184-191: Initial load effect is well-guarded.

Using hasInitialLoadRef to ensure single initial load is a good pattern. The eslint-disable comment is appropriate here since we explicitly want mount-only behavior.


337-343: Virtualizer configuration looks correct.

Using lanes: 2 for a two-column masonry-style layout with overscan: 6 provides good scrolling performance. The estimateSize callback properly calculates heights based on aspect ratios.


345-356: ResizeObserver cleanup is properly handled.

The effect correctly observes container width changes and disconnects on cleanup.


596-602: Consider adding unique keys for skeleton items.

The skeleton keys use index which is fine for static placeholders, but the template literal could be simplified.


654-690: PhotoCard handles drag and click correctly.

The component properly supports both drag-and-drop and click-to-insert workflows. The figcaption with gradient overlay provides good UX for author attribution.


620-641: Unsplash attribution is incomplete and does not meet API requirements.

The implementation provides a link to Unsplash but is missing required attribution elements. Per Unsplash API guidelines, proper attribution must include:

  • Photographer name and link to their profile
  • Link to Unsplash with UTM parameters (?utm_source=your_app_name&utm_medium=referral)
  • Text like "Photo by [Name] on Unsplash"

The current code only links to the Unsplash homepage without photographer attribution or UTM tracking, which does not satisfy API integration requirements.

Likely an incorrect or invalid review comment.

editor/grida-canvas-hosted/library/lib-icons.ts (4)

1-7: Well-defined IconsBrowserItem type.

The type provides a clean interface for icon items with appropriate optional fields. This aligns with learnings about keeping lib modules reusable and stable.


9-12: Good use of branded string union with escape hatch.

The (string & {}) pattern allows type-safe known values while permitting arbitrary strings when needed. This is a useful pattern for extensible category systems.


157-166: Good caching strategy for vendor data.

Using cache: "force-cache" for vendor metadata is appropriate since this data changes infrequently. The error handling with status code in the message aids debugging.


204-209: Clean utility for default variant generation.

The getDefaultVariants function provides a straightforward way to initialize variant filters with the ANY_VARIANT sentinel value.

editor/grida-canvas-hosted/library/icons-browser.tsx (4)

32-48: Imports and exports look well-organized.

The refactored imports properly separate concerns: UI components from internal library, icons from lucide-react (CheckIcon), and type/utility exports from ./lib-icons. The re-export of IconsBrowserItem maintains backward compatibility for consumers.


410-432: Clean migration to new UI primitives.

The refactored search input and pills-based vendor selection using SearchInput, Pill, and PillsList components follows a consistent pattern and improves maintainability. The active state binding (active={selectedVendor === vendor.vendor}) is correct.


484-523: Virtualized grid rendering is well-implemented.

The row-based virtualization correctly:

  • Calculates row items from filteredIcons using column count
  • Handles empty cells with placeholder divs
  • Uses stable keys (icon.id and empty-${virtualRow.key}-${idx})
  • Passes onDragStart through to each IconGridCell

289-311: Line range correction: the IconGridCell component spans lines 289-339, not 289-311.

The component correctly delegates drag handling to the parent via the onDragStart callback. The parent handler in library.tsx (lines 74-79) properly calls event.dataTransfer.setData() with encoded icon data, so the drag operation has the required transfer payload.

editor/grida-canvas-hosted/library/lib-photos-actions.ts (8)

1-4: Server action directive is correctly placed.

The "use server" directive at the top marks this as a Next.js Server Actions module. The unsplash-js import is appropriate for server-side usage.


6-15: Type extension for premium/plus flags is appropriate.

The UnsplashPhoto type correctly extends the base type to include the plus and premium flags that the Unsplash API returns but aren't in the library's types. The JSDoc comments clearly explain the filtering purpose.


89-105: Client caching works but consider edge cases.

The module-level clientCache is effective for reducing client instantiation. However, in serverless environments, the cache may persist across invocations within the same instance. This is generally acceptable for an API client, but note that if environment variables change at runtime, the cached client won't reflect those changes.


107-130: Normalization handles edge cases well.

The normalizePhoto function properly handles:

  • Missing alt_description with a fallback chain
  • Type narrowing for premium/plus fields using "in" operator
  • Profile image resolution fallback (small → medium → large)

139-156: Random photos implementation handles API response variance correctly.

The function correctly handles the Unsplash API behavior where getRandom returns either a single photo object or an array depending on the count parameter. The Array.isArray check at line 153 is essential.


202-243: Search implementation is correct and mirrors topic photos pattern.

The query validation, API call, normalization, and result construction follow the established pattern. The same pagination caveat (filtered results vs. API totals) applies here as well.


279-290: Mode fallback logic may produce unexpected behavior.

The condition mode === "random" || (!safeQuery && mode !== "topic") means:

  • If mode === "search" but query is empty, it falls back to random mode silently.

This might be intentional UX, but consider whether an empty search query should return an error instead of random photos, to avoid confusing the caller.


326-337: Error handling is robust and produces consistent state.

The catch block returns a well-structured error state with:

  • status: "error"
  • Preserved mode for context
  • Empty results array
  • Sanitized error message

This ensures consumers always receive a valid PhotoActionState shape.

Comment thread editor/app/(dev)/ui/floating-window/page.tsx
Comment thread editor/components/logos/unsplash.tsx
Comment thread editor/grida-canvas-hosted/library/lib-photos-actions.ts Outdated
Comment thread editor/grida-canvas-hosted/library/lib-photos-actions.ts
Comment on lines +51 to +72
const handleInsertIcon = useCallback(
async (icon: IconsBrowserItem) => {
const task = fetch(icon.download, { cache: "no-store" })
.then((res) => {
if (!res.ok) {
throw new Error("Failed to fetch icon");
}
return res.text();
})
.then((svg) => instance.commands.createNodeFromSvg(svg))
.then((node) => {
node.$.name = icon.name || node.$.name;
});

toast.promise(task, {
loading: "Loading icon...",
success: "Icon inserted",
error: "Failed to insert icon",
});
},
[instance.commands]
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unhandled promise rejection risk in icon insertion flow.

The task promise chain doesn't propagate errors correctly to toast.promise. If createNodeFromSvg returns a node and the subsequent .then block throws, the error won't be caught. Additionally, the callback accesses node.$.name without null-checking the node.

   const handleInsertIcon = useCallback(
     async (icon: IconsBrowserItem) => {
       const task = fetch(icon.download, { cache: "no-store" })
         .then((res) => {
           if (!res.ok) {
             throw new Error("Failed to fetch icon");
           }
           return res.text();
         })
         .then((svg) => instance.commands.createNodeFromSvg(svg))
         .then((node) => {
+          if (!node) {
+            throw new Error("Failed to create node from SVG");
+          }
           node.$.name = icon.name || node.$.name;
         });

       toast.promise(task, {
         loading: "Loading icon...",
         success: "Icon inserted",
         error: "Failed to insert icon",
       });
     },
     [instance.commands]
   );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleInsertIcon = useCallback(
async (icon: IconsBrowserItem) => {
const task = fetch(icon.download, { cache: "no-store" })
.then((res) => {
if (!res.ok) {
throw new Error("Failed to fetch icon");
}
return res.text();
})
.then((svg) => instance.commands.createNodeFromSvg(svg))
.then((node) => {
node.$.name = icon.name || node.$.name;
});
toast.promise(task, {
loading: "Loading icon...",
success: "Icon inserted",
error: "Failed to insert icon",
});
},
[instance.commands]
);
const handleInsertIcon = useCallback(
async (icon: IconsBrowserItem) => {
const task = fetch(icon.download, { cache: "no-store" })
.then((res) => {
if (!res.ok) {
throw new Error("Failed to fetch icon");
}
return res.text();
})
.then((svg) => instance.commands.createNodeFromSvg(svg))
.then((node) => {
if (!node) {
throw new Error("Failed to create node from SVG");
}
node.$.name = icon.name || node.$.name;
});
toast.promise(task, {
loading: "Loading icon...",
success: "Icon inserted",
error: "Failed to insert icon",
});
},
[instance.commands]
);
🤖 Prompt for AI Agents
In editor/grida-canvas-hosted/library/library.tsx around lines 51 to 72, the
promise chain for inserting an icon can swallow errors and accesses node.$.name
without checking node; change the implementation so the entire async flow
returns a single promise (or rewrite as async/await) that propagates any thrown
errors to toast.promise, and add a null/undefined check before setting
node.$.name (e.g., if (!node) throw new Error(...) or guard the assignment).
Ensure any synchronous operations inside .then return values or throw to be
caught by toast.promise so errors surface correctly.

Comment on lines +36 to +99
function usePhotos(perPage: number) {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, SEARCH_INPUT_DEBOUNCE_MS);
const [photos, setPhotos] = useState<PhotoAsset[]>([]);
const [topics, setTopics] = useState<PhotoTopic[]>([]);
const [topicsError, setTopicsError] = useState<string | null>(null);
const [topicsLoading, setTopicsLoading] = useState(true);
const [selectedTopic, setSelectedTopic] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const hasSearchedRef = useRef(false);

// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [currentMode, setCurrentMode] = useState<PhotoMode>("random");
const [currentQuery, setCurrentQuery] = useState("");
const [currentTopicSlug, setCurrentTopicSlug] = useState<string | null>(null);
const [totalPages, setTotalPages] = useState(1);

// Scroll detection for infinite scrolling
const sentinelRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const hasInitialLoadRef = useRef(false);

const loadInitial = useCallback(
(params: { mode: PhotoMode; topicSlug?: string; searchQuery?: string }) => {
// Reset scroll to top when switching topics/queries
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}

const queryToUse = params.searchQuery ?? query;

startTransition(async () => {
setError(null);
setCurrentPage(1);
setHasMore(true);
setCurrentMode(params.mode);
setCurrentQuery(queryToUse);
setCurrentTopicSlug(params.topicSlug ?? null);

const next = await fetchPhotosAction({
mode: params.mode,
query: queryToUse,
perPage,
topicSlug: params.topicSlug,
page: 1,
});
if (next.status === "error") {
setPhotos([]);
setError(next.message ?? "Failed to load photos");
setHasMore(false);
setTotalPages(0);
return;
}
setPhotos(next.results);
setTotalPages(next.totalPages ?? 1);
setHasMore((next.totalPages ?? 1) > 1);
});
},
[perPage, query]
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Complex hook with potential stale closure issue.

The loadInitial callback depends on query but also receives searchQuery as a parameter. When params.searchQuery is undefined, it falls back to query from closure, which may be stale in certain call sequences. Consider making the search query parameter required or removing the closure dependency.

   const loadInitial = useCallback(
-    (params: { mode: PhotoMode; topicSlug?: string; searchQuery?: string }) => {
+    (params: { mode: PhotoMode; topicSlug?: string; searchQuery: string }) => {
       // Reset scroll to top when switching topics/queries
       if (scrollRef.current) {
         scrollRef.current.scrollTop = 0;
       }

-      const queryToUse = params.searchQuery ?? query;
+      const queryToUse = params.searchQuery;

       startTransition(async () => {
         // ...
       });
     },
-    [perPage, query]
+    [perPage]
   );

Then update call sites to always pass the query explicitly.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function usePhotos(perPage: number) {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, SEARCH_INPUT_DEBOUNCE_MS);
const [photos, setPhotos] = useState<PhotoAsset[]>([]);
const [topics, setTopics] = useState<PhotoTopic[]>([]);
const [topicsError, setTopicsError] = useState<string | null>(null);
const [topicsLoading, setTopicsLoading] = useState(true);
const [selectedTopic, setSelectedTopic] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const hasSearchedRef = useRef(false);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [currentMode, setCurrentMode] = useState<PhotoMode>("random");
const [currentQuery, setCurrentQuery] = useState("");
const [currentTopicSlug, setCurrentTopicSlug] = useState<string | null>(null);
const [totalPages, setTotalPages] = useState(1);
// Scroll detection for infinite scrolling
const sentinelRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const hasInitialLoadRef = useRef(false);
const loadInitial = useCallback(
(params: { mode: PhotoMode; topicSlug?: string; searchQuery?: string }) => {
// Reset scroll to top when switching topics/queries
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
const queryToUse = params.searchQuery ?? query;
startTransition(async () => {
setError(null);
setCurrentPage(1);
setHasMore(true);
setCurrentMode(params.mode);
setCurrentQuery(queryToUse);
setCurrentTopicSlug(params.topicSlug ?? null);
const next = await fetchPhotosAction({
mode: params.mode,
query: queryToUse,
perPage,
topicSlug: params.topicSlug,
page: 1,
});
if (next.status === "error") {
setPhotos([]);
setError(next.message ?? "Failed to load photos");
setHasMore(false);
setTotalPages(0);
return;
}
setPhotos(next.results);
setTotalPages(next.totalPages ?? 1);
setHasMore((next.totalPages ?? 1) > 1);
});
},
[perPage, query]
);
function usePhotos(perPage: number) {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, SEARCH_INPUT_DEBOUNCE_MS);
const [photos, setPhotos] = useState<PhotoAsset[]>([]);
const [topics, setTopics] = useState<PhotoTopic[]>([]);
const [topicsError, setTopicsError] = useState<string | null>(null);
const [topicsLoading, setTopicsLoading] = useState(true);
const [selectedTopic, setSelectedTopic] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const hasSearchedRef = useRef(false);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [currentMode, setCurrentMode] = useState<PhotoMode>("random");
const [currentQuery, setCurrentQuery] = useState("");
const [currentTopicSlug, setCurrentTopicSlug] = useState<string | null>(null);
const [totalPages, setTotalPages] = useState(1);
// Scroll detection for infinite scrolling
const sentinelRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const hasInitialLoadRef = useRef(false);
const loadInitial = useCallback(
(params: { mode: PhotoMode; topicSlug?: string; searchQuery: string }) => {
// Reset scroll to top when switching topics/queries
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
const queryToUse = params.searchQuery;
startTransition(async () => {
setError(null);
setCurrentPage(1);
setHasMore(true);
setCurrentMode(params.mode);
setCurrentQuery(queryToUse);
setCurrentTopicSlug(params.topicSlug ?? null);
const next = await fetchPhotosAction({
mode: params.mode,
query: queryToUse,
perPage,
topicSlug: params.topicSlug,
page: 1,
});
if (next.status === "error") {
setPhotos([]);
setError(next.message ?? "Failed to load photos");
setHasMore(false);
setTotalPages(0);
return;
}
setPhotos(next.results);
setTotalPages(next.totalPages ?? 1);
setHasMore((next.totalPages ?? 1) > 1);
});
},
[perPage]
);
🤖 Prompt for AI Agents
In editor/grida-canvas-hosted/library/photos-browser.tsx around lines 36 to 99,
loadInitial currently falls back to the outer `query` closure when
params.searchQuery is undefined, which can produce stale values; make the search
query explicit by changing loadInitial signature to require a searchQuery
(remove fallback to closure) and update all call sites to pass the intended
query string (e.g., always pass debouncedQuery or current input) so loadInitial
no longer depends on the potentially stale `query` state; ensure tests and
usages set searchQuery when switching topics/modes and update the types
accordingly.

Comment on lines +2 to +23
export type DataTransferPayload =
| {
type: "svg";
name: string;
src: string;
}
| {
type: "image";
name: string;
src: string;
width?: number;
height?: number;
};

export const key = "x-grida-data-transfer";
export function encode(data: any) {
export function encode(data: DataTransferPayload) {
const txt = JSON.stringify(data);
return txt;
}
export function decode(data: string): DataTransferPayload {
return JSON.parse(data) as DataTransferPayload;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Harden decode() against invalid/malicious payloads (don’t trust JSON.parse + cast).

At minimum, catch parse errors and validate the discriminant + required fields before returning.

 export namespace datatransfer {
+  function isRecord(v: unknown): v is Record<string, unknown> {
+    return typeof v === "object" && v !== null;
+  }
+
   export function encode(data: DataTransferPayload) {
     const txt = JSON.stringify(data);
     return txt;
   }
   export function decode(data: string): DataTransferPayload {
-    return JSON.parse(data) as DataTransferPayload;
+    const parsed: unknown = JSON.parse(data);
+    if (!isRecord(parsed) || typeof parsed.type !== "string") {
+      throw new Error("Invalid data-transfer payload");
+    }
+    if (parsed.type === "svg") {
+      if (typeof parsed.name !== "string" || typeof parsed.src !== "string") {
+        throw new Error("Invalid svg payload");
+      }
+      return parsed as DataTransferPayload;
+    }
+    if (parsed.type === "image") {
+      if (typeof parsed.name !== "string" || typeof parsed.src !== "string") {
+        throw new Error("Invalid image payload");
+      }
+      return parsed as DataTransferPayload;
+    }
+    throw new Error("Unknown payload type");
   }
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In editor/grida-canvas/data-transfer.ts around lines 2–23, decode() currently
trusts JSON.parse and a cast; wrap the parse in a try/catch to handle malformed
JSON, then perform runtime validation on the resulting object: ensure it is a
plain object, validate discriminant "type" is either "svg" or "image", check
required fields (name and src) are non-empty strings, and for "image" validate
optional width/height are numbers if present. If validation fails, throw a clear
error (or return a safe default) rather than returning a blind cast; implement
these checks in a small type-guard helper used by decode().

@vercel vercel Bot temporarily deployed to Preview – blog December 12, 2025 19:24 Inactive
@vercel vercel Bot temporarily deployed to Preview – viewer December 12, 2025 19:24 Inactive
@vercel vercel Bot temporarily deployed to Preview – backgrounds December 12, 2025 19:24 Inactive
@softmarshmallow softmarshmallow merged commit 6f68e99 into main Dec 12, 2025
8 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant