diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..34ea63c --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Database (production) +DATABASE_URL="postgresql://prod_user:password@host/database" + +# Email Service +INBOUND_API_KEY="your_production_api_key" + +# 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" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 37c2b6f..0742840 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ yarn-error.log* # env files .env* +!.env.example # vercel .vercel diff --git a/README.md b/README.md index 264563f..dfc1773 100644 --- a/README.md +++ b/README.md @@ -13,28 +13,33 @@ cursor.link is a platform for creating, sharing, and discovering Cursor IDE rule - **🔗 Easy Sharing** - Share rules with unique URLs and public/private visibility - **📦 CLI Integration** - Install rules directly via `npx shadcn add` command - **👤 User Dashboard** - Manage all your rules in one place -- **🔍 Public Discovery** - Browse and discover community-shared rules +- **🔍 Public Discovery** - Browse and discover community-shared rules in Hot/New feeds +- **📱 CLI Tool** - Full-featured CLI for syncing rules between local and cloud +- **📋 Lists & Collections** - Organize rules into custom lists - **🎨 Modern UI** - Beautiful dark theme with Tailwind CSS and Radix components ## 🛠️ Tech Stack ### Frontend -- **Next.js 15** - App Router with React 19 -- **TypeScript** - Full type safety -- **Tailwind CSS v4** - Modern styling system +- **Next.js 15.2.4** - App Router with React 19 +- **TypeScript 5** - Full type safety +- **Tailwind CSS 4.1.9** - Modern styling system - **Radix UI** - Accessible component primitives - **React Hook Form + Zod** - Form handling and validation +- **Geist Font** - Modern typography ### Backend - **PostgreSQL** - Primary database (via Neon.tech) -- **Drizzle ORM** - Type-safe database queries -- **Better Auth** - Modern authentication with magic links -- **Inbound Email** - Transactional email service +- **Drizzle ORM 0.44.5** - Type-safe database queries +- **Better Auth 1.3.8-beta.9** - Modern authentication with magic links +- **Inbound Email 4.0.0** - Transactional email service ### Tools & Services - **Vercel** - Deployment and hosting -- **React Scan** - Performance monitoring -- **Sonner** - Toast notifications +- **React Scan 0.4.3** - Performance monitoring +- **Sonner 2.0.7** - Toast notifications +- **Vercel Analytics** - Usage analytics +- **GPT Tokenizer** - Token counting for rules ## 🚀 Getting Started @@ -70,21 +75,25 @@ BETTER_AUTH_URL="http://localhost:3000" 2. **Install dependencies** ```bash + # Using bun (recommended) + bun install + + # Or using npm npm install ``` 3. **Set up the database** ```bash # Generate migrations - npm run db:generate + bun run db:generate # Apply migrations - npm run db:migrate + bun run db:migrate ``` 4. **Start the development server** ```bash - npm run dev + bun run dev ``` 5. **Open your browser** @@ -99,16 +108,26 @@ cursor.link/ │ ├── api/ # API routes │ │ ├── auth/ # Authentication endpoints │ │ ├── cursor-rules/ # CRUD operations for rules +│ │ ├── feed/ # Hot/New feed endpoints +│ │ ├── lists/ # Lists management │ │ ├── my-rules/ # User's personal rules │ │ ├── public-rule/ # Public rule access │ │ └── registry/ # shadcn-style CLI registry │ ├── dashboard/ # User dashboard +│ ├── feed/ # Public feed/discovery page │ ├── login/ # Authentication pages │ └── page.tsx # Homepage/editor ├── components/ # Reusable UI components │ ├── auth/ # Authentication components +│ ├── dashboard/ # Dashboard components +│ ├── lists/ # Lists management components │ ├── ui/ # Base UI components │ └── header.tsx # Site header +├── cursor-link-cli/ # CLI tool package +│ ├── src/ # CLI source code +│ │ ├── commands/ # CLI commands (auth, push, pull, get) +│ │ └── utils/ # CLI utilities +│ └── package.json # CLI package configuration ├── lib/ # Shared utilities │ ├── auth.ts # Authentication configuration │ ├── db.ts # Database connection @@ -182,15 +201,72 @@ GET /api/my-rules ``` Get all rules belonging to the authenticated user. +### Feed & Discovery + +#### Hot Feed +```http +GET /api/feed/hot +GET /api/feed/hot?q=search_query +``` +Get popular rules sorted by views and engagement. + +#### New Feed +```http +GET /api/feed/new +GET /api/feed/new?q=search_query +``` +Get recently created rules. + +### Lists Management + +#### Get Lists +```http +GET /api/lists +``` +Get all lists for the authenticated user. + +#### Create List +```http +POST /api/lists +Content-Type: application/json + +{ + "title": "My List Name" +} +``` + +#### Add Rules to List +```http +POST /api/lists/[listId]/rules +Content-Type: application/json + +{ + "ruleIds": ["rule-id-1", "rule-id-2"] +} +``` + ## 🎯 Usage ### Creating Rules 1. **Visit the homepage** - Start creating immediately without login 2. **Choose rule type** - Select from Always Apply, Intelligent, File-specific, or Manual -3. **Write your rule** - Use the built-in editor with syntax highlighting +3. **Write your rule** - Use the built-in editor with syntax highlighting and token counting 4. **Save and share** - Login to save privately or share publicly +### Discovering Rules + +1. **Visit the Feed** - Browse popular and new rules from the community +2. **Search rules** - Use the search bar to find rules by title, content, or author +3. **View details** - Click on any rule to see full content and metadata +4. **Copy or download** - Use the action buttons to copy content or download as `.mdc` file + +### Organizing Rules + +1. **Create lists** - Organize your rules into custom collections +2. **Add to lists** - Select multiple rules and add them to lists +3. **Manage collections** - Edit, delete, and organize your lists from the dashboard + ### Rule Types - **Always Apply** - Applied to every chat and cmd-k session @@ -208,6 +284,82 @@ npx shadcn add https://cursor.link/api/registry/your-rule-id This installs the rule to `~/.cursor/rules/` automatically. +## 📱 CLI Tool + +cursor.link includes a powerful CLI tool for syncing rules between your local development environment and the cloud platform. + +### Installation + +Install the CLI globally: + +```bash +npm install -g cursor-link +# or +pnpm add -g cursor-link +# or run directly with npx +npx cursor-link --help +``` + +### Quick Start + +1. **Authenticate with cursor.link:** + ```bash + cursor-link auth login + ``` + This opens your browser for device authorization. + +2. **Push your local cursor rules:** + ```bash + cursor-link push + ``` + +3. **Pull rules from cursor.link:** + ```bash + cursor-link pull + ``` + +### Commands + +#### Authentication +- `cursor-link auth login` - Sign in using device authorization +- `cursor-link auth logout` - Sign out +- `cursor-link auth status` - Check authentication status + +#### Rule Management +- `cursor-link push [options]` - Push local cursor rules to cursor.link + - `--public` - Make rules public (default: private) + - `--force` - Overwrite existing rules without confirmation +- `cursor-link pull [options]` - Pull cursor rules from cursor.link + - `--list` - List available rules without downloading + - `--all` - Include public rules from other users (default: only your rules) +- `cursor-link get ` - Get a specific rule by slug or ID + +### How it Works + +#### Push Process +1. Scans your `.cursor/rules/` directory for `.mdc` files +2. Parses each file to extract title, content, and settings +3. Uploads rules to your cursor.link account +4. Handles conflicts by asking for your preference + +#### Pull Process +1. Fetches available rules from cursor.link +2. Shows an interactive selection interface +3. Downloads selected rules to `.cursor/rules/` +4. Preserves frontmatter settings like `alwaysApply` + +#### File Format +The CLI works with cursor rule files in the `.cursor/rules/` directory. Each file should be a `.mdc` file with this format: + +```markdown +--- +alwaysApply: true +--- +# My Rule Title + +Your rule content here... +``` + ## 🤝 Contributing We welcome contributions! Here's how to get started: diff --git a/app/api/feed/hot/route.ts b/app/api/feed/hot/route.ts index c4f488c..2703ed4 100644 --- a/app/api/feed/hot/route.ts +++ b/app/api/feed/hot/route.ts @@ -1,12 +1,31 @@ import { NextRequest, NextResponse } from "next/server" import { db } from "@/lib/db" import { cursorRule, user } from "@/lib/schema" -import { eq, desc } from "drizzle-orm" +import { eq, desc, or, ilike, and } from "drizzle-orm" export async function GET(request: NextRequest) { try { - // Fetch most viewed public rules - const rules = await db + const { searchParams } = new URL(request.url) + const searchQuery = searchParams.get('q') + + // Build where condition + let whereCondition + + if (searchQuery && searchQuery.trim()) { + const searchTerm = `%${searchQuery.trim()}%` + 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) + } + + let query = db .select({ id: cursorRule.id, title: cursorRule.title, @@ -21,7 +40,10 @@ export async function GET(request: NextRequest) { }) .from(cursorRule) .innerJoin(user, eq(cursorRule.userId, user.id)) - .where(eq(cursorRule.isPublic, true)) + .where(whereCondition) + + // Fetch most viewed public rules (with optional search) + const rules = await query .orderBy(desc(cursorRule.views), desc(cursorRule.createdAt)) .limit(50) diff --git a/app/api/feed/new/route.ts b/app/api/feed/new/route.ts index adbdf33..8ac4c18 100644 --- a/app/api/feed/new/route.ts +++ b/app/api/feed/new/route.ts @@ -1,12 +1,31 @@ import { NextRequest, NextResponse } from "next/server" import { db } from "@/lib/db" import { cursorRule, user } from "@/lib/schema" -import { eq, desc } from "drizzle-orm" +import { eq, desc, or, ilike, and } from "drizzle-orm" export async function GET(request: NextRequest) { try { - // Fetch newest public rules - const rules = await db + const { searchParams } = new URL(request.url) + const searchQuery = searchParams.get('q') + + // Build where condition + let whereCondition + + if (searchQuery && searchQuery.trim()) { + const searchTerm = `%${searchQuery.trim()}%` + 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) + } + + let query = db .select({ id: cursorRule.id, title: cursorRule.title, @@ -21,7 +40,10 @@ export async function GET(request: NextRequest) { }) .from(cursorRule) .innerJoin(user, eq(cursorRule.userId, user.id)) - .where(eq(cursorRule.isPublic, true)) + .where(whereCondition) + + // Fetch newest public rules (with optional search) + const rules = await query .orderBy(desc(cursorRule.createdAt)) .limit(50) diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 9aa5edd..971c6db 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -120,6 +120,10 @@ export default function DashboardPage() { const [nameInput, setNameInput] = useState("") const [showNamePrompt, setShowNamePrompt] = useState(false) const [isUpdatingName, setIsUpdatingName] = useState(false) + const [selectedRules, setSelectedRules] = useState>(new Set()) + const [showListDialog, setShowListDialog] = useState(false) + const [availableLists, setAvailableLists] = useState>([]) + const [isAddingToList, setIsAddingToList] = useState(false) // Helper function to show checkmark feedback const showActionFeedback = (actionKey: string) => { @@ -129,6 +133,109 @@ export default function DashboardPage() { }, 1500) } + // Helper functions for rule selection + const toggleRuleSelection = (ruleId: string) => { + setSelectedRules(prev => { + const newSet = new Set(prev) + if (newSet.has(ruleId)) { + newSet.delete(ruleId) + } else { + newSet.add(ruleId) + } + return newSet + }) + } + + const selectAllRules = () => { + setSelectedRules(new Set(rules.map(rule => rule.id))) + } + + const clearSelection = () => { + setSelectedRules(new Set()) + } + + const isRuleSelected = (ruleId: string) => { + return selectedRules.has(ruleId) + } + + // Fetch available lists for adding rules + 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 }))) + } + } catch (error) { + console.error('Error fetching lists:', error) + } + } + + // 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 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') + } + } + // Check if user needs to set their name const needsNameUpdate = (user: any) => { if (!user?.name) return true @@ -305,6 +412,7 @@ export default function DashboardPage() { if (session) { fetchRules() + fetchAvailableLists() } }, [session]) @@ -390,9 +498,129 @@ export default function DashboardPage() { {/* User Lists Section */}
-

