Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@ Read `GUIDE.md` first — it contains the full project briefing and teaching rul

## Current Milestone

**M7 (Auth) is complete.** Next milestone is **M8: Chrome Extension**.
**ui-overhaul branch is in progress** (landing page + smart `/` routing — not yet merged).

After M8 comes M9 (Vercel deployment + preview URLs on PRs).
Before M8 can start, two things must be done on this branch:
1. Remove debug `console.log` from `client/src/app/page.tsx` line 9
2. Fix the Supabase SSR session refresh loop — create `client/src/proxy.ts` (Next.js 16.2 uses `proxy.ts`, NOT `middleware.ts`) using the Supabase SSR proxy pattern so token refresh happens before server components run

After that: **M8: Chrome Extension**, then M9 (Vercel deployment).

## Project Structure

Expand Down
10 changes: 10 additions & 0 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@upstash/redis": "^1.37.0",
"@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0",
"lucide-react": "^1.7.0",
"next": "^16.2.1",
"open-graph-scraper": "^6.11.0",
"pg": "^8.20.0",
Expand Down
16 changes: 16 additions & 0 deletions client/public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions client/public/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions client/src/app/app/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import BookmarkCardSkeleton from "@/components/BookmarkCardSkeleton";

export default function Loading() {
return (
<div className="min-h-screen bg-[var(--bg)]">
{/* Header placeholder */}
<div className="h-12 border-b border-[var(--border)] bg-[var(--bg)]/80" />
<main className="max-w-2xl mx-auto pt-6 pb-16 px-4">
<div className="flex flex-col gap-3">
<BookmarkCardSkeleton />
<BookmarkCardSkeleton />
<BookmarkCardSkeleton />
</div>
</main>
</div>
);
}
78 changes: 78 additions & 0 deletions client/src/app/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Suspense } from "react";
import { prisma } from "@/lib/prisma";
import BookmarkCard from "@/components/BookmarkCard";
import BookmarkCardSkeleton from "@/components/BookmarkCardSkeleton";
import SaveUrlForm from "@/components/SaveUrlForm";
import Header from "@/components/Header";
import SearchBar from "@/components/SearchBar";
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import { Bookmark } from "lucide-react";

export const metadata = { title: "Your Bookmarks | Recall" };

async function BookmarkList({ userId }: { userId: string }) {
const bookmarks = await prisma.bookmark.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
include: { tags: true },
});
Comment on lines +15 to +19

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

Cap the initial bookmark query.

This loads every bookmark plus every tag for the user, and the route is refreshed again after mutations from SaveUrlForm and BookmarkCard. Once an account grows, /app becomes an unbounded DB query and HTML payload on every save/delete. Add pagination, cursoring, or at least a first-page limit here.

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

In `@client/src/app/app/page.tsx` around lines 15 - 19, The initial query using
prisma.bookmark.findMany that populates bookmarks (variable bookmarks) currently
fetches all user rows including tags; limit the result set by adding
pagination/cursoring (e.g., take/skip or take + cursor) or at minimum a
first-page limit (take: N) and return a nextCursor/token so the UI components
(SaveUrlForm, BookmarkCard) can request subsequent pages; update the server
handler that renders the /app page to accept page/cursor params and adjust any
client-side refreshes to use paged refetches rather than reloading the entire
bookmarks list.


if (bookmarks.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-[var(--border)] bg-[var(--surface)] text-center" style={{ padding: "48px 32px" }}>
<Bookmark size={40} className="text-[var(--accent)]" />
<div>
<h2 className="text-xl font-semibold text-[var(--text)] mb-1">Nothing saved yet</h2>
<p className="text-sm text-[var(--text-muted)]">Paste any URL above to save your first bookmark</p>
</div>
</div>
);
}

return (
<div className="flex flex-col gap-3">
{bookmarks.map((bookmark, i) => (
<div key={bookmark.id} className="animate-fade-up" style={{ animationDelay: `${i * 40}ms` }}>
<BookmarkCard
bookmark={{
...bookmark,
createdAt: bookmark.createdAt.toISOString(),
}}
/>
</div>
))}
</div>
);
}

function BookmarkListSkeleton() {
return (
<div className="flex flex-col gap-3">
<BookmarkCardSkeleton />
<BookmarkCardSkeleton />
<BookmarkCardSkeleton />
</div>
);
}

export default async function AppPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/auth");

return (
<div className="min-h-screen bg-[var(--bg)]">
<Header email={user.email ?? ""} />
<main className="max-w-2xl mx-auto pt-6 pb-16 px-4">
<SaveUrlForm />
<SearchBar />
<Suspense fallback={<BookmarkListSkeleton />}>
<BookmarkList userId={user.id} />
</Suspense>
</main>
</div>
);
}
178 changes: 110 additions & 68 deletions client/src/app/auth/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import { Eye, EyeOff } from "lucide-react";

