Skip to content
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@ Read `GUIDE.md` first — it contains the full project briefing and teaching rul
| P2.3 Collections (folders) | ✅ Done |
| P2.4 AI auto-tagging via Gemini | ✅ Done |
| P2.5 Text search UI improvements | ✅ Done |
| P2.6 Bookmarklet | ⬜ Not started |
| P2.6 Bookmarklet | ✅ Done |
| P2.7 Daily digest email (resurfacing V1) | ⬜ Not started |

**TODO after Chrome Web Store publish:**
- Add "Add to Chrome" button to the "Save without leaving the page" section on landing page (`src/app/page.tsx` — Extension card)

**P2.7 Daily Digest — implementation plan**
- Vercel cron job hits `/api/digest` daily
- Picks 5 oldest unvisited bookmarks per user
Expand Down
81 changes: 41 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,31 +24,32 @@ No manual work. No lost tabs. No graveyard of forgotten links.

## Features

| | Feature | Description | Status |
| -- | ------- | ----------- | ------ |
| 🔖 | **Save anything** | Any URL works. Tweet, blog post, YouTube video, image, Reddit thread, product page. | Live |
| 🤖 | **AI auto-tagging** | The moment you save, Gemini generates 3–5 relevant tags automatically. Not satisfied? You can always add or remove tags manually too. | Live |
| 🖼️ | **Rich previews** | Every bookmark shows title, description, and thumbnail fetched automatically from OG tags. | Live |
| 🧩 | **Chrome extension** | Save any tab in one click or with `Alt+Shift+S` (Windows/Linux) / `Option+Shift+S` (Mac). No copy-paste needed. | Live |
| 🔐 | **Secure auth** | Email/password + Google + GitHub via Supabase Auth. | Live |
| ⚡ | **Rate limiting** | Per-user rate limiting via Upstash Redis to keep the service stable. | Live |
| 🔍 | **Instant search** | Search by title, URL, or description. Filter by tag. Sort by newest, oldest, or A→Z. | Live |
| 📁 | **Collections** | Group bookmarks into folders manually, or let AI suggest groupings based on your saves. | Coming soon |
| ✨ | **Resurfacing** | Daily digest, random rediscovery, and "you saved this a year ago". Recall brings things back to you. | Coming soon |
| | Feature | Description | Status |
| --- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| 🔖 | **Save anything** | Any URL works. Tweet, blog post, YouTube video, image, Reddit thread, product page. | Live |
| 🤖 | **AI auto-tagging** | The moment you save, Gemini generates 3–5 relevant tags automatically. Not satisfied? You can always add or remove tags manually too. | Live |
| 🖼️ | **Rich previews** | Every bookmark shows title, description, and thumbnail fetched automatically from OG tags. | Live |
| 🧩 | **Chrome extension** | Save any tab in one click or with `Alt+Shift+S` (Windows/Linux) / `Option+Shift+S` (Mac). No copy-paste needed. | Live |
| 🔐 | **Secure auth** | Email/password + Google + GitHub via Supabase Auth. | Live |
| ⚡ | **Rate limiting** | Per-user rate limiting via Upstash Redis to keep the service stable. | Live |
| 🔍 | **Instant search** | Search by title, URL, or description. Filter by tag. Sort by newest, oldest, or A→Z. | Live |
| 📁 | **Collections** | Group bookmarks into folders. Filter your saves by collection. | Live |
| 🔗 | **Bookmarklet** | No extension? Drag a link to your bookmarks bar — save any page from any browser in one click. | Live |
| ✨ | **Resurfacing** | Daily digest, random rediscovery, and "you saved this a year ago". Recall brings things back to you. | Coming soon |

## Tech Stack

| Layer | Technology |
| ----- | ---------- |
| Frontend + API | Next.js 16 (App Router) |
| Styling | Tailwind CSS v4 |
| Database | PostgreSQL via Supabase |
| ORM | Prisma 7 |
| AI Tagging | Google Gemini API |
| Auth | Supabase Auth |
| Deployment | Vercel |
| Rate Limiting | Upstash Redis |
| Analytics | Vercel Analytics + Speed Insights |
| Layer | Technology |
| -------------- | --------------------------------- |
| Frontend + API | Next.js 16 (App Router) |
| Styling | Tailwind CSS v4 |
| Database | PostgreSQL via Supabase |
| ORM | Prisma 7 |
| AI Tagging | Google Gemini API |
| Auth | Supabase Auth |
| Deployment | Vercel |
| Rate Limiting | Upstash Redis |
| Analytics | Vercel Analytics + Speed Insights |

