-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ui): P2.6 bookmarklet — save any page to Recall from any browser #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
8d7aec1
f479e1d
5cb10c5
62aa052
f06c1d2
d7ccfe5
af17520
5241c61
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential hydration mismatch with Accessing 🛠️ 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </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> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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> | ||
| ); | ||
|
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> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: MinitJain/recall
Length of output: 669
🏁 Script executed:
cat -n client/src/app/api/bookmarks/route.ts | head -100Repository: MinitJain/recall
Length of output: 3737
🏁 Script executed:
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.tscatches 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.openeris 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