export default function AuthPage() {
const router = useRouter();
Expand All @@ -12,6 +13,7 @@
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [slow, setSlow] = useState(false);
const [showPassword, setShowPassword] = useState(false);

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
Expand Down Expand Up @@ -42,7 +44,7 @@
return;
}
}
router.push("/");
router.push("/app");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "something went wrong");
Expand All @@ -54,77 +56,117 @@
}

return (
<main className="min-h-screen bg-zinc-50 dark:bg-zinc-900 flex items-center justify-center px-4">
<div className="w-full max-w-sm">
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 mb-6">
{mode === "login" ? "Log in" : "Sign up"}
</h1>

<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label htmlFor="email-input" className="text-sm text-zinc-400">Email</label>
<input
id="email-input"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-500"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="password-input" className="text-sm text-zinc-400">Password</label>
<input
id="password-input"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-500"
/>
<main className="min-h-screen bg-[var(--bg)] flex items-center justify-center px-4">
<div className="w-full max-w-sm animate-fade-up">
{/* Logo / wordmark */}
<div className="mb-8 text-center">
<div className="inline-flex items-center justify-center w-12 h-12 mb-4">
<img src="/logo.svg" alt="Recall" width={48} height={48} />

Check warning on line 64 in client/src/app/auth/page.tsx

View workflow job for this annotation

GitHub Actions / ci

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
</div>
<h1 className="text-xl font-semibold text-[var(--text)]">Recall</h1>
<p className="text-sm text-[var(--text-muted)] mt-1">
Save anything. Surface it when it matters.
</p>
</div>

<div className="rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-6">
<h2 className="text-sm font-semibold text-[var(--text)] mb-5">
{mode === "login" ? "Welcome back" : "Create your account"}
</h2>

<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label
htmlFor="email-input"
className="text-xs font-medium text-[var(--text-muted)]"
>
Email
</label>
<input
id="email-input"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-sm text-[var(--text)] placeholder:text-[var(--text-dim)] focus:outline-none focus:border-[var(--accent)] transition-colors duration-100"
/>
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="password-input"
className="text-xs font-medium text-[var(--text-muted)]"
>
Password
</label>
<div className="relative">
<input
id="password-input"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded-lg border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 pr-9 text-sm text-[var(--text)] placeholder:text-[var(--text-dim)] focus:outline-none focus:border-[var(--accent)] transition-colors duration-100"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? "Hide password" : "Show password"}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[var(--text-muted)] hover:text-[var(--text)] transition-colors duration-100"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>

{error && (
<p role="alert" aria-live="polite" className="text-sm text-red-400">
{error}
</p>
)}
{slow && !error && (
<p className="text-sm text-zinc-500" aria-live="polite">
Still working — this can take a few seconds on first load...
</p>
)}
{error && (
<p
role="alert"
aria-live="polite"
className="text-xs text-[var(--error)] bg-[var(--error-bg)] rounded-lg px-3 py-2"
>
{error}
</p>
)}
{slow && !error && (
<p
className="text-xs text-[var(--text-muted)]"
aria-live="polite"
>
Still working — this can take a few seconds on first load…
</p>
)}

<button
type="submit"
disabled={loading}
className="rounded-lg bg-zinc-100 px-4 py-2 text-sm font-semibold text-zinc-900 hover:bg-white disabled:opacity-50"
>
{loading
? "Please wait..."
: mode === "login"
? "Log in"
: "Sign up"}
</button>
</form>
<button
type="submit"
disabled={loading}
className="rounded-lg bg-[var(--accent)] px-4 py-2 text-sm font-semibold text-[var(--accent-text)] hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors duration-100 active:scale-95 mt-1"
>
{loading
? "Please wait"
: mode === "login"
? "Log in"
: "Sign up"}
</button>
</form>

<p className="text-sm text-zinc-500 mt-4">
{mode === "login"
? "Don't have an account?"
: "Already have an account?"}{" "}
<button
onClick={() => {
setMode(mode === "login" ? "signup" : "login");
setError(null);
setPassword("");
}}
className="text-zinc-300 hover:underline"
>
{mode === "login" ? "Sign up" : "Log in"}
</button>
</p>
<p className="text-xs text-[var(--text-muted)] mt-4 text-center">
{mode === "login"
? "Don't have an account?"
: "Already have an account?"}{" "}
<button
onClick={() => {
setMode(mode === "login" ? "signup" : "login");
setError(null);
setPassword("");
}}
className="text-[var(--accent)] hover:underline font-medium"
>
{mode === "login" ? "Sign up" : "Log in"}
</button>
</p>
</div>
</div>
</main>
);
Expand Down
Loading
Loading