Skip to content

Add search, multiselect and more#12

Open
csark0812 wants to merge 5 commits intoR44VC0RP:mainfrom
csark0812:main
Open

Add search, multiselect and more#12
csark0812 wants to merge 5 commits intoR44VC0RP:mainfrom
csark0812:main

Conversation

@csark0812
Copy link
Copy Markdown

@csark0812 csark0812 commented Sep 2, 2025

🚀 Enhanced Feed & Dashboard Functionality with Project Rebranding

📋 Summary

This PR introduces significant improvements to the cursor.link platform, including enhanced feed functionality, improved dashboard capabilities, and a complete project rebranding. The changes focus on improving user experience through better search capabilities, rule management features, and updated documentation.

✨ Key Features Added

🔍 Enhanced Feed Functionality

  • Search Capabilities: Added comprehensive search functionality to the feed page
    • Filter rules by title, content, or author
    • Real-time search with instant results
    • Improved user experience for discovering rules

🎯 Improved Dashboard Experience

  • Rule Selection: Implemented multi-select functionality for rules
    • Select multiple rules for batch operations
    • Enhanced user interface for rule management
    • Better organization and workflow capabilities

🎯 Navigation Improvements

  • Dashboard Access: Added dashboard link to the top navigation
    • Improved accessibility to user dashboard
    • Streamlined navigation experience
    • Better user flow between sections

📚 Project Rebranding & Documentation

  • Project Rename: Changed from "my-v0-project" to "cursor-link"
  • Updated README: Comprehensive documentation updates including:
    • New features documentation (Hot/New feeds, CLI tool capabilities)
    • Enhanced tech stack section with updated versions
    • Detailed sections for feed discovery and lists management
    • CLI tool usage instructions

🔧 Development Environment

  • Environment Setup: Added .env.example file for easier development setup
  • Git Configuration: Updated .gitignore for better development workflow

📁 Files Changed

File Changes Description
app/api/feed/hot/route.ts +24 lines Added search functionality to hot feed API
app/api/feed/new/route.ts +24 lines Added search functionality to new feed API
app/dashboard/page.tsx +310 lines Enhanced dashboard with selection and management features
app/feed/page.tsx +81 lines Improved feed page with search capabilities
components/header.tsx +8 lines Added dashboard navigation link
components/auth/user-avatar.tsx -7 lines Cleaned up navigation logic
components/dashboard/user-lists.tsx +6 lines Enhanced list management
README.md +178 lines Comprehensive documentation update
package.json +2 lines Project rebranding
.env.example +13 lines Development environment setup
.gitignore +1 line Improved development workflow

🎯 Impact

  • User Experience: Significantly improved search and discovery capabilities
  • Developer Experience: Better documentation and environment setup
  • Project Identity: Clear rebranding to "cursor-link" with updated documentation
  • Functionality: Enhanced rule management and batch operations

📝 Commit History

  1. d3fc3bf - update env - Added environment configuration
  2. 769a618 - add dashboard to top buttons - Navigation improvements
  3. d7f3d87 - Enhance feed and dashboard functionality with search and selection features - Core functionality enhancements
  4. 2d0acfc - Rename project to cursor-link and update README for new features and dependencies - Project rebranding and documentation

Summary by CodeRabbit

  • New Features

    • Feed search across titles, content, and authors with a debounced search bar and updated empty states.
    • Dashboard multi-select: Select All/Clear, per-item toggles, add selected to lists via dialog, and bulk delete with confirmation.
    • Header adds a Dashboard shortcut; Dashboard entry removed from user menu.
  • Improvements

    • Create List dialog now consistently controlled.
  • Documentation

    • README expanded (feeds, lists, CLI, public APIs, setup, tech stack) and added production env examples.
  • Chores

    • Track .env.example in repo; package renamed to “cursor-link.”

…atures

- Added search functionality to the feed page, allowing users to filter rules by title, content, or author.
- Implemented optional search filters in the hot and new API routes for fetching rules.
- Introduced rule selection capabilities in the dashboard, enabling users to select multiple rules for actions like adding to lists or deleting.
- Enhanced user experience with a dialog for adding selected rules to lists and improved feedback mechanisms for actions taken on rules.
…dependencies

- Changed project name in package.json from "my-v0-project" to "cursor-link".
- Updated README to reflect new features including Hot/New feeds, CLI tool capabilities, and lists management.
- Enhanced tech stack section with updated versions for Next.js, TypeScript, Tailwind CSS, and other dependencies.
- Added detailed sections for feed and discovery, lists management, and CLI tool usage.
@vercel
Copy link
Copy Markdown

vercel bot commented Sep 2, 2025

@csark0812 is attempting to deploy a commit to the exon Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Sep 2, 2025

Walkthrough

Adds a tracked production env template and updates README. Implements search for Hot/New feeds (API and client). Enhances dashboard with multi-select, bulk add-to-list and bulk delete flows, and controlled create-list dialog. Adds Dashboard nav button, removes it from user menu, and renames package to "cursor-link."

Changes

Cohort / File(s) Summary
Environment templates & tracking
/.env.example, /.gitignore
Adds production placeholders to .env.example (DATABASE_URL, INBOUND_API_KEY, BETTER_AUTH_* , GITHUB_*), and negates .gitignore to ensure .env.example is tracked.
Documentation & metadata
/README.md, /package.json
Expands README with features, CLI, API, tech pins, and production env examples; renames package name from "my-v0-project" to "cursor-link".
Feed API: Hot & New
/app/api/feed/hot/route.ts, /app/api/feed/new/route.ts
Adds optional q param search; uses ilike + or across title/content/user.name, expands selected fields, orders and limits results (≤50), retains isPublic constraint and error handling.
Client feed page (search)
/app/feed/page.tsx
Adds debounced search input (300ms), includes q when fetching Hot/New, updates empty-state messaging and UI behavior during searches.
Dashboard: multi-select, lists, bulk ops
/app/dashboard/page.tsx
Adds multi-select state and UI, Select All/Clear controls, bulk Add-to-List (opens list dialog, posts per-rule), bulk Delete (confirmation dialog, deletes per-rule), per-result toasts, event tracking, and local state updates.
Lists UI: controlled dialog
/components/dashboard/user-lists.tsx
Switches Create List dialog to controlled isCreateDialogOpen state and closes dialog on successful creation; updates header and empty-state buttons.
Header & user menu
/components/header.tsx, /components/auth/user-avatar.tsx
Adds a Dashboard button to the header; removes the Dashboard link from the user-avatar menu.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor U as User
  participant FP as Feed Page (Client)
  participant HF as /api/feed/hot
  participant NF as /api/feed/new
  participant DB as DB (cursorRule, user)

  U->>FP: Type in search box
  FP->>FP: Debounce 300ms
  alt q present
    FP->>HF: GET /api/feed/hot?q={q}
    FP->>NF: GET /api/feed/new?q={q}
  else no q
    FP->>HF: GET /api/feed/hot
    FP->>NF: GET /api/feed/new
  end
  HF->>DB: Select public rules with OR ilike on title/content/user.name (hot order)
  DB-->>HF: Rows (≤50)
  NF->>DB: Select public rules with OR ilike on title/content/user.name (newest)
  DB-->>NF: Rows (≤50)
  HF-->>FP: JSON results
  NF-->>FP: JSON results
  FP-->>U: Render Hot/New tabs (or “No results”)
Loading
sequenceDiagram
  autonumber
  actor U as User
  participant DP as Dashboard Page
  participant LS as /api/lists
  participant LR as /api/lists/{listId}/rules
  participant CR as /api/cursor-rules

  U->>DP: Select multiple rules
  U->>DP: Click "Add to List"
  DP->>LS: GET /api/lists
  LS-->>DP: Lists
  U->>DP: Choose list
  par For each selected rule
    DP->>LR: POST /api/lists/{listId}/rules { ruleId }
    LR-->>DP: 200 / error per rule
  end
  DP-->>U: Toast per-result, clear selection

  U->>DP: Click "Delete Selected"
  DP-->>U: Show confirm dialog
  U->>DP: Confirm
  par For each selected rule
    DP->>CR: DELETE /api/cursor-rules?id={id}
    CR-->>DP: 200 / error per rule
  end
  DP-->>U: Toast summary, update UI, clear selection
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

