Skip to content

feat: P2.3 collections, optimistic UI, security hardening, landing polish#26

Merged
MinitJain merged 5 commits into
mainfrom
p2-collections
Mar 30, 2026
Merged

feat: P2.3 collections, optimistic UI, security hardening, landing polish#26
MinitJain merged 5 commits into
mainfrom
p2-collections

Conversation

@MinitJain

@MinitJain MinitJain commented Mar 30, 2026

Copy link
Copy Markdown
Owner

P2.3 Collections (complete)

  • Full CRUD API for collections and bookmark
    membership
  • Collection pills in the dashboard with active
    filter banner
  • Inline delete confirmation on collection pills
  • Folder button on each bookmark card to
    add/remove from collections
  • Collection chips shown directly on each card

Optimistic UI (everything is now instant)

  • Tag add/remove: no more router.refresh(),
    reverts on failure
  • Bookmark delete: fires only on API success
  • Collection membership: add/remove with revert on
    failure
  • Collection delete: snapshot revert on failure
  • Tag filter pills now live-update when tags
    change on cards

Security

  • Thumbnail SSRF protection (validates URL before
    storing)
  • Gemini prompt injection fix (truncate + strip
    newlines from user input)
  • Auth standardized to getUserFromRequest across
    all API routes
  • Prisma P2025 handled gracefully in collection
    bookmark DELETE
  • supabase-rls.sql added — run this in Supabase
    SQL editor to enable RLS
  • Safety cap (take: 500) on bookmark GET endpoint

Bug fixes

  • Concurrent tag removal uses Set instead
    of single string
  • Tag input disabled during inflight add request
  • Missing try/catch on collections POST/DELETE
    routes
  • handleDeleteCollection reverts UI on API failure

Landing page

  • Removed "AI-powered bookmarking" badge (cleaner
    hero)
  • Bigger headings across all sections
  • Em dashes replaced with hyphens throughout
  • Section labels: emojis removed, uppercase
    tracking-wide style

Action required

After merging, run supabase-rls.sql in your
Supabase SQL editor to enable Row Level Security
on all tables.

Summary by CodeRabbit

  • New Features

    • Collections API: create, list, delete collections; add/remove bookmarks to collections; collection-based filtering
    • Dashboard and Bookmark UI: initial collections, client-side membership handling, collection chips and optimistic updates
    • Admin users list is now cached for faster admin page loads
  • Bug Fixes

    • Thumbnail URL safety validation
    • Tag-generation preprocessing to reduce duplicates and trim input
    • Rate-limit message punctuation standardized
  • Style & UX

    • Increased typography sizes, removed hero badge, unified hyphen usage, added smooth scrolling
  • Security & Config

    • Enforced HTTPS for remote images; added row-level security policies and URL/IP validation utilities
  • Documentation

    • Project roadmap updated (collections marked complete; added daily digest plan and advanced tasks)

…lish

Collections (P2.3 complete):
- Full CRUD API: POST/DELETE /api/collections, add/remove bookmark endpoints
- DashboardClient: collection pills, active filter banner, inline delete confirm
- BookmarkCard: folder button dropdown, collection chips with inline remove

Optimistic updates (instant UI, no router.refresh):
- Tag add/remove: local state with revert on failure, duplicate guard
- Bookmark delete: fires onDelete only on API success
- Collection membership: add/remove with revert on failure
- Collection delete: snapshot revert on failure
- Tag filter pills now live-update via onTagsChange callback

Security:
- Thumbnail SSRF protection in scraper.ts
- Gemini prompt injection fix (truncate + strip newlines)
- Auth standardized to getUserFromRequest across all API routes
- Prisma P2025 handled in collection bookmark DELETE
- supabase-rls.sql added for RLS policies (apply in Supabase SQL editor)
- Safety cap (take: 500) on bookmark GET

Bug fixes:
- Concurrent tag removal uses Set<string> instead of single removingId
- loading state now disables tag input during inflight request
- Missing try/catch on collections POST/DELETE routes
- handleDeleteCollection reverts on API failure

Landing page:
- Removed "AI-powered bookmarking" badge
- Bigger headings and subheadings (clamp values bumped up)
- Em dashes replaced with regular hyphens throughout
- Section headers: emojis removed, uppercase tracking-wide labels

Other:
- scroll-behavior smooth warning fixed (data-scroll-behavior on html)
- admin page revalidate=300 to prevent rate limiting Supabase listUsers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vercel

vercel Bot commented Mar 30, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
recall Ready Ready Preview, Comment Mar 30, 2026 7:09pm

@coderabbitai

coderabbitai Bot commented Mar 30, 2026

Copy link
Copy Markdown

Note

.coderabbit.yaml has unrecognized properties

CodeRabbit is using all valid settings from your configuration. Unrecognized properties (listed below) have been ignored and may indicate typos or deprecated fields that can be removed.

⚠️ Parsing warnings (1)
Validation error: Unrecognized key(s) in object: 'instructions'
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bef5fedd-a521-44e2-ae05-a385555c3273

📥 Commits

Reviewing files that changed from the base of the PR and between 44d5235 and bb7d37c.

📒 Files selected for processing (1)
  • CLAUDE.md
✅ Files skipped from review due to trivial changes (1)
  • CLAUDE.md

📝 Walkthrough

Walkthrough

Adds collection CRUD and membership APIs with auth, client-side collection UI/state and optimistic membership updates, replaces Supabase client auth with getUserFromRequest in APIs, adds Supabase RLS SQL, URL safety checks for scraped thumbnails, caches admin Supabase user listing, and minor UI/typography tweaks.

Changes