## Getting Started

Expand Down Expand Up @@ -145,10 +146,10 @@ Recall will appear in your extensions list with the bookmark icon. Click the puz

### Keyboard shortcut

| Shortcut | Action |
| -------- | ------ |
| Shortcut | Action |
| ------------------------------- | ----------------------------------------------- |
| `Alt+Shift+S` (Windows / Linux) | Save the current tab instantly, no popup needed |
| `Option+Shift+S` (Mac) | Save the current tab instantly, no popup needed |
| `Option+Shift+S` (Mac) | Save the current tab instantly, no popup needed |

> Remap it at `chrome://extensions/shortcuts`.

Expand Down Expand Up @@ -183,21 +184,21 @@ When you search, Recall looks across titles, descriptions, and tags simultaneous

## Roadmap

| Status | Item |
| ------ | ---- |
| ✅ | Chrome extension (save, popup, keyboard shortcut) |
| ✅ | AI auto-tagging via Gemini |
| ✅ | Vercel deployment at recallsave.vercel.app |
| ✅ | OAuth (Google + GitHub) |
| ✅ | User dashboard (web UI) |
| ✅ | Admin dashboard |
| | Collections UI |
| ⬜ | Natural language / semantic search |
| ⬜ | Resurfacing (daily digest, random rediscovery, time capsule) |
| | Bookmarklet (no extension required) |
| ⬜ | Chrome Web Store listing |
| ⬜ | D3.js knowledge graph |
| ⬜ | Background queue for async AI tagging |
| Status | Item |
| ------ | ------------------------------------------------------------ |
| ✅ | Chrome extension (save, popup, keyboard shortcut) |
| ✅ | AI auto-tagging via Gemini |
| ✅ | Vercel deployment at recallsave.vercel.app |
| ✅ | OAuth (Google + GitHub) |
| ✅ | User dashboard (web UI) |
| ✅ | Admin dashboard |
| | Collections UI |
| ⬜ | Natural language / semantic search |
| ⬜ | Resurfacing (daily digest, random rediscovery, time capsule) |
| | Bookmarklet (no extension required) |
| ⬜ | Chrome Web Store listing |
| ⬜ | D3.js knowledge graph |
| ⬜ | Background queue for async AI tagging |

## Contributing

Expand Down
86 changes: 86 additions & 0 deletions client/src/app/bookmarklet/BookmarkletSaver.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"use client";

import { useEffect, useState } from "react";