I twitch my whiskers, sift the streams,
Hot and New now heed my dreams.
With lists I gather, hop-collect,
Bulk nibbles—add, delete, select.
A dashboard path, a tidy link,
Env seeds planted—blink blink blink.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • 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

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Comment on lines +592 to +601
<DialogTrigger asChild>
<Button
variant="primary"
size="sm"
className="bg-red-600 hover:bg-red-700 text-white"
onClick={handleDeleteSelected}
>
Delete {selectedRules.size} Rule{selectedRules.size !== 1 ? 's' : ''}
</Button>
</DialogTrigger>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The delete confirmation dialog uses DialogTrigger to wrap the delete button, which will close the dialog immediately when clicked, preventing the delete action from executing.

View Details
📝 Patch Details
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
index 971c6db..4bcb7c9 100644
--- a/app/dashboard/page.tsx
+++ b/app/dashboard/page.tsx
@@ -7,7 +7,7 @@ import { redirect } from "next/navigation"
 import { useState, useEffect } from "react"
 import { toast } from "sonner"
 import { Button } from "@/components/ui/button"
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, DialogClose } from "@/components/ui/dialog"
 import { Input } from "@/components/ui/input"
 import { Card } from "@/components/ui/card"
 import { track } from "@vercel/analytics"
@@ -207,6 +207,9 @@ export default function DashboardPage() {
     }
   }
 