Cohort / File(s) Summary
Collection API Routes
client/src/app/api/collections/route.ts, client/src/app/api/collections/[id]/route.ts
New GET/POST/DELETE endpoints for user-scoped collections with auth, validation, ownership checks, idempotent delete handling, and error responses.
Collection-Bookmark Membership Routes
client/src/app/api/collections/[id]/bookmarks/route.ts, client/src/app/api/collections/[id]/bookmarks/[bookmarkId]/route.ts
New POST (upsert membership) and DELETE handlers; authenticate via getUserFromRequest, verify collection ownership, treat missing records (P2025) as idempotent 204.
Bookmarks & Tags APIs
client/src/app/api/bookmarks/route.ts, client/src/app/api/bookmarks/[id]/tags/route.ts, client/src/app/api/bookmarks/[id]/tags/[tagId]/route.ts
Switched auth retrieval to getUserFromRequest(req) across handlers; added take: 500 cap on GET bookmarks; minor error message punctuation tweak; handler param rename _reqreq.
Dashboard & Bookmark UI
client/src/components/DashboardClient.tsx, client/src/components/BookmarkCard.tsx, client/src/app/app/page.tsx
Large client-side state changes: DashboardClient accepts initialCollections, tracks localBookmarks, memberships, and tagOverrides; BookmarkCard API expanded with collection UI, optimistic tag/membership flows, local tag state; loader now fetches collections+bookmarks and serializes collectionIds.
Admin caching
client/src/app/admin/page.tsx
Added unstable_cache-wrapped getCachedAuthUsers to cache Supabase admin user listing (revalidate: 300) and replaced inline per-request listUsers call.
URL safety & scraper
client/src/lib/url-validation.ts, client/src/lib/scraper.ts
Added isPrivateIp and isSafeUrl; scraper validates scraped thumbnail URLs and returns null for unsafe/invalid thumbnails.
Gemini tagging
client/src/lib/gemini.ts
generateTags now strips newlines and truncates combined title+description to 500 chars before prompting the model.
Supabase RLS SQL
client/supabase-rls.sql
New SQL file adding RLS policies for Bookmark, Tag, Collection, and CollectionBookmark to enforce per-user ownership and scoped access via related rows.
URL validation & config
client/next.config.ts, client/src/app/globals.css, client/src/app/layout.tsx, client/src/app/page.tsx, client/src/app/auth/page.tsx
Removed HTTP remote image pattern (HTTPS-only); .font-mono-lp uses generic monospace; root <html> adds data-scroll-behavior="smooth"; landing/auth copy/typography and punctuation adjustments.
Minor utils & types
client/src/lib/*
Other small updates across libs (scraper, gemini) and types used by UI components to include collectionIds and new collection types.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Client as DashboardClient / BookmarkCard
  participant API as Next.js API routes
  participant Auth as getUserFromRequest
  participant DB as Prisma
  participant RLS as Supabase RLS

  Client->>API: POST /api/collections (create) with user session
  API->>Auth: extract user from request
  Auth-->>API: user
  API->>DB: prisma.collection.create({ userId })
  DB-->>API: created collection
  API-->>Client: 201 Created

  Client->>API: POST /api/collections/:id/bookmarks (add membership)
  API->>Auth: extract user from request
  Auth-->>API: user
  API->>DB: verify collection.userId == user.id
  DB-->>API: collection
  API->>DB: upsert collectionBookmark (collectionId, bookmarkId)
  DB-->>API: upsert result
  API-->>Client: 204 No Content

  Client->>API: DELETE /api/collections/:id/bookmarks/:bookmarkId (remove)
  API->>Auth: extract user from request
  Auth-->>API: user
  API->>DB: delete collectionBookmark (handle P2025 -> 204)
  DB-->>API: deleted / error
  API-->>Client: 204 No Content

  note over DB,RLS: RLS policies ensure DB operations are scoped to authenticated user ownership
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.58% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: collections implementation, optimistic UI updates, security hardening, and landing page polish.
Description check ✅ Passed The description covers all required template sections with substantial detail. It includes a comprehensive summary of changes organized by feature area, a clear action-required callout, and addresses all major PR objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch p2-collections

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.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@MinitJain

Copy link
Copy Markdown
Owner Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Mar 30, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🧹 Nitpick comments (4)
client/src/app/api/collections/route.ts (2)

6-16: Consider using NextResponse.json() for consistency with other API routes.

This route uses Response.json() while other routes in this PR (e.g., bookmarks/route.ts) use NextResponse.json(). Both work in Next.js App Router, but using NextResponse consistently enables easier adoption of Next.js-specific features like setting cookies or headers.

♻️ Suggested change for consistency
 import { prisma } from "@/lib/prisma";
 import { getUserFromRequest } from "@/lib/supabase/get-user";
-import { NextRequest } from "next/server";
+import { NextRequest, NextResponse } from "next/server";

 // GET /api/collections — list all collections for the user
 export async function GET(req: NextRequest) {
   const user = await getUserFromRequest(req);
-  if (!user) return Response.json({ error: "unauthorized" }, { status: 401 });
+  if (!user) return NextResponse.json({ error: "unauthorized" }, { status: 401 });

   const collections = await prisma.collection.findMany({
     where: { userId: user.id },
     orderBy: { createdAt: "asc" },
   });

-  return Response.json(collections);
+  return NextResponse.json(collections);
 }

Apply similar changes to POST handler.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/app/api/collections/route.ts` around lines 6 - 16, Replace usages
of the global Response.json with NextResponse.json for consistency and to enable
Next.js App Router features: update the exported GET (and the POST handler if
present) in route.ts to return NextResponse.json(...) instead of
Response.json(...), ensure you import NextResponse from "next/server", and keep
the same payload and options (e.g., { status: 401 }) when converting the return
values.

18-43: Consider adding rate limiting and name length validation.

Unlike the bookmark and tag routes, collection creation has no rate limiting. A malicious user could create many collections rapidly.

Additionally, there's no maximum length validation on name, allowing arbitrarily long strings to be stored.

🛡️ Suggested improvements
 import { prisma } from "@/lib/prisma";
 import { getUserFromRequest } from "@/lib/supabase/get-user";
 import { NextRequest, NextResponse } from "next/server";
+import { collectionRatelimit } from "@/lib/ratelimit"; // add this limiter

 // POST /api/collections — create a new collection
 export async function POST(req: NextRequest) {
   const user = await getUserFromRequest(req);
   if (!user) return NextResponse.json({ error: "unauthorized" }, { status: 401 });

+  const { success } = await collectionRatelimit.limit(user.id);
+  if (!success)
+    return NextResponse.json(
+      { error: "too many requests - slow down" },
+      { status: 429 },
+    );

   // ... existing json parsing ...

   const name = typeof body.name === "string" ? body.name.trim() : "";
-  if (!name) return NextResponse.json({ error: "name is required" }, { status: 400 });
+  if (!name) return NextResponse.json({ error: "name is required" }, { status: 400 });
+  if (name.length > 100) return NextResponse.json({ error: "name too long" }, { status: 400 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/app/api/collections/route.ts` around lines 18 - 43, Add rate
limiting and a maximum name length check to the POST handler: in the POST
function, call the same per-user rate limiter used by the bookmark/tag routes
(e.g., rateLimit or limiter middleware used elsewhere) right after
getUserFromRequest to early-return 429 when the user is over quota, and validate
the name variable before creating the record by enforcing a max length (e.g.,
MAX_NAME_LENGTH = 100) after trimming (reject with Response.json({ error: "name
too long" }, { status: 400 }) if exceeded) and keep the existing empty-name
check; ensure the prisma.collection.create call still uses the validated name
and user.id.
client/src/app/app/page.tsx (1)

13-24: Consider adding a pagination limit for consistency with the API route.

The API route at /api/bookmarks has a take: 500 safety cap, but this server-side query fetches all bookmarks without limit. For users with a large number of bookmarks, this could cause performance degradation.

If this is intentional (server-side can handle more), consider documenting the rationale. Otherwise, apply a consistent limit.

🔧 Suggested change to add safety cap
     prisma.bookmark.findMany({
       where: { userId },
       orderBy: { createdAt: "desc" },
+      take: 500, // safety cap — matches API route
       include: { tags: true, collections: { select: { collectionId: true } } },
     }),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/app/app/page.tsx` around lines 13 - 24, The server-side query for
bookmarks (prisma.bookmark.findMany used to populate the bookmarks variable)
fetches all rows while the API route enforces take: 500; add a consistent safety
cap (e.g., include take: 500 or a configurable MAX_BOOKMARKS constant) to the
prisma.bookmark.findMany call and keep the same ordering and includes; if the
higher/no-limit is intentional, add a brief inline comment documenting the
rationale instead of leaving it unbounded.
client/src/app/page.tsx (1)

75-90: Move these landing-page style tweaks into Tailwind utilities.

These changes keep expanding the inline-style surface in a client/**/* file instead of using Tailwind classes, which makes this page harder to keep consistent with the rest of the app.