export default function BookmarkletSaver({ url }: { url: string }) {
const [status, setStatus] = useState<"saving" | "saved" | "duplicate" | "error">("saving");
const [errorMsg, setErrorMsg] = useState<string | null>(null);

useEffect(() => {
async function save() {
try {
const res = await fetch("/api/bookmarks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
});
if (res.status === 409) {
setStatus("duplicate");
Comment on lines +17 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check Prisma schema for unique constraint on Bookmark model
fd -t f 'schema.prisma' --exec cat {} | grep -A 30 'model Bookmark'

Repository: MinitJain/recall

Length of output: 669


🏁 Script executed:

cat -n client/src/app/api/bookmarks/route.ts | head -100

Repository: MinitJain/recall

Length of output: 3737


🏁 Script executed:

cat -n client/src/app/bookmarklet/BookmarkletSaver.tsx

Repository: MinitJain/recall

Length of output: 4284


Remove dead code: 409 duplicate detection is unreachable.

Lines 17-18 will never execute. The API route at client/src/app/api/bookmarks/route.ts catches all errors from the Prisma create operation (line 70) and returns status 500 with no distinction for duplicate violations. Additionally, the Bookmark schema has no unique constraint on (userId, url), so duplicates are silently accepted as separate bookmarks.

Either add a unique constraint to the Bookmark model, update the API route to detect and return 409 for duplicates, or remove this dead code path.

Also fix hydration mismatch on line 52: window.opener is accessed during render. This will differ between server and client. Move this logic into a client-side effect or use a state that initializes only on the client.

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

In `@client/src/app/bookmarklet/BookmarkletSaver.tsx` around lines 17 - 18, The
duplicate-detection branch in BookmarkletSaver.tsx (the if (res.status === 409)
{ setStatus("duplicate"); } path) is unreachable because the API route always
returns 500 and the Bookmark model has no unique constraint; either remove this
dead branch or implement a proper duplicate flow by adding a unique constraint
on the Bookmark model (userId + url) and updating the API handler to detect
Prisma unique-constraint errors and return 409 so setStatus("duplicate") becomes
reachable; additionally move the window.opener access (currently read during
render around line referencing window.opener) into a client-only effect
(useEffect) or initialize it into state on mount to avoid SSR/client hydration
mismatch.

} else if (res.ok) {
setStatus("saved");
setTimeout(() => window.close(), 1500);
} else {
const data = await res.json().catch(() => ({}));
setErrorMsg(data.error ?? "Failed to save");
setStatus("error");
}
} catch {
setErrorMsg("Network error — are you online?");
setStatus("error");
}
}
save();
}, [url]);

return (
<div className="flex flex-col items-center justify-center gap-3 text-center px-6">
{status === "saving" && (
<>
<div className="w-8 h-8 rounded-full border-2 border-[var(--accent)] border-t-transparent animate-spin" />
<p className="text-sm text-[var(--text-muted)]">Saving...</p>
</>
)}
{status === "saved" && (
<>
<div className="w-10 h-10 rounded-full bg-[var(--accent-soft)] flex items-center justify-center">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="text-[var(--accent)]">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<p className="text-sm font-medium text-[var(--text)]">Saved to Recall</p>
<p className="text-xs text-[var(--text-dim)]">
{window.opener ? "This window will close..." : (
<a href="/app" className="text-[var(--accent)] hover:underline">View your bookmarks</a>
)}
Comment on lines +52 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential hydration mismatch with window.opener check.

Accessing window.opener during render can cause a hydration mismatch since window is undefined during SSR. Consider moving this check into a useEffect or using a state variable initialized client-side.

🛠️ Suggested fix using state
 export default function BookmarkletSaver({ url }: { url: string }) {
   const [status, setStatus] = useState<"saving" | "saved" | "duplicate" | "error">("saving");
   const [errorMsg, setErrorMsg] = useState<string | null>(null);
+  const [isPopup, setIsPopup] = useState(false);
+
+  useEffect(() => {
+    setIsPopup(!!window.opener);
+  }, []);

   useEffect(() => {
     // ... existing save logic
   }, [url]);

   // In the JSX:
   <p className="text-xs text-[var(--text-dim)]">
-    {window.opener ? "This window will close..." : (
+    {isPopup ? "This window will close..." : (
       <a href="/app" className="text-[var(--accent)] hover:underline">View your bookmarks</a>
     )}
   </p>
📝 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
{window.opener ? "This window will close..." : (
<a href="/app" className="text-[var(--accent)] hover:underline">View your bookmarks</a>
)}
export default function BookmarkletSaver({ url }: { url: string }) {
const [status, setStatus] = useState<"saving" | "saved" | "duplicate" | "error">("saving");
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [isPopup, setIsPopup] = useState(false);
useEffect(() => {
setIsPopup(!!window.opener);
}, []);
useEffect(() => {
// ... existing save logic
}, [url]);
// ... rest of component
return (
<div>
{/* ... */}
<p className="text-xs text-[var(--text-dim)]">
{isPopup ? "This window will close..." : (
<a href="/app" className="text-[var(--accent)] hover:underline">View your bookmarks</a>
)}
</p>
</div>
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/app/bookmarklet/BookmarkletSaver.tsx` around lines 52 - 54, The
render reads window.opener causing potential SSR hydration mismatch in
BookmarkletSaver (client/src/app/bookmarklet/BookmarkletSaver.tsx); change it to
use a client-only state: add a boolean state like hasOpener initialized false
and set it inside useEffect by reading window.opener, then render based on that
state instead of directly accessing window.opener; update any JSX that currently
uses window.opener to use hasOpener so the initial server render matches the
client.

</p>
</>
)}
{status === "duplicate" && (
<>
<div className="w-10 h-10 rounded-full bg-[var(--surface-2)] flex items-center justify-center">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-[var(--text-muted)]">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<p className="text-sm font-medium text-[var(--text)]">Already saved</p>
<p className="text-xs text-[var(--text-dim)]">This URL is already in your Recall.</p>
</>
)}
{status === "error" && (
<>
<div className="w-10 h-10 rounded-full bg-[var(--error-bg)] flex items-center justify-center">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-[var(--error)]">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<p className="text-sm font-medium text-[var(--text)]">Could not save</p>
<p className="text-xs text-[var(--error)]">{errorMsg}</p>
</>
)}
</div>
);
}
82 changes: 82 additions & 0 deletions client/src/app/bookmarklet/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import Image from "next/image";
import { createClient } from "@/lib/supabase/server";
import BookmarkletSaver from "./BookmarkletSaver";

export const metadata = { title: "Save to Recall" };

export default async function BookmarkletPage({
searchParams,
}: {
searchParams: Promise<{ url?: string }>;
}) {
const { url } = await searchParams;

const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();

const Logo = () => (
<div className="flex items-center gap-2 mb-6">
<Image src="/logo.svg" alt="Recall" width={24} height={24} />
<span className="text-sm font-semibold text-[var(--text)]">Recall</span>
</div>
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

if (!user) {
return (
<div className="min-h-screen bg-[var(--bg)] flex items-center justify-center">
<div className="flex flex-col items-center text-center px-6">
<Logo />
<p className="text-sm font-medium text-[var(--text)] mb-1">Not logged in</p>
<p className="text-xs text-[var(--text-muted)] mb-4">Log in to Recall first, then click the bookmarklet again.</p>
<a
href="/auth"
target="_blank"
rel="noopener noreferrer"
className="text-xs px-4 py-2 rounded-lg bg-[var(--accent)] text-[var(--accent-text)] hover:opacity-90 transition-opacity"
>
Log in
</a>
</div>
</div>
);
}

// Validate URL param
let validUrl: string | null = null;
if (url) {
try {
const parsed = new URL(url);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
validUrl = url;
}
} catch {
// invalid
}
}

if (!validUrl) {
return (
<div className="min-h-screen bg-[var(--bg)] flex items-center justify-center">
<div className="flex flex-col items-center text-center px-6">
<Logo />
<p className="text-sm font-medium text-[var(--text)] mb-1">Invalid URL</p>
<p className="text-xs text-[var(--text-muted)]">The bookmarklet did not pass a valid URL.</p>
</div>
</div>
);
}

return (
<div className="min-h-screen bg-[var(--bg)] flex items-center justify-center">
<div className="flex flex-col items-center w-full max-w-xs">
<Logo />
<p className="text-xs text-[var(--text-dim)] mb-6 truncate max-w-[240px]" title={validUrl}>
{validUrl}
</p>
<BookmarkletSaver url={validUrl} />
</div>
</div>
);
}
66 changes: 66 additions & 0 deletions client/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,72 @@ if (user) redirect("/app");
</div>
</section>

{/* ── SAVE FROM ANYWHERE ─────────────────────────────────────────── */}
<section style={{ padding: "100px 24px", background: "var(--lp-bg-primary)" }}>
<div style={{ maxWidth: 1100, margin: "0 auto" }}>
<div data-animate style={{ textAlign: "center", marginBottom: 56 }}>
<h2 className="font-display" style={{ fontSize: "clamp(34px, 5.5vw, 58px)", fontWeight: 800, color: "var(--lp-text-primary)", margin: "0 0 16px" }}>
Save without leaving the page
</h2>
<p style={{ fontSize: "clamp(15px, 2vw, 18px)", color: "var(--lp-text-secondary)", margin: 0 }}>
Three ways to save - pick what works for you.
</p>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 20 }} className="steps-grid">
{/* Web app */}
<div data-animate style={{
background: "var(--lp-bg-card)", border: "1px solid var(--lp-border)",
borderRadius: 20, padding: "32px 28px", display: "flex", flexDirection: "column", gap: 12,
}}>
<span style={{ fontSize: 28 }}>🌐</span>
<h3 style={{ fontSize: 16, fontWeight: 700, color: "var(--lp-text-primary)", margin: 0 }}>Web app</h3>
<p style={{ fontSize: 14, color: "var(--lp-text-secondary)", margin: 0, lineHeight: 1.6 }}>
Paste any URL directly into Recall. Works on every device and browser - no install needed.
</p>
</div>
{/* Extension */}
<div data-animate style={{
background: "var(--lp-bg-card)", border: "1px solid var(--lp-border)",
borderRadius: 20, padding: "32px 28px", display: "flex", flexDirection: "column", gap: 12,
}}>
<span style={{ fontSize: 28 }}>🧩</span>
<h3 style={{ fontSize: 16, fontWeight: 700, color: "var(--lp-text-primary)", margin: 0 }}>Browser extension</h3>
<p style={{ fontSize: 14, color: "var(--lp-text-secondary)", margin: 0, lineHeight: 1.6 }}>
Save any tab in one click - no copy-pasting. Works on Chrome, Brave, Edge, Arc, Perplexity, and all Chromium browsers.
</p>
</div>
{/* Bookmarklet */}
<div data-animate style={{
background: "var(--lp-bg-card)", border: "1px solid var(--lp-border)",
borderRadius: 20, padding: "32px 28px", display: "flex", flexDirection: "column", gap: 12,
}}>
<span style={{ fontSize: 28 }}>🔖</span>
<h3 style={{ fontSize: 16, fontWeight: 700, color: "var(--lp-text-primary)", margin: 0 }}>Bookmarklet</h3>
<p style={{ fontSize: 14, color: "var(--lp-text-secondary)", margin: 0, lineHeight: 1.6 }}>
On Safari, Firefox, or any browser? Drag this link to your bookmarks bar once - then save any page with one click, forever.
</p>
{/* eslint-disable-next-line no-script-url */}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
<a
href={`javascript:(function(){window.open('https://recallsave.vercel.app/bookmarklet?url='+encodeURIComponent(location.href),'recall-save','width=400,height=220,toolbar=0,menubar=0,location=0')})();`}
onClick={(e) => e.preventDefault()}
draggable
style={{
marginTop: 4, display: "inline-flex", alignItems: "center", gap: 6,
fontSize: 13, fontWeight: 600, color: "var(--lp-accent)",
background: "var(--lp-accent-soft)", borderRadius: 10,
padding: "8px 14px", cursor: "grab", userSelect: "none",
border: "1px dashed var(--lp-accent)", textDecoration: "none",
alignSelf: "flex-start",
}}
title="Drag this to your bookmarks bar"
>
← drag to bookmarks bar
</a>
</div>
</div>
</div>
</section>

{/* ── RESURFACING CALLOUT ────────────────────────────────────────── */}
<section style={{
padding: "100px 24px",
Expand Down
18 changes: 18 additions & 0 deletions client/src/components/SaveUrlForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,24 @@ export default function SaveUrlForm() {
</button>
</form>
{error && <p className="text-xs text-[var(--error)] mt-2">{error}</p>}

{/* Bookmarklet install strip */}
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-[var(--border)]">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-[var(--text-dim)] flex-shrink-0">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
<span className="text-xs text-[var(--text-dim)]">Save from any page —</span>
{/* eslint-disable-next-line no-script-url */}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
<a
href={`javascript:(function(){window.open('https://recallsave.vercel.app/bookmarklet?url='+encodeURIComponent(location.href),'recall-save','width=400,height=220,toolbar=0,menubar=0,location=0')})();`}
onClick={(e) => e.preventDefault()}
draggable
className="text-xs text-[var(--accent)] hover:underline cursor-grab active:cursor-grabbing select-none"
title="Drag this to your bookmarks bar"
>
drag to bookmarks bar
</a>
</div>
</div>
);
}
Loading