Your Cursor Rules

-
- {rulesLoading ? "Loading..." : `${rules.length} rule${rules.length !== 1 ? 's' : ''}`} +
+

Your Cursor Rules

+
+ {rulesLoading ? "Loading..." : `${rules.length} rule${rules.length !== 1 ? 's' : ''}`} +
+
+
+ {!rulesLoading && rules.length > 0 && ( + <> + + + 0 ? 'text-gray-400' : 'text-gray-600'}`}> + {selectedRules.size} selected + + + + + + + + + + + Delete Selected Rules + + Are you sure you want to delete {selectedRules.size} rule{selectedRules.size !== 1 ? 's' : ''}? This action cannot be undone. + + + + + + + + + + + + + + + + )}
@@ -429,13 +657,25 @@ export default function DashboardPage() { {/* Left side - Rule info */}
- - compose-3 - - - - - +
@@ -666,6 +906,56 @@ export default function DashboardPage() { )}
+ + {/* List Selection Dialog */} + + + + Add Rules to List + + Select a list to add {selectedRules.size} rule{selectedRules.size !== 1 ? 's' : ''} to. + + +
+ {availableLists.length === 0 ? ( +
+

No lists available. Create a list first.

+
+ ) : ( + availableLists.map((list) => ( + + )) + )} +
+ + + +
+
) } diff --git a/app/feed/page.tsx b/app/feed/page.tsx index 790f1d8..df219ec 100644 --- a/app/feed/page.tsx +++ b/app/feed/page.tsx @@ -1,9 +1,10 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useState, useCallback } from "react" import { Header } from "@/components/header" import { Card } from "@/components/ui/card" -import { Eye, Clock } from "lucide-react" +import { Input } from "@/components/ui/input" +import { Eye, Clock, Search, X } from "lucide-react" import { toast } from "sonner" import { track } from "@vercel/analytics" import { AddToListButton } from "@/components/lists/add-to-list-button" @@ -114,6 +115,8 @@ export default function FeedPage() { const [newRules, setNewRules] = useState([]) const [loading, setLoading] = useState(true) const [actionStates, setActionStates] = useState<{[key: string]: boolean}>({}) + const [searchQuery, setSearchQuery] = useState('') + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('') // Helper function to show checkmark feedback const showActionFeedback = (actionKey: string) => { @@ -132,14 +135,31 @@ export default function FeedPage() { ) + // Debounce search query + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchQuery(searchQuery) + }, 300) + + return () => clearTimeout(timer) + }, [searchQuery]) + useEffect(() => { const fetchRules = async () => { setLoading(true) try { + // Build URLs with search query if present + const hotUrl = debouncedSearchQuery + ? `/api/feed/hot?q=${encodeURIComponent(debouncedSearchQuery)}` + : '/api/feed/hot' + const newUrl = debouncedSearchQuery + ? `/api/feed/new?q=${encodeURIComponent(debouncedSearchQuery)}` + : '/api/feed/new' + // Fetch both hot and new rules in parallel const [hotResponse, newResponse] = await Promise.all([ - fetch('/api/feed/hot'), - fetch('/api/feed/new') + fetch(hotUrl), + fetch(newUrl) ]) if (hotResponse.ok) { @@ -160,7 +180,7 @@ export default function FeedPage() { } fetchRules() - }, []) + }, [debouncedSearchQuery]) const handleCopyContent = async (rule: CursorRule) => { await navigator.clipboard.writeText(rule.content) @@ -205,6 +225,26 @@ export default function FeedPage() {

Discover popular and recently created cursor rules from the community

+ {/* Search Bar */} +
+ + 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]" + /> + {searchQuery && ( + + )} +
+ {/* Tabs */}
) : ( currentRules.map((rule, index) => ( diff --git a/components/auth/user-avatar.tsx b/components/auth/user-avatar.tsx index 6647470..2f68cb2 100644 --- a/components/auth/user-avatar.tsx +++ b/components/auth/user-avatar.tsx @@ -225,13 +225,7 @@ export function UserAvatar() { -
-
- - Dashboard - -
-
+
diff --git a/components/dashboard/user-lists.tsx b/components/dashboard/user-lists.tsx index e045783..dda7da5 100644 --- a/components/dashboard/user-lists.tsx +++ b/components/dashboard/user-lists.tsx @@ -94,6 +94,7 @@ export function UserLists({ onListsChange }: UserListsProps) { const [newListTitle, setNewListTitle] = useState("") const [isCreating, setIsCreating] = useState(false) const [actionStates, setActionStates] = useState<{[key: string]: boolean}>({}) + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) // Helper function to show checkmark feedback const showActionFeedback = (actionKey: string) => { @@ -161,6 +162,7 @@ export function UserLists({ onListsChange }: UserListsProps) { const newList = await response.json() setLists(prev => [newList, ...prev]) setNewListTitle("") + setIsCreateDialogOpen(false) // Close the dialog after successful creation toast.success(`Created list "${newListTitle}"!`) track("List Created", { listId: newList.id }) onListsChange?.() @@ -293,7 +295,7 @@ export function UserLists({ onListsChange }: UserListsProps) {
{loading ? "Loading..." : `${lists.length} list${lists.length !== 1 ? 's' : ''}`}
- + +
diff --git a/package.json b/package.json index 82dd128..d2cadbd 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "my-v0-project", + "name": "cursor-link", "version": "0.1.0", "private": true, "scripts": {