As per coding guidelines, client/**/*.{tsx,jsx,ts,js,css}: Use Tailwind CSS v4 for styling.

Also applies to: 281-285, 317-319, 364-392

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/app/page.tsx` around lines 75 - 90, The inline style block on the
landing headline (the <h1> with three <span> lines) and the paragraph below (the
<p> using "fadeUp" animation) should be replaced with Tailwind v4 utility
classes: remove the style props on the <h1>, its child <span>s, and the <p>, and
map fontSize/clamp, fontWeight, lineHeight, margins, colors, display, and the
text-shadow/animation into Tailwind classes or custom utilities; add any missing
utilities (e.g., a custom clamp font-size, text-shadow, and the fadeUp
keyframes/animation) in tailwind.config.js or your globals CSS and then use
those class names in the JSX (refer to the <h1> block, the three inner <span>
elements, and the animated <p> to locate all occurrences). Ensure the same
adjustments are applied to the other inline-style occurrences noted (around
lines 281-285, 317-319, 364-392).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/src/app/admin/page.tsx`:
- Around line 9-10: The file sets export const revalidate = 300 but
createClient() reads cookies() (forcing the route to be dynamic), so the
revalidate is ignored; remove the revalidate export and instead wrap the
expensive data fetch with Next.js caching (use unstable_cache) so the auth flow
remains dynamic while supabaseAdmin.auth.admin.listUsers() and related queries
are cached; locate the revalidate export to delete it, find
createClient()/cookies() usage to keep dynamic behavior, and create an
unstable_cache-wrapped helper that calls supabaseAdmin.auth.admin.listUsers()
(and any related data operations) which the page code should call.