+  // State for delete dialog
+  const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
+
   // Handle deleting selected rules
   const handleDeleteSelected = async () => {
     if (selectedRules.size === 0) return
@@ -229,6 +232,7 @@ export default function DashboardPage() {
         toast.success(`Deleted ${successful} rule${successful !== 1 ? 's' : ''}!`)
         track("Rules Deleted", { count: successful })
         clearSelection()
+        setIsDeleteDialogOpen(false) // Close dialog after successful deletion
       }
     } catch (error) {
       console.error('Error deleting rules:', error)
@@ -553,7 +557,7 @@ export default function DashboardPage() {
                     Add to List
                   </button>
 
-                  <Dialog>
+                  <Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
                     <DialogTrigger asChild>
                       <button
                         disabled={selectedRules.size === 0}
@@ -584,21 +588,17 @@ export default function DashboardPage() {
                         </DialogDescription>
                       </DialogHeader>
                       <DialogFooter className="gap-2">
-                        <DialogTrigger asChild>
-                          <Button variant="secondary" size="sm">
-                            Cancel
-                          </Button>
-                        </DialogTrigger>
-                        <DialogTrigger asChild>
-                          <Button 
-                            variant="primary" 
-                            size="sm"
-                            className="bg-red-600 hover:bg-red-700 text-white"
-                            onClick={handleDeleteSelected}
-                          >
-                            Delete {selectedRules.size} Rule{selectedRules.size !== 1 ? 's' : ''}
-                          </Button>
-                        </DialogTrigger>
+                        <Button variant="secondary" size="sm" onClick={() => setIsDeleteDialogOpen(false)}>
+                          Cancel
+                        </Button>
+                        <Button 
+                          variant="primary" 
+                          size="sm"
+                          className="bg-red-600 hover:bg-red-700 text-white"
+                          onClick={handleDeleteSelected}
+                        >
+                          Delete {selectedRules.size} Rule{selectedRules.size !== 1 ? 's' : ''}
+                        </Button>
                       </DialogFooter>
                     </DialogContent>
                   </Dialog>

Analysis

The delete confirmation dialog has a UX bug where the delete button is wrapped with DialogTrigger asChild. This means when the user clicks "Delete X Rules", the dialog will close immediately due to the DialogTrigger behavior, and the onClick={handleDeleteSelected} may not execute properly or may execute after the dialog has closed.

The button structure is:

<DialogTrigger asChild>
  <Button onClick={handleDeleteSelected}>
    Delete {selectedRules.size} Rule{selectedRules.size !== 1 ? 's' : ''}
  </Button>
</DialogTrigger>

Expected behavior: User clicks delete button → confirmation executes → dialog closes after success
Actual behavior: User clicks delete button → dialog closes immediately → delete action may not complete properly

Fix: Remove the DialogTrigger asChild wrapper from the delete button and handle dialog closing manually in the handleDeleteSelected function, or use a different approach like DialogClose after the action completes.

The cancel button has the same pattern but it's correct behavior there since canceling should close the dialog.

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: 6

Caution

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

⚠️ Outside diff range comments (3)
components/auth/user-avatar.tsx (1)

179-181: Fix crash when email is undefined.

email?.charAt(0) can be undefined; calling .toUpperCase() then throws. Use a null-safe fallback.

Apply this diff:

-          <span className="text-white font-semibold text-sm leading-none">
-            {session.user.email?.charAt(0).toUpperCase()}
-          </span>
+          <span className="text-white font-semibold text-sm leading-none">
+            {(session.user.email?.charAt(0)?.toUpperCase()
+              ?? session.user.name?.charAt(0)?.toUpperCase()
+              ?? '')}
+          </span>
components/dashboard/user-lists.tsx (1)

298-336: Single controlled Dialog to avoid duplicate modals.

Both places share open={isCreateDialogOpen}; when lists.length === 0, opening one opens both. Use one Dialog root with two triggers or turn buttons into onClick toggles and keep a single Dialog content.

Apply these diffs to convert triggers to simple buttons:

-          <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
-            <DialogTrigger asChild>
-              <Button variant="secondary" size="sm">
-                <Plus className="h-4 w-4 mr-2" />
-                Create List
-              </Button>
-            </DialogTrigger>
-            <DialogContent className="bg-[#1B1D21] border-white/10 text-white">
-              ...
-            </DialogContent>
-          </Dialog>
+          <Button variant="secondary" size="sm" onClick={() => setIsCreateDialogOpen(true)}>
+            <Plus className="h-4 w-4 mr-2" />
+            Create List
+          </Button>
-              <Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
-                <DialogTrigger asChild>
-                  <Button variant="primary" size="sm">
-                    <Plus className="h-4 w-4 mr-2" />
-                    Create Your First List
-                  </Button>
-                </DialogTrigger>
-                <DialogContent className="bg-[#1B1D21] border-white/10 text-white">
-                  ...
-                </DialogContent>
-              </Dialog>
+              <Button variant="primary" size="sm" onClick={() => setIsCreateDialogOpen(true)}>
+                <Plus className="h-4 w-4 mr-2" />
+                Create Your First List
+              </Button>

Add one shared Dialog once in the component (outside the lists/empty-state branches):

// Place once within the component's return (e.g., near the end of the top-level <div>)
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
  <DialogContent className="bg-[#1B1D21] border-white/10 text-white">
    <DialogHeader>
      <DialogTitle className="text-white">Create New List</DialogTitle>
      <DialogDescription className="text-gray-400">
        Create a new list to organize your cursor rules
      </DialogDescription>
    </DialogHeader>
    <div className="space-y-4">
      <Input
        placeholder="Enter list name"
        value={newListTitle}
        onChange={(e) => setNewListTitle(e.target.value)}
        className="bg-[#0F1419] border-white/10 text-white"
        onKeyDown={(e) => e.key === 'Enter' && handleCreateList()}
      />
    </div>
    <DialogFooter>
      <Button variant="outline" className="border-white/10 bg-transparent hover:bg-white/5" onClick={() => setIsCreateDialogOpen(false)}>
        Cancel
      </Button>
      <Button onClick={handleCreateList} disabled={isCreating || !newListTitle.trim()}>
        {isCreating ? "Creating..." : "Create List"}
      </Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

Also applies to: 534-572

app/dashboard/page.tsx (1)

851-876: Per-item delete dialog: same DialogClose fix applies.

Replace both inner DialogTrigger components with DialogClose to close reliably after Cancel/Delete.

-                              <DialogFooter className="gap-2">
-                                <DialogTrigger asChild>
+                              <DialogFooter className="gap-2">
+                                <DialogClose asChild>
                                   <Button variant="secondary" size="sm">
                                     Cancel
                                   </Button>
-                                </DialogTrigger>
-                                <DialogTrigger asChild>
+                                </DialogClose>
+                                <DialogClose asChild>
                                   <Button 
                                     variant="primary" 
                                     size="sm"
                                     className="bg-red-600 hover:bg-red-700 text-white"
                                     onClick={() => handleDeleteRule(rule.id)}
                                   >
                                     Delete
                                   </Button>
-                                </DialogTrigger>
+                                </DialogClose>

(Import DialogClose as noted above.)

🧹 Nitpick comments (17)
.env.example (1)

1-13: Address dotenv-linter warnings: drop quotes, trim trailing whitespace, add EOF newline.

Removes noisy warnings and keeps a clean template.

Apply this diff:

-# Database (production)
-DATABASE_URL="postgresql://prod_user:password@host/database"
+# Database (production)
+DATABASE_URL=postgresql://prod_user:password@host/database

-# Email Service
-INBOUND_API_KEY="your_production_api_key"
+# Email Service
+INBOUND_API_KEY=your_production_api_key

-# Auth
-BETTER_AUTH_SECRET="your_secure_random_string"
-BETTER_AUTH_URL="https://your-domain.com"
+# Auth
+BETTER_AUTH_SECRET=your_secure_random_string
+BETTER_AUTH_URL=https://your-domain.com

-# GitHub
-GITHUB_CLIENT_ID="your-client-id" 
-GITHUB_CLIENT_SECRET="your-client-secret"
+# GitHub
+GITHUB_CLIENT_ID=your-client-id
+GITHUB_CLIENT_SECRET=your-client-secret
+
components/dashboard/user-lists.tsx (1)

150-168: Snapshot title before clearing state to ensure correct toast text.

Avoid relying on async state timing by storing the title locally.

Apply this diff:

-      const response = await fetch('/api/lists', {
+      const createdTitle = newListTitle.trim()
+      const response = await fetch('/api/lists', {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ title: newListTitle.trim() })
+        body: JSON.stringify({ title: createdTitle })
       })
...
-      setNewListTitle("")
-      setIsCreateDialogOpen(false) // Close the dialog after successful creation
-      toast.success(`Created list "${newListTitle}"!`)
+      setNewListTitle("")
+      setIsCreateDialogOpen(false) // Close the dialog after successful creation
+      toast.success(`Created list "${createdTitle}"!`)
components/header.tsx (2)

43-50: Add accessible name for icon-only state

On small screens the label is hidden; add an accessible name so screen readers still announce “Dashboard”.

-          <Link href="/dashboard">
+          <Link href="/dashboard" aria-label="Dashboard">

46-46: Use React SVG camelCase attributes

TSX prefers fillOpacity over fill-opacity to avoid type warnings.

-              <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><title>dashboard</title><g fill="#70A7D7"><path d="M2.75 2.75C2.75 2.336 3.086 2 3.5 2H6.5C6.914 2 7.25 2.336 7.25 2.75V6.5C7.25 6.914 6.914 7.25 6.5 7.25H3.5C3.086 7.25 2.75 6.914 2.75 6.5V2.75Z" fill-opacity="0.4"></path> <path d="M10.75 2.75C10.75 2.336 11.086 2 11.5 2H14.5C14.914 2 15.25 2.336 15.25 2.75V6.5C15.25 6.914 14.914 7.25 14.5 7.25H11.5C11.086 7.25 10.75 6.914 10.75 6.5V2.75Z" fill-opacity="0.4"></path> <path d="M2.75 10.75C2.75 10.336 3.086 10 3.5 10H6.5C6.914 10 7.25 10.336 7.25 10.75V14.5C7.25 14.914 6.914 15.25 6.5 15.25H3.5C3.086 15.25 2.75 14.914 2.75 14.5V10.75Z" fill-opacity="0.4"></path> <path d="M10.75 10.75C10.75 10.336 11.086 10 11.5 10H14.5C14.914 10 15.25 10.336 15.25 10.75V14.5C15.25 14.914 14.914 15.25 14.5 15.25H11.5C11.086 15.25 10.75 14.914 10.75 14.5V10.75Z" fill-opacity="0.4"></path></g></svg>
+              <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><title>dashboard</title><g fill="#70A7D7"><path d="M2.75 2.75C2.75 2.336 3.086 2 3.5 2H6.5C6.914 2 7.25 2.336 7.25 2.75V6.5C7.25 6.914 6.914 7.25 6.5 7.25H3.5C3.086 7.25 2.75 6.914 2.75 6.5V2.75Z" fillOpacity="0.4"></path> <path d="M10.75 2.75C10.75 2.336 11.086 2 11.5 2H14.5C14.914 2 15.25 2.336 15.25 2.75V6.5C15.25 6.914 14.914 7.25 14.5 7.25H11.5C11.086 7.25 10.75 6.914 10.75 6.5V2.75Z" fillOpacity="0.4"></path> <path d="M2.75 10.75C2.75 10.336 3.086 10 3.5 10H6.5C6.914 10 7.25 10.336 7.25 10.75V14.5C7.25 14.914 6.914 15.25 6.5 15.25H3.5C3.086 15.25 2.75 14.914 2.75 14.5V10.75Z" fillOpacity="0.4"></path> <path d="M10.75 10.75C10.75 10.336 11.086 10 11.5 10H14.5C14.914 10 15.25 10.336 15.25 10.75V14.5C15.25 14.914 14.914 15.25 14.5 15.25H11.5C11.086 15.25 10.75 14.914 10.75 14.5V10.75Z" fillOpacity="0.4"></path></g></svg>
app/api/feed/new/route.ts (3)

28-39: Guard against pathological q sizes

Trim and cap q length (e.g., 200 chars) to protect against expensive leading-wildcard scans.

The diff above includes slice(0, 200). Consider documenting this limit in README.


11-27: Optional: extract a shared feed query builder

Hot/New duplicate selection/join/filters. Factor into a tiny helper (e.g., lib/queries/feed.ts) to keep logic consistent and avoid regressions.


41-44: Indexing advice for search and sort

Add indexes to sustain LIKE searches and sort limits:

  • GIN trigram on title, content, and user.name (requires pg_trgm).
  • Composite btree on (isPublic, createdAt DESC).
app/api/feed/hot/route.ts (1)

41-44: Consider secondary sort tie-breaker

views DESC then createdAt DESC is good. If ties are frequent, add id DESC to stabilize pagination.

app/feed/page.tsx (2)

231-237: Add accessible label to search input

Placeholder isn’t an accessible name; add aria-label.

-            <Input
+            <Input
               type="text"
               placeholder="Search rules by title, content, or author..."
               value={searchQuery}
               onChange={(e) => setSearchQuery(e.target.value)}
               className="pl-10 pr-10 bg-[#1B1D21] border-white/10 text-white placeholder:text-gray-400 focus:border-[#70A7D7] focus:ring-[#70A7D7]"
+              aria-label="Search rules"
             />

147-159: Minor: cache-busting and analytics consistency

If results must always be fresh, pass cache: 'no-store'. Also consider tracking when searches execute (debounced value changes) for better analytics.

-          fetch(hotUrl, { signal: controller.signal }),
-          fetch(newUrl, { signal: controller.signal })
+          fetch(hotUrl, { signal: controller.signal, cache: 'no-store' }),
+          fetch(newUrl, { signal: controller.signal, cache: 'no-store' })
README.md (1)

206-217: Document that feed search only returns public rules

Clarify behavior to match the API intent and avoid user confusion.

-GET /api/feed/hot?q=search_query
+GET /api/feed/hot?q=search_query  # Searches public rules (title, content, author)
-GET /api/feed/new?q=search_query
+GET /api/feed/new?q=search_query  # Searches public rules (title, content, author)
app/dashboard/page.tsx (6)

149-156: Select all/clear look fine.

If the list can be very large later, consider virtualized lists before optimizing further.


161-173: Handle non-OK responses from /api/lists.

Currently ignores non-2xx. Surface a toast for visibility.

Apply within this block:

   const fetchAvailableLists = async () => {
     try {
       const response = await fetch('/api/lists')
-      if (response.ok) {
-        const lists = await response.json()
-        setAvailableLists(lists.map((list: any) => ({ id: list.id, title: list.title })))
-      }
+      if (!response.ok) {
+        toast.error('Failed to fetch lists')
+        return
+      }
+      const lists = await response.json()
+      setAvailableLists(lists.map((list: any) => ({ id: list.id, title: list.title })))
     } catch (error) {
       console.error('Error fetching lists:', error)
     }
   }

413-416: Fetching lists on session ready is fine.

Optional: also refresh lists when opening the list dialog (see comment below).


510-528: Minor UX: reflect partial selection.

If some (not all) are selected, consider an “indeterminate” checkbox state for better affordance.


660-679: Add basic a11y to selection toggle.

Expose checkbox semantics and keyboard toggle.

-                          <button
+                          <button
+                            role="checkbox"
+                            aria-checked={isRuleSelected(rule.id)}
+                            aria-label={isRuleSelected(rule.id) ? 'Deselect rule' : 'Select rule'}
                             onClick={(e) => {
                               e.stopPropagation()
                               toggleRuleSelection(rule.id)
                             }}
                             className="flex items-center justify-center w-4 h-4 rounded border border-white/20 hover:border-white/40 transition-colors"
                             style={{
                               backgroundColor: isRuleSelected(rule.id) ? '#70A7D7' : 'transparent'
                             }}
                             title={isRuleSelected(rule.id) ? 'Deselect rule' : 'Select rule'}
+                            onKeyDown={(e) => {
+                              if (e.key === ' ' || e.key === 'Enter') {
+                                e.preventDefault()
+                                toggleRuleSelection(rule.id)
+                              }
+                            }}
                           >

910-959: List dialog is solid; small QoL: refresh lists on open and disable Cancel while adding.

Keeps options fresh and avoids accidental close mid-submit.

-      <Dialog open={showListDialog} onOpenChange={setShowListDialog}>
+      <Dialog
+        open={showListDialog}
+        onOpenChange={(open) => {
+          setShowListDialog(open)
+          if (open) fetchAvailableLists()
+        }}
+      >
...
-            <Button
+            <Button
               variant="outline"
               onClick={() => setShowListDialog(false)}
+              disabled={isAddingToList}
               className="border-white/10 bg-transparent hover:bg-white/5"
             >
               Cancel
             </Button>
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 1afb62e and 2d0acfc.

📒 Files selected for processing (11)
  • .env.example (1 hunks)
  • .gitignore (1 hunks)
  • README.md (5 hunks)
  • app/api/feed/hot/route.ts (2 hunks)
  • app/api/feed/new/route.ts (2 hunks)
  • app/dashboard/page.tsx (6 hunks)
  • app/feed/page.tsx (6 hunks)
  • components/auth/user-avatar.tsx (1 hunks)
  • components/dashboard/user-lists.tsx (4 hunks)
  • components/header.tsx (1 hunks)
  • package.json (1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/better-auth-device-authorization.mdc)

When calling device.token, set grant_type to "urn:ietf:params:oauth:grant-type:device_code"

Files:

  • components/dashboard/user-lists.tsx
  • components/header.tsx
  • components/auth/user-avatar.tsx
  • app/api/feed/hot/route.ts
  • app/dashboard/page.tsx
  • app/feed/page.tsx
  • app/api/feed/new/route.ts
🧬 Code graph analysis (4)
app/api/feed/hot/route.ts (2)
app/api/feed/new/route.ts (1)
  • GET (6-51)
lib/schema.ts (2)
  • cursorRule (49-59)
  • user (3-11)
app/dashboard/page.tsx (1)
lib/schema.ts (1)
  • list (61-67)
app/feed/page.tsx (1)
components/ui/input.tsx (1)
  • Input (21-21)
app/api/feed/new/route.ts (3)
app/api/feed/hot/route.ts (1)
  • GET (6-51)
lib/db.ts (1)
  • db (10-10)
lib/schema.ts (2)
  • cursorRule (49-59)
  • user (3-11)
🪛 dotenv-linter (3.3.0)
.env.example

[warning] 2-2: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 5-5: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 8-8: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 9-9: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)


[warning] 12-12: [TrailingWhitespace] Trailing whitespace detected

(TrailingWhitespace)


[warning] 13-13: [EndingBlankLine] No blank line at the end of the file

(EndingBlankLine)


[warning] 13-13: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)

🪛 LanguageTool
README.md

[grammar] ~16-~16: There might be a mistake here.
Context: ... community-shared rules in Hot/New feeds - 📱 CLI Tool - Full-featured CLI for sy...

(QB_NEW_EN)


[grammar] ~17-~17: There might be a mistake here.
Context: ...rules in Hot/New feeds - 📱 CLI Tool - Full-featured CLI for syncing rules be...

(QB_NEW_EN)


[grammar] ~17-~17: There might be a mistake here.
Context: ...es in Hot/New feeds - 📱 CLI Tool - Full-featured CLI for syncing rules between l...

(QB_NEW_EN)


[grammar] ~17-~17: There might be a mistake here.
Context: ...or syncing rules between local and cloud - 📋 Lists & Collections - Organize rule...

(QB_NEW_EN)


[grammar] ~18-~18: There might be a mistake here.
Context: ...ons** - Organize rules into custom lists - 🎨 Modern UI - Beautiful dark theme wi...

(QB_NEW_EN)


[grammar] ~19-~19: There might be a mistake here.
Context: ...e with Tailwind CSS and Radix components ## 🛠️ Tech Stack ### Frontend - **Next.js...

(QB_NEW_EN)


[grammar] ~21-~21: There might be a mistake here.
Context: ... and Radix components ## 🛠️ Tech Stack ### Frontend - Next.js 15.2.4 - App Rout...

(QB_NEW_EN)


[grammar] ~23-~23: There might be a mistake here.
Context: ...ponents ## 🛠️ Tech Stack ### Frontend - Next.js 15.2.4 - App Router with React...

(QB_NEW_EN)


[grammar] ~24-~24: There might be a mistake here.
Context: ...t.js 15.2.4** - App Router with React 19 - TypeScript 5 - Full type safety - **Ta...

(QB_NEW_EN)


[grammar] ~25-~25: There might be a mistake here.
Context: ...19 - TypeScript 5 - Full type safety - Tailwind CSS 4.1.9 - Modern styling sy...

(QB_NEW_EN)


[grammar] ~26-~26: There might be a mistake here.
Context: ...wind CSS 4.1.9** - Modern styling system - Radix UI - Accessible component primit...

(QB_NEW_EN)


[grammar] ~27-~27: There might be a mistake here.
Context: ...x UI** - Accessible component primitives - React Hook Form + Zod - Form handling ...

(QB_NEW_EN)


[grammar] ~28-~28: There might be a mistake here.
Context: ...m + Zod** - Form handling and validation - Geist Font - Modern typography ### Ba...

(QB_NEW_EN)


[grammar] ~31-~31: There might be a mistake here.
Context: ... Font** - Modern typography ### Backend - PostgreSQL - Primary database (via Neo...

(QB_NEW_EN)


[grammar] ~32-~32: There might be a mistake here.
Context: ...SQL** - Primary database (via Neon.tech) - Drizzle ORM 0.44.5 - Type-safe databas...

(QB_NEW_EN)


[grammar] ~33-~33: There might be a mistake here.
Context: ...RM 0.44.5** - Type-safe database queries - Better Auth 1.3.8-beta.9 - Modern auth...

(QB_NEW_EN)


[grammar] ~34-~34: There might be a mistake here.
Context: ...- Modern authentication with magic links - Inbound Email 4.0.0 - Transactional em...

(QB_NEW_EN)


[grammar] ~37-~37: There might be a mistake here.
Context: ...onal email service ### Tools & Services - Vercel - Deployment and hosting - **Re...

(QB_NEW_EN)


[grammar] ~38-~38: There might be a mistake here.
Context: ...es - Vercel - Deployment and hosting - React Scan 0.4.3 - Performance monitor...

(QB_NEW_EN)


[grammar] ~39-~39: There might be a mistake here.
Context: ...ct Scan 0.4.3** - Performance monitoring - Sonner 2.0.7 - Toast notifications - *...

(QB_NEW_EN)


[grammar] ~40-~40: There might be a mistake here.
Context: ...- Sonner 2.0.7 - Toast notifications - Vercel Analytics - Usage analytics - *...

(QB_NEW_EN)


[grammar] ~41-~41: There might be a mistake here.
Context: ...- Vercel Analytics - Usage analytics - GPT Tokenizer - Token counting for rul...

(QB_NEW_EN)


[grammar] ~248-~248: There might be a mistake here.
Context: ...e-id-1", "rule-id-2"] } ``` ## 🎯 Usage ### Creating Rules 1. *Visit the homepage...

(QB_NEW_EN)


[grammar] ~287-~287: There might be a mistake here.
Context: ...r/rules/` automatically. ## 📱 CLI Tool cursor.link includes a powerful CLI tool...

(QB_NEW_EN)


[grammar] ~339-~339: There might be a mistake here.
Context: ... ID ### How it Works #### Push Process 1. Scans your .cursor/rules/ directory fo...

(QB_NEW_EN)


[grammar] ~340-~340: There might be a mistake here.
Context: ...ursor/rules/directory for.mdc` files 2. Parses each file to extract title, conte...

(QB_NEW_EN)


[grammar] ~341-~341: There might be a mistake here.
Context: ... to extract title, content, and settings 3. Uploads rules to your cursor.link accoun...

(QB_NEW_EN)


[grammar] ~342-~342: There might be a mistake here.
Context: ...ploads rules to your cursor.link account 4. Handles conflicts by asking for your pre...

(QB_NEW_EN)


[grammar] ~345-~345: There might be a mistake here.
Context: ...g for your preference #### Pull Process 1. Fetches available rules from cursor.link...

(QB_NEW_EN)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Vade Review
🔇 Additional comments (10)
package.json (1)

2-2: Rename to “cursor-link” — looks good.

No issues spotted with the package rename.

.gitignore (1)

21-21: Good call tracking .env.example.

Keeps secrets out while documenting env shape.

components/auth/user-avatar.tsx (1)

185-241: Header includes Dashboard link — resolved

The top navigation in components/header.tsx (line 44) contains a <Link href="/dashboard">Dashboard</Link>, so no further changes are needed.

components/dashboard/user-lists.tsx (1)

97-99: All device.token calls include the required grant_type
Verified the sole authClient.device.token invocation in cursor-link-cli/src/utils/auth-client.ts already uses grant_type: "urn:ietf:params:oauth:grant-type:device_code".

components/header.tsx (1)

43-50: LGTM: clear, consistent nav affordance

Adding a dedicated Dashboard entry here matches the user menu change and improves discoverability.

README.md (1)

305-310: No action needed—grant_type is correct
Verified in cursor-link-cli/src/utils/auth-client.ts (line 218) that authClient.device.token uses "urn:ietf:params:oauth:grant-type:device_code".

app/dashboard/page.tsx (4)

123-127: Good call using Set for multi-select state.

Efficient membership checks and clean updates.


136-147: toggleRuleSelection logic is solid.

Idempotent, minimal re-renders via copying the Set.


157-160: Helper reads clearly.

No changes needed.


606-622: Clear selection control looks good.

State and disabled styling are consistent.

Comment on lines +28 to +39
// Add search filter if query is provided
if (searchQuery && searchQuery.trim()) {
const searchTerm = `%${searchQuery.trim()}%`
query = query.where(
or(
eq(cursorRule.isPublic, true),
ilike(cursorRule.title, searchTerm),
ilike(cursorRule.content, searchTerm),
ilike(user.name, searchTerm)
)
)
}
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

Same leakage bug as New feed: OR widens to private content

Apply AND with isPublic as in the New route fix.

-import { eq, desc, or, ilike } from "drizzle-orm"
+import { eq, desc, or, ilike, and } from "drizzle-orm"
@@
-    if (searchQuery && searchQuery.trim()) {
-      const searchTerm = `%${searchQuery.trim()}%`
-      query = query.where(
-        or(
-          eq(cursorRule.isPublic, true),
-          ilike(cursorRule.title, searchTerm),
-          ilike(cursorRule.content, searchTerm),
-          ilike(user.name, searchTerm)
-        )
-      )
-    }
+    if (searchQuery && searchQuery.trim()) {
+      const searchTerm = `%${searchQuery.trim().slice(0, 200)}%`
+      query = query.where(
+        and(
+          eq(cursorRule.isPublic, true),
+          or(
+            ilike(cursorRule.title, searchTerm),
+            ilike(cursorRule.content, searchTerm),
+            ilike(user.name, searchTerm)
+          )
+        )
+      )
+    }
📝 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
// Add search filter if query is provided
if (searchQuery && searchQuery.trim()) {
const searchTerm = `%${searchQuery.trim()}%`
query = query.where(
or(
eq(cursorRule.isPublic, true),
ilike(cursorRule.title, searchTerm),
ilike(cursorRule.content, searchTerm),
ilike(user.name, searchTerm)
)
)
}
// At top of file, include `and` in the import
import { eq, desc, or, ilike, and } from "drizzle-orm"
// ...
// Add search filter if query is provided
if (searchQuery && searchQuery.trim()) {
- const searchTerm = `%${searchQuery.trim()}%`
- query = query.where(
- or(
- eq(cursorRule.isPublic, true),
- ilike(cursorRule.title, searchTerm),
- ilike(cursorRule.content, searchTerm),
- ilike(user.name, searchTerm)
- )
- )
// limit search term length to prevent overly long patterns
const searchTerm = `%${searchQuery.trim().slice(0, 200)}%`
query = query.where(
and(
// always require public content…
eq(cursorRule.isPublic, true),
// …and at least one field matches the term
or(
ilike(cursorRule.title, searchTerm),
ilike(cursorRule.content, searchTerm),
ilike(user.name, searchTerm)
)
)
)
}
🤖 Prompt for AI Agents
In app/api/feed/hot/route.ts around lines 28 to 39, the search filter currently
wraps isPublic inside an OR which expands results to include private content
when the search matches; replace that logic so isPublic is always required and
the search terms are applied as an additional condition: wrap the ilike clauses
in an or(...) and combine that or(...) with eq(cursorRule.isPublic, true) using
and(...), i.e. change the where(...) call to require isPublic true AND (title
ilike OR content ilike OR user.name ilike) so private items are not returned by
searches.

Comment on lines +28 to +39
// Add search filter if query is provided
if (searchQuery && searchQuery.trim()) {
const searchTerm = `%${searchQuery.trim()}%`
query = query.where(
or(
eq(cursorRule.isPublic, true),
ilike(cursorRule.title, searchTerm),
ilike(cursorRule.content, searchTerm),
ilike(user.name, searchTerm)
)
)
}
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

Private rules can leak via OR search; combine with isPublic using AND

The second where() overrides the first and the OR condition allows matches on non-public rows. Must AND the search group with isPublic.

-import { eq, desc, or, ilike } from "drizzle-orm"
+import { eq, desc, or, ilike, and } from "drizzle-orm"
@@
-    // Add search filter if query is provided
-    if (searchQuery && searchQuery.trim()) {
-      const searchTerm = `%${searchQuery.trim()}%`
-      query = query.where(
-        or(
-          eq(cursorRule.isPublic, true),
-          ilike(cursorRule.title, searchTerm),
-          ilike(cursorRule.content, searchTerm),
-          ilike(user.name, searchTerm)
-        )
-      )
-    }
+    // Add search filter if query is provided
+    if (searchQuery && searchQuery.trim()) {
+      const searchTerm = `%${searchQuery.trim().slice(0, 200)}%`
+      // Preserve isPublic = true AND apply (title OR content OR author) filter
+      query = query.where(
+        and(
+          eq(cursorRule.isPublic, true),
+          or(
+            ilike(cursorRule.title, searchTerm),
+            ilike(cursorRule.content, searchTerm),
+            ilike(user.name, searchTerm)
+          )
+        )
+      )
+    }
📝 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
// Add search filter if query is provided
if (searchQuery && searchQuery.trim()) {
const searchTerm = `%${searchQuery.trim()}%`
query = query.where(
or(
eq(cursorRule.isPublic, true),
ilike(cursorRule.title, searchTerm),
ilike(cursorRule.content, searchTerm),
ilike(user.name, searchTerm)
)
)
}
// At the top of the file, include `and` alongside the other imports
import { eq, desc, or, ilike, and } from "drizzle-orm"
// …
// Add search filter if query is provided
if (searchQuery && searchQuery.trim()) {
- const searchTerm = `%${searchQuery.trim()}%`
- query = query.where(
- or(
- eq(cursorRule.isPublic, true),
- ilike(cursorRule.title, searchTerm),
- ilike(cursorRule.content, searchTerm),
- ilike(user.name, searchTerm)
- )
// Limit the length of the search term to avoid overly long queries
const searchTerm = `%${searchQuery.trim().slice(0, 200)}%`
// Ensure only public rules are returned, then apply the search filters
query = query.where(
and(
eq(cursorRule.isPublic, true),
or(
ilike(cursorRule.title, searchTerm),
ilike(cursorRule.content, searchTerm),
ilike(user.name, searchTerm)
)
)
)
}
🤖 Prompt for AI Agents
In app/api/feed/new/route.ts around lines 28 to 39, the second query.where(...)
uses an OR that allows non-public rows to match and effectively overrides the
previous isPublic filter; change the query to ensure the search group is
combined with isPublic using AND (for example, wrap the existing OR inside an
AND with eq(cursorRule.isPublic, true) or use the ORM's andWhere/merge
mechanism) so that search matches must also have isPublic=true.

Comment on lines +174 to +208
// Handle adding selected rules to a list
const handleAddSelectedToList = async (listId: string) => {
if (selectedRules.size === 0) return

setIsAddingToList(true)
try {
const promises = Array.from(selectedRules).map(ruleId =>
fetch(`/api/lists/${listId}/rules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ruleId })
})
)

const results = await Promise.allSettled(promises)
const successful = results.filter(result => result.status === 'fulfilled').length
const failed = results.length - successful

if (successful > 0) {
toast.success(`Added ${successful} rule${successful !== 1 ? 's' : ''} to list!`)
track("Rules Added to List", { count: successful, listId })
}
if (failed > 0) {
toast.error(`Failed to add ${failed} rule${failed !== 1 ? 's' : ''} (may already be in list)`)
}

setShowListDialog(false)
clearSelection()
} catch (error) {
console.error('Error adding rules to list:', error)
toast.error('Failed to add rules to list')
} finally {
setIsAddingToList(false)
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Count success by Response.ok, not just fulfilled promises; snapshot selection.

4xx/5xx resolve the promise but should be treated as failures. Also snapshot selected IDs to avoid races.

   const handleAddSelectedToList = async (listId: string) => {
-    if (selectedRules.size === 0) return
+    const ids = Array.from(selectedRules)
+    if (ids.length === 0) return

     setIsAddingToList(true)
     try {
-      const promises = Array.from(selectedRules).map(ruleId =>
-        fetch(`/api/lists/${listId}/rules`, {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ ruleId })
-        })
-      )
-
-      const results = await Promise.allSettled(promises)
-      const successful = results.filter(result => result.status === 'fulfilled').length
-      const failed = results.length - successful
+      const requests = ids.map(ruleId =>
+        fetch(`/api/lists/${listId}/rules`, {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ ruleId })
+        })
+      )
+      const settled = await Promise.allSettled(requests)
+      let successful = 0
+      for (const r of settled) {
+        if (r.status === 'fulfilled' && r.value.ok) successful++
+      }
+      const failed = ids.length - successful

       if (successful > 0) {
         toast.success(`Added ${successful} rule${successful !== 1 ? 's' : ''} to list!`)
         track("Rules Added to List", { count: successful, listId })
       }
       if (failed > 0) {
-        toast.error(`Failed to add ${failed} rule${failed !== 1 ? 's' : ''} (may already be in list)`)
+        toast.error(`Failed to add ${failed} rule${failed !== 1 ? 's' : ''} (already in list or server error)`)
       }

       setShowListDialog(false)
       clearSelection()
     } catch (error) {
       console.error('Error adding rules to list:', error)
       toast.error('Failed to add rules to list')
     } finally {
       setIsAddingToList(false)
     }
   }

If this becomes a hot path, consider a batch API (POST /api/lists/{id}/rules: { ruleIds: string[] }) to reduce N requests.

📝 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
// Handle adding selected rules to a list
const handleAddSelectedToList = async (listId: string) => {
if (selectedRules.size === 0) return
setIsAddingToList(true)
try {
const promises = Array.from(selectedRules).map(ruleId =>
fetch(`/api/lists/${listId}/rules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ruleId })
})
)
const results = await Promise.allSettled(promises)
const successful = results.filter(result => result.status === 'fulfilled').length
const failed = results.length - successful
if (successful > 0) {
toast.success(`Added ${successful} rule${successful !== 1 ? 's' : ''} to list!`)
track("Rules Added to List", { count: successful, listId })
}
if (failed > 0) {
toast.error(`Failed to add ${failed} rule${failed !== 1 ? 's' : ''} (may already be in list)`)
}
setShowListDialog(false)
clearSelection()
} catch (error) {
console.error('Error adding rules to list:', error)
toast.error('Failed to add rules to list')
} finally {
setIsAddingToList(false)
}
}
// Handle adding selected rules to a list
const handleAddSelectedToList = async (listId: string) => {
const ids = Array.from(selectedRules)
if (ids.length === 0) return
setIsAddingToList(true)
try {
const requests = ids.map(ruleId =>
fetch(`/api/lists/${listId}/rules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ruleId })
})
)
const settled = await Promise.allSettled(requests)
let successful = 0
for (const r of settled) {
if (r.status === 'fulfilled' && r.value.ok) successful++
}
const failed = ids.length - successful
if (successful > 0) {
toast.success(`Added ${successful} rule${successful !== 1 ? 's' : ''} to list!`)
track("Rules Added to List", { count: successful, listId })
}
if (failed > 0) {
toast.error(`Failed to add ${failed} rule${failed !== 1 ? 's' : ''} (already in list or server error)`)
}
setShowListDialog(false)
clearSelection()
} catch (error) {
console.error('Error adding rules to list:', error)
toast.error('Failed to add rules to list')
} finally {
setIsAddingToList(false)
}
}
🤖 Prompt for AI Agents
In app/dashboard/page.tsx around lines 174 to 208, snapshot the current selected
rule IDs into a local array before any async work to avoid races, then perform
the fetches and treat a request as successful only if the Response.ok is true
(counting non-ok 4xx/5xx as failures). Concretely: copy selectedRules to an
array up-front, map that array to async fetch calls that check response.ok (and
throw or return a failure marker when not ok), await Promise.all or
Promise.allSettled and compute successful based on response.ok (or resolved
value) rather than settled status, then proceed to show success/error toasts and
clear selection. Ensure setIsAddingToList is still toggled in finally.

Comment on lines +210 to +237
// Handle deleting selected rules
const handleDeleteSelected = async () => {
if (selectedRules.size === 0) return

try {
const promises = Array.from(selectedRules).map(ruleId =>
fetch('/api/cursor-rules', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: ruleId })
})
)

const results = await Promise.allSettled(promises)
const successful = results.filter(result => result.status === 'fulfilled').length

if (successful > 0) {
// Remove successfully deleted rules from local state
setRules(prevRules => prevRules.filter(rule => !selectedRules.has(rule.id)))
toast.success(`Deleted ${successful} rule${successful !== 1 ? 's' : ''}!`)
track("Rules Deleted", { count: successful })
clearSelection()
}
} catch (error) {
console.error('Error deleting rules:', error)
toast.error('Failed to delete rules')
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Delete selected: verify Response.ok, remove only actually-deleted IDs, snapshot selection.

Current logic may report success when server returns 4xx/5xx and may race with selection changes.

   const handleDeleteSelected = async () => {
-    if (selectedRules.size === 0) return
+    const ids = Array.from(selectedRules)
+    if (ids.length === 0) return

     try {
-      const promises = Array.from(selectedRules).map(ruleId =>
-        fetch('/api/cursor-rules', {
-          method: 'DELETE',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ id: ruleId })
-        })
-      )
-
-      const results = await Promise.allSettled(promises)
-      const successful = results.filter(result => result.status === 'fulfilled').length
+      const results = await Promise.all(
+        ids.map(ruleId =>
+          fetch('/api/cursor-rules', {
+            method: 'DELETE',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ id: ruleId })
+          })
+            .then(res => ({ id: ruleId, ok: res.ok }))
+            .catch(() => ({ id: ruleId, ok: false }))
+        )
+      )
+      const successfulIds = results.filter(r => r.ok).map(r => r.id)
+      const successful = successfulIds.length
+      const failed = ids.length - successful

       if (successful > 0) {
-        // Remove successfully deleted rules from local state
-        setRules(prevRules => prevRules.filter(rule => !selectedRules.has(rule.id)))
+        // Remove successfully deleted rules from local state
+        setRules(prevRules => prevRules.filter(rule => !successfulIds.includes(rule.id)))
         toast.success(`Deleted ${successful} rule${successful !== 1 ? 's' : ''}!`)
         track("Rules Deleted", { count: successful })
         clearSelection()
       }
+      if (failed > 0) {
+        toast.error(`Failed to delete ${failed} rule${failed !== 1 ? 's' : ''}`)
+      }
     } catch (error) {
       console.error('Error deleting rules:', error)
       toast.error('Failed to delete rules')
     }
   }
📝 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
// Handle deleting selected rules
const handleDeleteSelected = async () => {
if (selectedRules.size === 0) return
try {
const promises = Array.from(selectedRules).map(ruleId =>
fetch('/api/cursor-rules', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: ruleId })
})
)
const results = await Promise.allSettled(promises)
const successful = results.filter(result => result.status === 'fulfilled').length
if (successful > 0) {
// Remove successfully deleted rules from local state
setRules(prevRules => prevRules.filter(rule => !selectedRules.has(rule.id)))
toast.success(`Deleted ${successful} rule${successful !== 1 ? 's' : ''}!`)
track("Rules Deleted", { count: successful })
clearSelection()
}
} catch (error) {
console.error('Error deleting rules:', error)
toast.error('Failed to delete rules')
}
}
// Handle deleting selected rules
const handleDeleteSelected = async () => {
// Snapshot the current selection
const ids = Array.from(selectedRules)
if (ids.length === 0) return
try {
// Perform all deletes and check each response.ok
const results = await Promise.all(
ids.map(ruleId =>
fetch('/api/cursor-rules', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: ruleId })
})
.then(res => ({ id: ruleId, ok: res.ok }))
.catch(() => ({ id: ruleId, ok: false }))
)
)
const successfulIds = results.filter(r => r.ok).map(r => r.id)
const successful = successfulIds.length
const failed = ids.length - successful
if (successful > 0) {
// Remove only actually-deleted rules from local state
setRules(prevRules => prevRules.filter(rule => !successfulIds.includes(rule.id)))
toast.success(`Deleted ${successful} rule${successful !== 1 ? 's' : ''}!`)
track("Rules Deleted", { count: successful })
clearSelection()
}
if (failed > 0) {
toast.error(`Failed to delete ${failed} rule${failed !== 1 ? 's' : ''}`)
}
} catch (error) {
console.error('Error deleting rules:', error)
toast.error('Failed to delete rules')
}
}
🤖 Prompt for AI Agents
In app/dashboard/page.tsx around lines 210 to 237, snapshot the current
selection at the start (e.g., const ids = Array.from(selectedRules)) to avoid
races, then perform the DELETE requests for that snapshot; after awaiting
results, for each fulfilled promise verify response.ok (and optionally parse
JSON) and only treat those with ok as actually deleted, collect their IDs,
remove only those IDs from local rules state and from selection (do not assume
all selected were deleted), and update the toast/track counts using the number
of actually-deleted IDs; preserve the try/catch but change success logic to rely
on response.ok rather than promise fulfillment alone.

Comment on lines +556 to +604
<Dialog>
<DialogTrigger asChild>
<button
disabled={selectedRules.size === 0}
className={`flex items-center gap-1 px-1.5 py-0.5 rounded-md transition-colors text-xs group ${
selectedRules.size > 0
? 'hover:bg-red-500/10 text-red-500'
: 'text-gray-600 cursor-not-allowed opacity-50'
}`}
title={selectedRules.size > 0 ? "Delete selected rules" : "Select rules to delete"}
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 18 18">
<title>trash-2</title>
<g fill={selectedRules.size > 0 ? "#EF4444" : "#6B7280"} className={selectedRules.size > 0 ? "group-hover:fill-red-400" : ""}>
<path opacity="0.4" d="M3.40771 5L3.90253 14.3892C3.97873 15.8531 5.18472 17 6.64862 17H11.3527C12.8166 17 14.0226 15.853 14.0988 14.3896L14.5936 5H3.40771Z"></path>
<path d="M7.37407 14.0001C6.98007 14.0001 6.64908 13.69 6.62608 13.2901L6.37608 8.7901C6.35408 8.3801 6.67008 8.02006 7.08308 8.00006C7.48908 7.98006 7.85107 8.29002 7.87407 8.71002L8.12407 13.21C8.14707 13.62 7.83007 13.9801 7.41707 14.0001H7.37407Z"></path>
<path d="M10.6261 14.0001H10.5831C10.1701 13.9801 9.85408 13.62 9.87608 13.21L10.1261 8.71002C10.1491 8.29012 10.4981 7.98006 10.9171 8.00006C11.3301 8.02006 11.6471 8.3801 11.6241 8.7901L11.3741 13.2901C11.3521 13.69 11.0211 14.0001 10.6261 14.0001Z"></path>
<path d="M15.25 4H12V2.75C12 1.7852 11.2148 1 10.25 1H7.75C6.7852 1 6 1.7852 6 2.75V4H2.75C2.3359 4 2 4.3359 2 4.75C2 5.1641 2.3359 5.5 2.75 5.5H15.25C15.6641 5.5 16 5.1641 16 4.75C16 4.3359 15.6641 4 15.25 4ZM7.5 2.75C7.5 2.6143 7.6143 2.5 7.75 2.5H10.25C10.3857 2.5 10.5 2.6143 10.5 2.75V4H7.5V2.75Z"></path>
</g>
</svg>
Delete
</button>
</DialogTrigger>
<DialogContent className="bg-[#1B1D21] border-white/10 text-white">
<DialogHeader>
<DialogTitle>Delete Selected Rules</DialogTitle>
<DialogDescription className="text-gray-400">
Are you sure you want to delete {selectedRules.size} rule{selectedRules.size !== 1 ? 's' : ''}? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<DialogTrigger asChild>
<Button variant="secondary" size="sm">
Cancel
</Button>
</DialogTrigger>
<DialogTrigger asChild>
<Button
variant="primary"
size="sm"
className="bg-red-600 hover:bg-red-700 text-white"
onClick={handleDeleteSelected}
>
Delete {selectedRules.size} Rule{selectedRules.size !== 1 ? 's' : ''}
</Button>
</DialogTrigger>
</DialogFooter>
</DialogContent>
</Dialog>
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

Dialog closing is broken: use DialogClose (not DialogTrigger) inside the dialog.

Cancel/Delete buttons won’t close reliably; DialogTrigger is for opening. Replace with DialogClose.

-                      <DialogFooter className="gap-2">
-                        <DialogTrigger asChild>
+                      <DialogFooter className="gap-2">
+                        <DialogClose asChild>
                           <Button variant="secondary" size="sm">
                             Cancel
                           </Button>
-                        </DialogTrigger>
-                        <DialogTrigger asChild>
+                        </DialogClose>
+                        <DialogClose asChild>
                           <Button 
                             variant="primary" 
                             size="sm"
                             className="bg-red-600 hover:bg-red-700 text-white"
                             onClick={handleDeleteSelected}
                           >
                             Delete {selectedRules.size} Rule{selectedRules.size !== 1 ? 's' : ''}
                           </Button>
-                        </DialogTrigger>
+                        </DialogClose>

Add import (outside this hunk):

// at the dialog import line
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, DialogClose } from "@/components/ui/dialog"
🤖 Prompt for AI Agents
In app/dashboard/page.tsx around lines 556 to 604, the dialog uses DialogTrigger
for the Cancel and Delete buttons inside the dialog which is incorrect; replace
those inner DialogTrigger usages with DialogClose (using asChild where
appropriate) so the buttons close the dialog reliably, keep the Delete button's
onClick handler intact, and add DialogClose to the dialog imports at the top
(import alongside the other Dialog components).

"use client"

import { useEffect, useState } from "react"
import { useEffect, useState, useCallback } from "react"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Avoid stale updates: add AbortController to debounced fetches

Without cancellation, slower prior requests can overwrite newer results. Use a shared AbortController.

-import { useEffect, useState, useCallback } from "react"
+import { useEffect, useState, useCallback, useRef } from "react"
   const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
+  const abortRef = useRef<AbortController | null>(null)
   useEffect(() => {
     const fetchRules = async () => {
-      setLoading(true)
+      setLoading(true)
+      // cancel previous in-flight fetches
+      abortRef.current?.abort()
+      const controller = new AbortController()
+      abortRef.current = controller
       try {
@@
-        const [hotResponse, newResponse] = await Promise.all([
-          fetch(hotUrl),
-          fetch(newUrl)
-        ])
+        const [hotResponse, newResponse] = await Promise.all([
+          fetch(hotUrl, { signal: controller.signal }),
+          fetch(newUrl, { signal: controller.signal })
+        ])
@@
     }
 
     fetchRules()
-  }, [debouncedSearchQuery])
+    return () => abortRef.current?.abort()
+  }, [debouncedSearchQuery])
📝 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
import { useEffect, useState, useCallback } from "react"
import { useEffect, useState, useCallback, useRef } from "react"
function FeedPage() {
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
const fetchRules = async () => {
setLoading(true)
// cancel any previous in-flight fetches
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
try {
const [hotResponse, newResponse] = await Promise.all([
fetch(hotUrl, { signal: controller.signal }),
fetch(newUrl, { signal: controller.signal })
])
// ...process responses...
} catch (error) {
if ((error as Error).name === 'AbortError') {
// request was cancelled; safe to ignore
} else {
// handle other errors
}
} finally {
setLoading(false)
}
}
fetchRules()
return () => {
// cleanup on unmount or before next effect run
abortRef.current?.abort()
}
}, [debouncedSearchQuery])
// ...rest of component...
}
🤖 Prompt for AI Agents
In app/feed/page.tsx around line 3, the debounced fetches can cause stale
responses to overwrite newer results; introduce a shared AbortController so each
new debounced request aborts the previous one and pass its signal into fetch
calls, ensure you call controller.abort() before starting a new request, catch
and ignore abort errors (or check error.name === 'AbortError'), and clean up by
aborting the controller in the component unmount/useEffect cleanup to prevent
memory leaks.

- Consolidated search logic into a single where condition for improved readability and maintainability.
- Enhanced search capabilities to filter rules by title, content, or author while ensuring only public rules are returned.
- Removed redundant code for search filtering, streamlining the query construction process.
@csark0812
Copy link
Copy Markdown
Author

Screen.Recording.2025-09-02.at.7.08.41.PM.mov

@R44VC0RP
Copy link
Copy Markdown
Owner

R44VC0RP commented Sep 3, 2025

okay @csark0812 this is pretty nice 😁

@csark0812
Copy link
Copy Markdown
Author

lol glad you think so

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: 0

♻️ Duplicate comments (1)
app/api/feed/hot/route.ts (1)

4-4: Public filter fix looks good; resolves prior leakage.

Importing and() and using it below properly constrains searches to isPublic = true. This addresses the earlier OR-based exposure flagged in prior reviews.

🧹 Nitpick comments (3)
app/api/feed/hot/route.ts (3)

11-26: Type the where condition, drop the non-null assertion, and harden the search term.

  • Remove the unnecessary non-null assertion (!) on and(...).
  • Type whereCondition to the Drizzle SQL type to satisfy TS.
  • Cap input length and escape LIKE wildcards (% and _) to avoid pathological scans and ensure literal substring semantics.

Apply:

-    // Build where condition
-    let whereCondition
+    // Build where condition
+    let whereCondition: SQL<unknown>
@@
-    if (searchQuery && searchQuery.trim()) {
-      const searchTerm = `%${searchQuery.trim()}%`
-      whereCondition = and(
+    if (searchQuery && searchQuery.trim()) {
+      const raw = searchQuery.trim().slice(0, 200)
+      const escaped = raw.replace(/[%_\\]/g, "\\$&")
+      const searchTerm = `%${escaped}%`
+      whereCondition = and(
         eq(cursorRule.isPublic, true),
         or(
           ilike(cursorRule.title, searchTerm),
           ilike(cursorRule.content, searchTerm),
           ilike(user.name, searchTerm)
         )
-      )!
+      )
     } else {
       whereCondition = eq(cursorRule.isPublic, true)
     }

Add the type import near the top of the file:

 import { eq, desc, or, ilike, and } from "drizzle-orm"
+import type { SQL } from "drizzle-orm"

28-28: Prefer const for the query builder variable.

It’s not reassigned.

-    let query = db
+    const query = db

45-48: Add supporting indexes for common paths (no-search and search).

  • No-search path: WHERE isPublic = true ORDER BY views DESC, createdAt DESC LIMIT 50 benefits from a composite index.
  • Search path with ILIKE will scan; consider pg_trgm GIN indexes to keep it responsive.

Example PostgreSQL migrations:

-- For hot (no-search)
CREATE INDEX IF NOT EXISTS idx_cursor_rule_public_views_createdat
  ON cursor_rule ( "isPublic", "views" DESC, "createdAt" DESC );

-- Enable trigram and add search indexes
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX IF NOT EXISTS idx_cursor_rule_title_trgm
  ON cursor_rule USING gin ("title" gin_trgm_ops);

CREATE INDEX IF NOT EXISTS idx_cursor_rule_content_trgm
  ON cursor_rule USING gin ("content" gin_trgm_ops);

CREATE INDEX IF NOT EXISTS idx_user_name_trgm
  ON "user" USING gin ("name" gin_trgm_ops);
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 2d0acfc and 3418a38.

📒 Files selected for processing (2)
  • app/api/feed/hot/route.ts (2 hunks)
  • app/api/feed/new/route.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/api/feed/new/route.ts
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/better-auth-device-authorization.mdc)

When calling device.token, set grant_type to "urn:ietf:params:oauth:grant-type:device_code"

Files:

  • app/api/feed/hot/route.ts
🧬 Code graph analysis (1)
app/api/feed/hot/route.ts (3)
app/api/feed/new/route.ts (1)
  • GET (6-55)
lib/schema.ts (2)
  • cursorRule (49-59)
  • user (3-11)
lib/db.ts (1)
  • db (10-10)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Vade Review
🔇 Additional comments (1)
app/api/feed/hot/route.ts (1)

8-10: Query param handling is fine.

Correctly derives q from the request URL.

Comment on lines +223 to +224
const results = await Promise.allSettled(promises)
const successful = results.filter(result => result.status === 'fulfilled').length
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The batch delete and add-to-list operations incorrectly count successful HTTP requests by checking Promise fulfillment status instead of actual HTTP response status codes.

View Details
📝 Patch Details
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
index 971c6db..e9b7d76 100644
--- a/app/dashboard/page.tsx
+++ b/app/dashboard/page.tsx
@@ -186,7 +186,9 @@ export default function DashboardPage() {
       )
 
       const results = await Promise.allSettled(promises)
-      const successful = results.filter(result => result.status === 'fulfilled').length
+      const successful = results.filter(result => 
+        result.status === 'fulfilled' && result.value.ok
+      ).length
       const failed = results.length - successful
 
       if (successful > 0) {
@@ -221,15 +223,29 @@ export default function DashboardPage() {
       )
 
       const results = await Promise.allSettled(promises)
-      const successful = results.filter(result => result.status === 'fulfilled').length
+      const successful = results.filter(result => 
+        result.status === 'fulfilled' && result.value.ok
+      ).length
+      const failed = selectedRules.size - successful
 
       if (successful > 0) {
         // Remove successfully deleted rules from local state
-        setRules(prevRules => prevRules.filter(rule => !selectedRules.has(rule.id)))
+        // Only remove rules that were actually deleted (successful requests)
+        const successfulRuleIds = new Set()
+        results.forEach((result, index) => {
+          if (result.status === 'fulfilled' && result.value.ok) {
+            const ruleId = Array.from(selectedRules)[index]
+            successfulRuleIds.add(ruleId)
+          }
+        })
+        setRules(prevRules => prevRules.filter(rule => !successfulRuleIds.has(rule.id)))
         toast.success(`Deleted ${successful} rule${successful !== 1 ? 's' : ''}!`)
         track("Rules Deleted", { count: successful })
         clearSelection()
       }
+      if (failed > 0) {
+        toast.error(`Failed to delete ${failed} rule${failed !== 1 ? 's' : ''}`)
+      }
     } catch (error) {
       console.error('Error deleting rules:', error)
       toast.error('Failed to delete rules')

Analysis

HTTP Error Response Counting Bug in Batch Operations

Bug Summary

The batch delete and add-to-list operations in the dashboard incorrectly count HTTP error responses (404, 500, etc.) as successful operations due to improper use of Promise.allSettled() with the fetch API.

How the Bug Manifests

When users perform batch operations (deleting multiple rules or adding multiple rules to lists), the system shows misleading success messages and incorrectly updates local state even when some or all HTTP requests fail with error status codes.

Example scenario:

  • User selects 5 rules to delete
  • 2 deletions return HTTP 200 (success)
  • 3 deletions return HTTP 404 (not found)
  • Current buggy behavior: Shows "Deleted 5 rules!" and removes all 5 from UI
  • Correct behavior: Should show "Deleted 2 rules!" and "Failed to delete 3 rules"

Root Cause Analysis

The fetch API has a specific behavior that differs from other promise-based APIs: fetch only rejects on network errors, not HTTP error status codes. According to the MDN documentation, "A fetch() promise does not reject if the server responds with HTTP status codes that indicate errors (404, 504, etc.). Instead, a then() handler must check the Response.ok and/or Response.status properties."

The buggy code uses this pattern:

const results = await Promise.allSettled(promises)
const successful = results.filter(result => result.status === 'fulfilled').length

This counts all resolved fetch promises as successful, including those that returned HTTP error codes like 404 or 500.

Impact Assessment

User Experience Impact:

  • Users see false success messages
  • UI state becomes inconsistent with server state (showing deleted items that weren't actually deleted)
  • Users may not realize operations failed and won't retry
  • Leads to confusion and potential data integrity issues

Technical Impact:

  • Local state diverges from server state
  • No proper error handling or retry mechanisms for failed requests
  • Potential for silent failures in critical operations

Technical Validation

Testing confirmed the bug exists in two locations:

  1. handleAddSelectedToList (line 188-189): Batch adding rules to lists
  2. handleDeleteSelected (line 223-224): Batch deleting rules

A simulation with mixed HTTP status codes (200, 404, 500, 201) showed the current code reports 4/4 successes while the correct count is 2/4 successes - a 50% overcount rate.

Solution Implemented

The fix checks both promise fulfillment AND HTTP response status:

const successful = results.filter(result => 
  result.status === 'fulfilled' && result.value.ok
).length

Additionally, for delete operations, the fix ensures only actually deleted items are removed from local state by tracking which specific requests succeeded.

Additional Resources:

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.

2 participants