In `@client/src/app/api/collections/`[id]/bookmarks/[bookmarkId]/route.ts:
- Around line 27-30: The current catch block treats Prisma's P2025 as a 404
which causes the frontend optimistic rollback to restore a deleted membership;
instead map P2025 to a success (e.g., 204 No Content or 200 OK) so
repeated/de-duped DELETEs are idempotent. In the catch (e: unknown) handling
where you check (e as { code?: string })?.code === "P2025", change the response
to a successful status (204 or 200) rather than Response.json({ error: "not
found" }, { status: 404 }); so DashboardClient.tsx’s optimistic snapshot logic
won't undo a deletion on a retried request.

In `@client/src/app/api/collections/`[id]/route.ts:
- Around line 20-24: The delete handler's catch block treats a missing row as a
server error; update the error handling around prisma.collection.delete so that
if the deletion throws a Prisma known-request error for "Record to delete does
not exist" (PrismaClientKnownRequestError with code 'P2025') you return a
successful response (e.g., Response.json({}, { status: 200 }) or 204) instead of
500, and only return the 500 error for other exceptions; locate the
prisma.collection.delete call and replace the bare catch with logic that
inspects the caught error (error.code === 'P2025' or instanceof
Prisma.PrismaClientKnownRequestError) to treat already-missing collections as
success.

In `@client/src/components/BookmarkCard.tsx`:
- Around line 251-257: The delete button currently only becomes visible on hover
(uses group-hover:opacity-100) which hides it from keyboard users; update the
button element(s) that call deleteBookmark (and the other similar button around
the 313-319 snippet) to also include focus-visible:opacity-100 and add an
accessible focus ring class (e.g., focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-red-400 or
your design-system equivalents) in their className so the control becomes
visible and keyboard-focusable; ensure you apply these classes to the same JSX
button elements that have onClick={deleteBookmark} and the matching counterpart
so tabbing shows a visible focus state.
- Around line 97-119: The failure path currently calls setLocalTags(snapshot)
which restores the entire pre-delete list and can re-add tags deleted
successfully by other in-flight operations; change it to reinsert only the
failed tag: capture the removed tag from snapshot (const failedTag =
snapshot.find(t => t.id === tagId)) and on error use setLocalTags(prev =>
prev.some(t => t.id === tagId) ? prev : (failedTag ? [...prev, failedTag] :
prev)); keep the existing setRemovingIds cleanup in finally and reference
functions/variables removeTag, setLocalTags, localTags, removingIds, and
snapshot when making the change.

In `@client/src/components/DashboardClient.tsx`:
- Around line 120-130: Wrap the fetch calls in DashboardClient.tsx in try/catch
blocks so thrown network or runtime errors are caught; in the catch
setCollectionError with an appropriate message and revert any optimistic state
changes (e.g., undo changes made via setCollections, membership updates
functions used around the create/delete/membership mutation sites). Specifically
update the create-collection block that calls fetch and uses setCollections and
setCollectionError, and mirror this pattern for the other mutation sites (the
blocks around lines referenced for delete and membership changes) so both res.ok
=== false and thrown exceptions consistently update collectionError and restore
optimistic state.
- Around line 211-228: The early return in the DashboardClient when
localBookmarks.length === 0 prevents the collections list and create UI from
rendering; instead of returning the entire component, remove this top-level
return and render the empty-bookmarks panel only in the bookmarks area (e.g.,
conditionally render the "Nothing saved yet" block where bookmarks are shown),
leaving the collections list and collection-creation UI (e.g., renderCollections
/ CreateCollectionButton or equivalent) outside that conditional so they remain
visible even when localBookmarks is empty; update DashboardClient to use
conditional JSX for the bookmarks region rather than an early return.

In `@client/src/lib/scraper.ts`:
- Around line 13-24: isSafeThumbnailUrl uses a narrow regex and misses IPv6,
0.0.0.0, 169.254.x.x, and non-dotted IP forms; replace its logic by reusing the
more comprehensive isPrivateIp validation used in route.ts. Import or extract
the existing isPrivateIp utility and call it from isSafeThumbnailUrl (preserve
the HTTPS check and URL parsing), returning false when
isPrivateIp(parsed.hostname) is true or the URL parse fails; update tests
accordingly to cover IPv4, IPv6, link-local, metadata IPs and unusual encodings.

---

Nitpick comments:
In `@client/src/app/api/collections/route.ts`:
- Around line 6-16: Replace usages of the global Response.json with
NextResponse.json for consistency and to enable Next.js App Router features:
update the exported GET (and the POST handler if present) in route.ts to return
NextResponse.json(...) instead of Response.json(...), ensure you import
NextResponse from "next/server", and keep the same payload and options (e.g., {
status: 401 }) when converting the return values.
- Around line 18-43: Add rate limiting and a maximum name length check to the
POST handler: in the POST function, call the same per-user rate limiter used by
the bookmark/tag routes (e.g., rateLimit or limiter middleware used elsewhere)
right after getUserFromRequest to early-return 429 when the user is over quota,
and validate the name variable before creating the record by enforcing a max
length (e.g., MAX_NAME_LENGTH = 100) after trimming (reject with Response.json({
error: "name too long" }, { status: 400 }) if exceeded) and keep the existing
empty-name check; ensure the prisma.collection.create call still uses the
validated name and user.id.

In `@client/src/app/app/page.tsx`:
- Around line 13-24: The server-side query for bookmarks
(prisma.bookmark.findMany used to populate the bookmarks variable) fetches all
rows while the API route enforces take: 500; add a consistent safety cap (e.g.,
include take: 500 or a configurable MAX_BOOKMARKS constant) to the
prisma.bookmark.findMany call and keep the same ordering and includes; if the
higher/no-limit is intentional, add a brief inline comment documenting the
rationale instead of leaving it unbounded.

In `@client/src/app/page.tsx`:
- Around line 75-90: The inline style block on the landing headline (the <h1>
with three <span> lines) and the paragraph below (the <p> using "fadeUp"
animation) should be replaced with Tailwind v4 utility classes: remove the style
props on the <h1>, its child <span>s, and the <p>, and map fontSize/clamp,
fontWeight, lineHeight, margins, colors, display, and the text-shadow/animation
into Tailwind classes or custom utilities; add any missing utilities (e.g., a
custom clamp font-size, text-shadow, and the fadeUp keyframes/animation) in
tailwind.config.js or your globals CSS and then use those class names in the JSX
(refer to the <h1> block, the three inner <span> elements, and the animated <p>
to locate all occurrences). Ensure the same adjustments are applied to the other
inline-style occurrences noted (around lines 281-285, 317-319, 364-392).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e4865fe3-d335-4dfc-8e52-dcb998e54558

📥 Commits

Reviewing files that changed from the base of the PR and between 8fb5224 and ca47158.

📒 Files selected for processing (17)
  • client/src/app/admin/page.tsx
  • client/src/app/api/bookmarks/[id]/tags/[tagId]/route.ts
  • client/src/app/api/bookmarks/[id]/tags/route.ts
  • client/src/app/api/bookmarks/route.ts
  • client/src/app/api/collections/[id]/bookmarks/[bookmarkId]/route.ts
  • client/src/app/api/collections/[id]/bookmarks/route.ts
  • client/src/app/api/collections/[id]/route.ts
  • client/src/app/api/collections/route.ts
  • client/src/app/app/page.tsx
  • client/src/app/auth/page.tsx
  • client/src/app/layout.tsx
  • client/src/app/page.tsx
  • client/src/components/BookmarkCard.tsx
  • client/src/components/DashboardClient.tsx
  • client/src/lib/gemini.ts
  • client/src/lib/scraper.ts
  • client/supabase-rls.sql

Comment thread client/src/app/admin/page.tsx Outdated
Comment thread client/src/app/api/collections/[id]/bookmarks/[bookmarkId]/route.ts
Comment thread client/src/app/api/collections/[id]/route.ts
Comment thread client/src/components/BookmarkCard.tsx
Comment thread client/src/components/BookmarkCard.tsx
Comment thread client/src/components/DashboardClient.tsx Outdated
Comment thread client/src/components/DashboardClient.tsx Outdated
Comment thread client/src/lib/scraper.ts Outdated
- admin: replace ineffective revalidate=300 with unstable_cache on listUsers
- collections DELETE: return 204 (not 404/500) for P2025 — idempotent deletes
- BookmarkCard: removeTag restores only the failed tag, not full snapshot
- BookmarkCard: delete buttons visible on focus-visible for keyboard users
- DashboardClient: wrap all collection fetch calls in try/catch
- DashboardClient: remove early return so collections stay visible after last bookmark deleted
- scraper: extract isPrivateIp to shared url-validation utility, covers IPv6/169.254/0.0.0.0 gaps
- bookmarks/route: import isPrivateIp from shared utility
- collections POST: enforce max name length of 100 chars
- page.tsx: add take:500 cap to server-side bookmark query

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

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

⚠️ Outside diff range comments (1)
client/src/app/api/bookmarks/route.ts (1)

45-49: ⚠️ Potential issue | 🔴 Critical

Note: IPv4-mapped IPv6 addresses also bypass this check.

The blockedHosts Set provides good defense-in-depth, but ::ffff:127.0.0.1 bypasses both blockedHosts.has() (not in set) and isPrivateIp() (the bug flagged in url-validation.ts). Once isPrivateIp is fixed, this combined check will be robust.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/app/api/bookmarks/route.ts` around lines 45 - 49, The host
validation in route.ts currently misses IPv4-mapped IPv6 addresses (e.g.,
::ffff:127.0.0.1) because blockedHosts.has(host) and isPrivateIp(host) are
checked against the raw hostname; update the check to first normalize/detect
IPv4-mapped IPv6 addresses (strip the ::ffff: prefix or convert to the mapped
IPv4 address) before applying blockedHosts.has() and isPrivateIp(); reference
the variables/functions blockedHosts, host, parsedUrl, and isPrivateIp and
ensure the normalizedHost is used for both the Set lookup and the private-IP
check so mapped IPv4 addresses are correctly blocked.
🧹 Nitpick comments (2)
client/src/app/admin/page.tsx (1)

13-16: Avoid silent failure in cached user fetch.

Line 15 swallows Supabase errors by returning [] without any signal, which makes admin-data outages hard to detect. Add lightweight server logging before fallback.

Suggested patch
 const getCachedAuthUsers = unstable_cache(
   async () => {
     const { data, error } = await supabaseAdmin.auth.admin.listUsers({ perPage: 1000 });
-    return error ? [] : data.users;
+    if (error) {
+      console.error("AdminPage listUsers failed", { message: error.message });
+      return [];
+    }
+    return data.users;
   },
   ["admin-auth-users"],
   { revalidate: 300 },
 );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/app/admin/page.tsx` around lines 13 - 16, The cached user fetch
currently swallows Supabase errors by returning [] in the expression "return
error ? [] : data.users" after calling supabaseAdmin.auth.admin.listUsers;
change this to log the error to the server logger (e.g., console.error or the
app's logger) before returning the fallback so outages are visible. Locate the
anonymous async function that calls supabaseAdmin.auth.admin.listUsers and add a
lightweight server-side log statement that includes the error object (and a
short context message) when error is truthy, then still return the empty array
as the safe fallback.
client/src/components/DashboardClient.tsx (1)

563-566: Replace these inline styles with Tailwind utilities/classes.

style={{ padding: "48px 32px" }} and the per-card animationDelay styles bypass the repo's Tailwind-only styling rule. The padding can move directly into utilities, and the bounded delays can come from a small class lookup instead of inline styles. As per coding guidelines, client/**/*.{tsx,jsx,ts,js,css}: Use Tailwind CSS v4 for styling.

Also applies to: 628-631, 652-655

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/components/DashboardClient.tsx` around lines 563 - 566, The
container div in DashboardClient.tsx uses an inline style for padding (style={{
padding: "48px 32px" }}) and several card elements use inline animationDelay
styles; replace those inline styles with Tailwind utility classes: convert
padding to py-12 px-8 on the container and for per-card delays create a small
mapping from index/delay value to Tailwind delay classes (e.g.,
"delay-75","delay-100","delay-150", etc.) and apply via className instead of
style; update the elements that set animationDelay (the per-card render logic
referenced around lines 628–631 and 652–655) to use the mapped classnames so all
styling uses Tailwind v4 utilities.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/src/components/DashboardClient.tsx`:
- Around line 633-638: The BookmarkCard is still receiving the original
bookmark.tags prop so its localTags (initialized in BookmarkCard from props) can
revert on remount; update both render branches where BookmarkCard is
instantiated (the lines passing bookmark with collectionIds override) to also
override tags using the tagOverrides map (e.g., pass tags:
tagOverrides.get(bookmark.id) ?? bookmark.tags) so BookmarkCard always gets the
current overridden tags; ensure the same change is applied at the other
occurrence noted (lines 658-662).
- Around line 289-303: The input for creating collections limits names to 50
chars while the API accepts up to 100, so update the UI to match the API: change
the input's maxLength from 50 to 100 and ensure any client-side validation
around newCollectionName and inside handleCreateCollection uses the same
100-character limit so users can create names up to the API's 100-char contract.
- Around line 137-150: handleDeleteCollection currently saves the entire
collections array and restores it on failure, which clobbers intervening user
changes; instead, capture the single deleted item and its original index (e.g.,
save deletedCollection = collections.find(c => c.id === id) and deletedIndex),
optimistically remove by filtering, then on failure call setCollections(prev =>
{ if (prev.length === snapshot.length - 1 && !prev.some(c => c.id === id))
return [...prev.slice(0, deletedIndex), deletedCollection,
...prev.slice(deletedIndex)]; return prev; }) so you only reinsert the
failed-deletion item when the collection list hasn't changed otherwise;
likewise, save the previous activeCollection value and only restore
activeCollection to that id if current activeCollection is still null (meaning
the user didn't change selection since the delete was initiated).
- Around line 196-219: The rollback currently restores the entire snapshot for
bookmarkId which can undo concurrent successful removals; change the revert
logic in removeFromCollection to only reinsert the failed collectionId into the
current memberships for that bookmark. Keep the existing snapshot variable for
context, but replace the revert implementation so it calls setMemberships(prev
=> { const next = new Map(prev); const list = new Set(next.get(bookmarkId) ??
[]); list.add(collectionId); next.set(bookmarkId, Array.from(list)); return
next; }) — i.e., merge the failed collectionId into the current list (guarding
against duplicates) instead of resetting to snapshot. Ensure this uses the same
removeFromCollection function, bookmarkId, and collectionId identifiers.

In `@client/src/lib/url-validation.ts`:
- Around line 15-23: The IPv6 branch (where isIP(host) === 6) currently misses
IPv4-mapped IPv6 addresses like "::ffff:127.0.0.1"; update the IPv6 handling in
url-validation.ts to detect when lower.startsWith("::ffff:") (or the "::FFFF:"
variant), extract the trailing IPv4 portion (the substring after "::ffff:"), and
then run the existing IPv4 private/loopback checks on that extracted IPv4 string
(i.e., reuse the same logic used for isIP(host) === 4) instead of only checking
"::1", "fe80:", "fc", "fd" patterns; keep the existing IPv6 checks for native
IPv6 addresses and ensure the mapped address path treats mapped IPv4 as IPv4 for
SSRF protection.

---

Outside diff comments:
In `@client/src/app/api/bookmarks/route.ts`:
- Around line 45-49: The host validation in route.ts currently misses
IPv4-mapped IPv6 addresses (e.g., ::ffff:127.0.0.1) because
blockedHosts.has(host) and isPrivateIp(host) are checked against the raw
hostname; update the check to first normalize/detect IPv4-mapped IPv6 addresses
(strip the ::ffff: prefix or convert to the mapped IPv4 address) before applying
blockedHosts.has() and isPrivateIp(); reference the variables/functions
blockedHosts, host, parsedUrl, and isPrivateIp and ensure the normalizedHost is
used for both the Set lookup and the private-IP check so mapped IPv4 addresses
are correctly blocked.

---

Nitpick comments:
In `@client/src/app/admin/page.tsx`:
- Around line 13-16: The cached user fetch currently swallows Supabase errors by
returning [] in the expression "return error ? [] : data.users" after calling
supabaseAdmin.auth.admin.listUsers; change this to log the error to the server
logger (e.g., console.error or the app's logger) before returning the fallback
so outages are visible. Locate the anonymous async function that calls
supabaseAdmin.auth.admin.listUsers and add a lightweight server-side log
statement that includes the error object (and a short context message) when
error is truthy, then still return the empty array as the safe fallback.

In `@client/src/components/DashboardClient.tsx`:
- Around line 563-566: The container div in DashboardClient.tsx uses an inline
style for padding (style={{ padding: "48px 32px" }}) and several card elements
use inline animationDelay styles; replace those inline styles with Tailwind
utility classes: convert padding to py-12 px-8 on the container and for per-card
delays create a small mapping from index/delay value to Tailwind delay classes
(e.g., "delay-75","delay-100","delay-150", etc.) and apply via className instead
of style; update the elements that set animationDelay (the per-card render logic
referenced around lines 628–631 and 652–655) to use the mapped classnames so all
styling uses Tailwind v4 utilities.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3a9d8a15-5f40-4f9b-bb43-6dcd874910b3

📥 Commits

Reviewing files that changed from the base of the PR and between ca47158 and cbb1892.

📒 Files selected for processing (10)
  • client/src/app/admin/page.tsx
  • client/src/app/api/bookmarks/route.ts
  • client/src/app/api/collections/[id]/bookmarks/[bookmarkId]/route.ts
  • client/src/app/api/collections/[id]/route.ts
  • client/src/app/api/collections/route.ts
  • client/src/app/app/page.tsx
  • client/src/components/BookmarkCard.tsx
  • client/src/components/DashboardClient.tsx
  • client/src/lib/scraper.ts
  • client/src/lib/url-validation.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • client/src/app/app/page.tsx
  • client/src/lib/scraper.ts
  • client/src/app/api/collections/[id]/route.ts
  • client/src/app/api/collections/[id]/bookmarks/[bookmarkId]/route.ts

Comment thread client/src/components/DashboardClient.tsx
Comment thread client/src/components/DashboardClient.tsx
Comment thread client/src/components/DashboardClient.tsx
Comment thread client/src/components/DashboardClient.tsx
Comment thread client/src/lib/url-validation.ts
Mobile performance (FCP/LCP on /app):
- Remove unoptimized from bookmark images — Next.js now serves WebP/AVIF
- Add sizes prop to both image variants (list: 64px, grid: responsive)
- Add priority prop support, pass priority={i === 0} from DashboardClient
- Remove http:// wildcard from remotePatterns (security + optimization)
- Drop JetBrains Mono from global layout (only used on landing page mock)
- Use system monospace on landing URL bar, remove --font-mono dependency

CodeRabbit findings:
- url-validation: handle IPv4-mapped IPv6 (::ffff:127.0.0.1) SSRF bypass
- admin: log error before returning empty fallback from getCachedAuthUsers
- DashboardClient: collection delete reverts only deleted item, not full snapshot
- DashboardClient: removeFromCollection reverts only failed collectionId (concurrent-safe)
- DashboardClient: maxLength 50→100 to match API contract
- DashboardClient: pass tagOverrides to BookmarkCard to prevent stale tag props on remount
- collections/route: switch Response.json → NextResponse.json for consistency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
client/src/components/DashboardClient.tsx (1)

137-157: ⚠️ Potential issue | 🟠 Major

Restore the active collection filter when delete rollback runs.

If the user deletes the currently selected collection and the request fails, the pill is reinserted but activeCollection stays cleared. The banner/result set then reflect a successful unfilter even though the delete did not stick.

Targeted rollback fix
 async function handleDeleteCollection(id: string) {
   const deletedIndex = collections.findIndex((c) => c.id === id);
   const deletedItem = collections[deletedIndex];
   if (!deletedItem) return;
+  const wasActive = activeCollection === id;
   setCollections((prev) => prev.filter((c) => c.id !== id));
-  if (activeCollection === id) setActiveCollection(null);
-  const revert = () => setCollections((prev) => {
-    if (prev.some((c) => c.id === id)) return prev;
-    const next = [...prev];
-    next.splice(Math.min(deletedIndex, next.length), 0, deletedItem);
-    return next;
-  });
+  if (wasActive) setActiveCollection(null);
+  const revert = () => {
+    setCollections((prev) => {
+      if (prev.some((c) => c.id === id)) return prev;
+      const next = [...prev];
+      next.splice(Math.min(deletedIndex, next.length), 0, deletedItem);
+      return next;
+    });
+    if (wasActive) {
+      setActiveCollection((prev) => (prev === null ? id : prev));
+    }
+  };
   try {
     const res = await fetch(`/api/collections/${id}`, { method: "DELETE" });
     if (!res.ok) {
       revert();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/components/DashboardClient.tsx` around lines 137 - 157,
handleDeleteCollection does an optimistic removal but doesn't restore the
activeCollection when the delete is rolled back; capture whether the deleted
item was active (e.g. const wasActive = activeCollection === id) before calling
setCollections and setActiveCollection(null), then update the revert closure to
not only reinsert the deletedItem via setCollections but also, if wasActive is
true, call setActiveCollection(id) to restore the filter when the rollback runs.
🧹 Nitpick comments (2)
client/src/components/DashboardClient.tsx (2)

115-133: Keep the draft name until create succeeds.

This closes the inline form and clears newCollectionName before the POST resolves. On a network or 4xx/5xx failure the user gets the error, but has to reopen the form and retype the collection name.

Small UX tweak
 async function handleCreateCollection() {
   const name = newCollectionName.trim();
   if (!name) return;
-  setCreatingCollection(false);
-  setNewCollectionName("");
   setCollectionError(null);
   try {
     const res = await fetch("/api/collections", {
       method: "POST",
       headers: { "Content-Type": "application/json" },
       body: JSON.stringify({ name }),
     });
     if (res.ok) {
       const created: CollectionItem = await res.json();
       setCollections((prev) => [...prev, created]);
+      setCreatingCollection(false);
+      setNewCollectionName("");
     } else {
       setCollectionError("Failed to create collection");
     }
   } catch {
     setCollectionError("Failed to create collection");
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/components/DashboardClient.tsx` around lines 115 - 133, The code
clears the draft (calls setCreatingCollection(false) and
setNewCollectionName("")) before the POST completes, losing the user's input on
failure; change the flow so you keep newCollectionName (and keep the form open)
until the request succeeds: remove the early calls to
setCreatingCollection(false) and setNewCollectionName("") before the fetch, and
instead call setCreatingCollection(false) and setNewCollectionName("") inside
the success branch where res.ok is true (after setCollections), while leaving
setCollectionError(...) and keeping the draft intact in the else and catch
branches; update references to newCollectionName, name, setCreatingCollection,
setNewCollectionName, setCollectionError and the POST handling accordingly.

572-575: Replace the static padding style with Tailwind classes.

These values are fixed, so px-8 py-12 would keep the empty-state panel inside the repo's Tailwind-only styling convention.

As per coding guidelines, client/**/*.{tsx,jsx,ts,js,css}: Use Tailwind CSS v4 for styling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/components/DashboardClient.tsx` around lines 572 - 575, The
empty-state container div in DashboardClient.tsx currently uses an inline style
for padding (style={{ padding: "48px 32px" }}); replace that inline style with
the equivalent Tailwind padding classes by removing the style prop and adding
px-8 py-12 to the div's className (the div with className starting "flex
flex-col items-center...") so the component uses Tailwind-only styling as
required.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/src/app/api/collections/route.ts`:
- Around line 30-38: Check for an existing collection with the same name for the
current user before creating and enforce user-scoped uniqueness in the database
schema: in route.ts, query prisma.collection.findFirst or findUnique with where:
{ userId: user.id, name } and return a 409/400 JSON error if found, and also add
a unique constraint to the Collection model (@@unique([userId, name]) or
equivalent) in the Prisma schema and run a migration; finally, handle Prisma
unique-constraint errors around prisma.collection.create (catch the specific
error code and return the same 409/400) so creating duplicates is prevented both
at the app and DB level.

---

Duplicate comments:
In `@client/src/components/DashboardClient.tsx`:
- Around line 137-157: handleDeleteCollection does an optimistic removal but
doesn't restore the activeCollection when the delete is rolled back; capture
whether the deleted item was active (e.g. const wasActive = activeCollection ===
id) before calling setCollections and setActiveCollection(null), then update the
revert closure to not only reinsert the deletedItem via setCollections but also,
if wasActive is true, call setActiveCollection(id) to restore the filter when
the rollback runs.

---

Nitpick comments:
In `@client/src/components/DashboardClient.tsx`:
- Around line 115-133: The code clears the draft (calls
setCreatingCollection(false) and setNewCollectionName("")) before the POST
completes, losing the user's input on failure; change the flow so you keep
newCollectionName (and keep the form open) until the request succeeds: remove
the early calls to setCreatingCollection(false) and setNewCollectionName("")
before the fetch, and instead call setCreatingCollection(false) and
setNewCollectionName("") inside the success branch where res.ok is true (after
setCollections), while leaving setCollectionError(...) and keeping the draft
intact in the else and catch branches; update references to newCollectionName,
name, setCreatingCollection, setNewCollectionName, setCollectionError and the
POST handling accordingly.
- Around line 572-575: The empty-state container div in DashboardClient.tsx
currently uses an inline style for padding (style={{ padding: "48px 32px" }});
replace that inline style with the equivalent Tailwind padding classes by
removing the style prop and adding px-8 py-12 to the div's className (the div
with className starting "flex flex-col items-center...") so the component uses
Tailwind-only styling as required.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 510f3f9e-3aa9-4d45-989e-dc15b8116047

📥 Commits

Reviewing files that changed from the base of the PR and between cbb1892 and 44d5235.

📒 Files selected for processing (9)
  • client/next.config.ts
  • client/src/app/admin/page.tsx
  • client/src/app/api/collections/route.ts
  • client/src/app/globals.css
  • client/src/app/layout.tsx
  • client/src/app/page.tsx
  • client/src/components/BookmarkCard.tsx
  • client/src/components/DashboardClient.tsx
  • client/src/lib/url-validation.ts
💤 Files with no reviewable changes (1)
  • client/next.config.ts
✅ Files skipped from review due to trivial changes (2)
  • client/src/app/globals.css
  • client/src/lib/url-validation.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • client/src/app/admin/page.tsx
  • client/src/app/page.tsx

Comment on lines +30 to +38
const name = typeof body.name === "string" ? body.name.trim() : "";
if (!name) return NextResponse.json({ error: "name is required" }, { status: 400 });
if (name.length > 100) return NextResponse.json({ error: "name too long" }, { status: 400 });

let collection;
try {
collection = await prisma.collection.create({
data: { name, userId: user.id },
});

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

Reject duplicate collection names per user.

This still allows the same account to create two collections with the same visible name. DashboardClient renders only col.name, so duplicate pills and delete confirmations become ambiguous very quickly. Please block duplicates here and back that up with user-scoped uniqueness instead of inserting another row.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/app/api/collections/route.ts` around lines 30 - 38, Check for an
existing collection with the same name for the current user before creating and
enforce user-scoped uniqueness in the database schema: in route.ts, query
prisma.collection.findFirst or findUnique with where: { userId: user.id, name }
and return a 409/400 JSON error if found, and also add a unique constraint to
the Collection model (@@unique([userId, name]) or equivalent) in the Prisma
schema and run a migration; finally, handle Prisma unique-constraint errors
around prisma.collection.create (catch the specific error code and return the
same 409/400) so creating duplicates is prevented both at the app and DB level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@MinitJain MinitJain merged commit ffe15de into main Mar 30, 2026
4 checks passed
@MinitJain MinitJain deleted the p2-collections branch March 31, 2026 18:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant