diff --git a/README.md b/README.md
index 3ef8b49..ab3eb3d 100644
--- a/README.md
+++ b/README.md
@@ -1,43 +1,57 @@
-# ποΈ closedNote
+# closedNote
-> *"Because even ChatGPT forgets sometimes..."*
+> **Prompts are living documents. closedNote is the only prompt manager that remembers how they evolved.**
-### π [closednote.vercel.app](https://closednote.vercel.app) β try it live
+[](https://closednote.vercel.app)
+[](https://nextjs.org)
+[](https://www.typescriptlang.org)
+[](https://supabase.com)
+[](LICENSE)
+[](https://vercel.com)
---
-## π What is closedNote?
+## Explanation
-A web app for saving, organizing, and re-using your best AI prompts β built for students, teachers, engineers, and anyone tired of retyping the same thing twice.
+PromptBase stores prompts. Notion organizes them. FlowGPT shares them. None of them remember how they got there.
----
+In real life, prompts evolve. You tweak your "code review prompt" three times, and by the fourth iteration you've forgotten what made version 2 actually work. There is no tool aimed at everyday users that tracks how your prompts change over time β until now.
+
+closedNote is built on one thesis: **a prompt is not a sticky note. It's a document with a history.**
-## π‘ The Story
+Beyond versioning, closedNote adds structure: organize into collections, chain into multi-step workflows, refine with AI, and import from any image via OCR β all private by default.
+
+---
-I got tired of re-engineering my "perfect ChatGPT prompts" every time I needed a particular kind of answer. Then my mum started doing the same thing (don't ask how she got into it π). Then my grandma. Then my classmates.
+## Version History β Git for Your Prompts
-Meanwhile, prompt engineers were dropping crazy tips on X (Twitter) and Stack Overflow, but I had nowhere to store them neatly.
+Every time you save an edit, closedNote snapshots the version. Jump back to any point in time, see exactly what changed line by line, and restore with one click β without overwriting your history.
-So, I built one. That's what closedNote is all about β a small home to make prompt saving easier for everyone. π
+
-Completely open source, open to contributions, and continuously improving.
+- Full version timeline on every prompt
+- Visual diff β additions in green, removals in red
+- Restore any version without losing the history chain
+- Versions only created when content actually changes β no noise
---
-## π§ Features
+## All Features
-- π **Instant Search** β command palette (`βK`) across all prompts
-- π **Collections** β group prompts by topic, project, or vibe
-- πΌοΈ **Image to Text (OCR)** β upload screenshots β extract text β save as prompt
-- β¨ **AI Refinement** β clean up extracted text into a polished, reusable prompt
-- πΎ **One-Click Copy** β paste straight into ChatGPT, Claude, Cursor, whatever
-- π **Dark Mode** β because your eyes matter
-- π± **Fully Responsive** β works on mobile without crying
-- π **Private by Default** β RLS ensures your data stays yours
+- **Version History** β track every draft with a visual diff and one-click restore *(new)*
+- **Instant Search** β command palette (`βK`) across your entire library
+- **Collections** β group prompts by topic, project, or use case
+- **AI Refinement** β clean up rough ideas into polished, reusable prompts using your own API key
+- **OCR Import** β upload a screenshot or photo, extract the text, save it as a prompt
+- **Prompt Chains** β link prompts into multi-step workflows where each output feeds the next
+- **One-Click Copy** β paste straight into ChatGPT, Claude, Cursor, or wherever you work
+- **Private by Default** β row-level security ensures your data is never accessible to others
+- **Dark Mode** β full theme support, system-aware
+- **Fully Responsive** β works on mobile without crying
---
-## π₯οΈ Demo
+## Demo
### Dashboard
@@ -53,29 +67,29 @@ Completely open source, open to contributions, and continuously improving.

-### π± Mobile
+### Mobile
-| | |
-| ------------------------------------------------- | ------------------------------------------------- |
+| | |
+|---|---|
|  |  |
---
-## βοΈ Tech Stack
-
-**Frontend:** Next.js 14 Β· React 18 Β· TypeScript Β· Tailwind CSS
-
-**Backend:** Supabase (PostgreSQL + PKCE Auth + RLS) Β· Next.js API Routes
-
-**AI / OCR:** OpenAI GPT-4o-mini Β· HuggingFace Zephyr-7b Β· Tesseract.js (offline fallback)
+## Tech Stack
-**Deployment:** Vercel
+| Layer | Technology |
+|---|---|
+| Frontend | Next.js 14 (App Router) Β· React 18 Β· TypeScript 5.5 Β· Tailwind CSS 3.4 |
+| Backend | Supabase (PostgreSQL + PKCE Auth + Row-Level Security) Β· Next.js API Routes |
+| AI / OCR | OpenAI GPT-4o-mini Β· HuggingFace Zephyr-7b Β· Tesseract.js (offline fallback) |
+| Diff Engine | Google diff-match-patch |
+| Deployment | Vercel |
-Users without API keys still get full prompt management + offline OCR. AI features unlock when they add a key in Settings.
+Users without API keys get full prompt management + offline OCR. AI features unlock when they add their own key in Settings.
---
-## π§ͺ Tests
+## Tests

@@ -87,14 +101,24 @@ npm test
---
-## β‘ Run Locally
+## The Story
+
+I got tired of re-engineering my "perfect ChatGPT prompts" every time I needed a particular kind of answer. Then my mum started doing the same thing. Then my grandma. Then my classmates.
+
+Meanwhile, prompt engineers were dropping tips on X and Stack Overflow, but nobody had a good place to store, iterate on, and *remember* them.
+
+So I built one β and added version control, because the best prompt you'll ever write is usually the fourth draft of something you thought was broken.
+
+---
+
+## Run Locally
```bash
git clone https://github.com/aboderinsamuel/closedNote.git
cd closedNote
npm install
cp .env.example .env.local
-# Fill in your Supabase keys in .env.local
+# Fill in your Supabase keys
npm run dev
```
@@ -104,11 +128,11 @@ NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
```
-AI features are optional β users add their own OpenAI or HuggingFace key in Settings.
+**Supabase setup:** run the four migration files in [`/supabase/migrations`](./supabase/migrations) in order inside the Supabase SQL editor.
---
-## π Deploy
+## Deploy
1. Fork this repo
2. Import to [Vercel](https://vercel.com) and add the two env vars above
@@ -116,31 +140,32 @@ AI features are optional β users add their own OpenAI or HuggingFace key in Se
---
-## π£οΈ Open Issues & Roadmap
+## Contributing
-See the [open issues](https://github.com/aboderinsamuel/closedNote_v0.01/issues) for what's being worked on.
+Got ideas? Contributions welcome.
-Got ideas? Dark mode themes, AI tag suggestions, team sharing, prompt history β contributions welcome!
+1. Fork this repo
+2. Create a branch: `git checkout -b feature/your-idea`
+3. Commit and push
+4. Open a pull request
-1. Fork this repo π΄
-2. Create a branch (`feature/my-new-idea`)
-3. Commit, push, and open a pull request π
+See [open issues](https://github.com/aboderinsamuel/closedNote/issues) for what's being worked on.
---
-## π¨π½βπ About
+## Built by
-Built by [**Samuel Aboderin**](https://github.com/aboderinsamuel),
-Computer Engineering student at **UNILAG π³π¬**
+**Samuel Aboderin** β Computer Engineering, UNILAG π³π¬
-[LinkedIn](https://www.linkedin.com/in/samuelaboderin) Β· [GitHub](https://github.com/aboderinsamuel)
+[](https://github.com/aboderinsamuel)
+[](https://www.linkedin.com/in/samuelaboderin)
---
-## π§Ύ License
+## License
-MIT β use it, remix it, improve it. Just don't lock it behind a paywall. ππ½
+MIT β use it, remix it, improve it.
---
-*closedNote β because your prompts deserve better than browser history.* β¨
+*closedNote β because your prompts deserve better than browser history.*
diff --git a/__tests__/components/PromptForm.test.tsx b/__tests__/components/PromptForm.test.tsx
index bcb647c..7359ce0 100644
--- a/__tests__/components/PromptForm.test.tsx
+++ b/__tests__/components/PromptForm.test.tsx
@@ -56,7 +56,7 @@ describe("PromptForm β rendering", () => {
it("renders all form fields", () => {
renderWithAuth();
expect(screen.getByPlaceholderText("Give your prompt a name")).toBeInTheDocument();
- expect(screen.getByPlaceholderText("Enter your prompt here...")).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Paste or type your prompt here...")).toBeInTheDocument();
expect(screen.getByPlaceholderText("e.g. coding")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /save prompt/i })).toBeInTheDocument();
});
@@ -72,7 +72,7 @@ describe("PromptForm β unauthenticated behavior", () => {
it("redirects to /login on submit when user is not authenticated", async () => {
renderWithAuth(null);
await userEvent.type(screen.getByPlaceholderText("Give your prompt a name"), "Test Prompt");
- await userEvent.type(screen.getByPlaceholderText("Enter your prompt here..."), "Some content");
+ await userEvent.type(screen.getByPlaceholderText("Paste or type your prompt here..."), "Some content");
await userEvent.click(screen.getByRole("button", { name: /save prompt/i }));
expect(mockPush).toHaveBeenCalledWith("/login");
@@ -90,7 +90,7 @@ describe("PromptForm β authenticated submission", () => {
it("calls addOptimistic and navigates home on valid submit", async () => {
renderWithAuth();
await userEvent.type(screen.getByPlaceholderText("Give your prompt a name"), "My Prompt");
- await userEvent.type(screen.getByPlaceholderText("Enter your prompt here..."), "Prompt content");
+ await userEvent.type(screen.getByPlaceholderText("Paste or type your prompt here..."), "Prompt content");
await userEvent.click(screen.getByRole("button", { name: /save prompt/i }));
expect(mockAddOptimistic).toHaveBeenCalledWith(
@@ -102,7 +102,7 @@ describe("PromptForm β authenticated submission", () => {
it("defaults collection to 'uncategorized' when left blank", async () => {
renderWithAuth();
await userEvent.type(screen.getByPlaceholderText("Give your prompt a name"), "Prompt");
- await userEvent.type(screen.getByPlaceholderText("Enter your prompt here..."), "Content");
+ await userEvent.type(screen.getByPlaceholderText("Paste or type your prompt here..."), "Content");
await userEvent.click(screen.getByRole("button", { name: /save prompt/i }));
expect(mockAddOptimistic).toHaveBeenCalledWith(
@@ -113,7 +113,7 @@ describe("PromptForm β authenticated submission", () => {
it("uses the entered collection name when provided", async () => {
renderWithAuth();
await userEvent.type(screen.getByPlaceholderText("Give your prompt a name"), "Prompt");
- await userEvent.type(screen.getByPlaceholderText("Enter your prompt here..."), "Content");
+ await userEvent.type(screen.getByPlaceholderText("Paste or type your prompt here..."), "Content");
await userEvent.type(screen.getByPlaceholderText("e.g. coding"), "engineering");
await userEvent.click(screen.getByRole("button", { name: /save prompt/i }));
@@ -125,7 +125,7 @@ describe("PromptForm β authenticated submission", () => {
it("calls savePrompt asynchronously after optimistic update", async () => {
renderWithAuth();
await userEvent.type(screen.getByPlaceholderText("Give your prompt a name"), "Prompt");
- await userEvent.type(screen.getByPlaceholderText("Enter your prompt here..."), "Content");
+ await userEvent.type(screen.getByPlaceholderText("Paste or type your prompt here..."), "Content");
await userEvent.click(screen.getByRole("button", { name: /save prompt/i }));
await waitFor(() => {
@@ -138,7 +138,7 @@ describe("PromptForm β authenticated submission", () => {
it("calls refresh after savePrompt resolves", async () => {
renderWithAuth();
await userEvent.type(screen.getByPlaceholderText("Give your prompt a name"), "Prompt");
- await userEvent.type(screen.getByPlaceholderText("Enter your prompt here..."), "Content");
+ await userEvent.type(screen.getByPlaceholderText("Paste or type your prompt here..."), "Content");
await userEvent.click(screen.getByRole("button", { name: /save prompt/i }));
await waitFor(() => expect(mockRefresh).toHaveBeenCalled());
@@ -150,7 +150,7 @@ describe("PromptForm β authenticated submission", () => {
jest.spyOn(console, "error").mockImplementation(() => {});
renderWithAuth();
await userEvent.type(screen.getByPlaceholderText("Give your prompt a name"), "Prompt");
- await userEvent.type(screen.getByPlaceholderText("Enter your prompt here..."), "Content");
+ await userEvent.type(screen.getByPlaceholderText("Paste or type your prompt here..."), "Content");
await userEvent.click(screen.getByRole("button", { name: /save prompt/i }));
await waitFor(() => expect(mockRefresh).toHaveBeenCalled());
@@ -159,7 +159,7 @@ describe("PromptForm β authenticated submission", () => {
it("generated prompt has a non-empty id and ISO timestamp", async () => {
renderWithAuth();
await userEvent.type(screen.getByPlaceholderText("Give your prompt a name"), "Prompt");
- await userEvent.type(screen.getByPlaceholderText("Enter your prompt here..."), "Content");
+ await userEvent.type(screen.getByPlaceholderText("Paste or type your prompt here..."), "Content");
await userEvent.click(screen.getByRole("button", { name: /save prompt/i }));
const optimisticArg = mockAddOptimistic.mock.calls[0][0];
diff --git a/app/api/prompts/[id]/versions/route.ts b/app/api/prompts/[id]/versions/route.ts
new file mode 100644
index 0000000..5b7b21c
--- /dev/null
+++ b/app/api/prompts/[id]/versions/route.ts
@@ -0,0 +1,44 @@
+import { NextRequest, NextResponse } from "next/server";
+import { createClient } from "@supabase/supabase-js";
+
+export async function GET(
+ req: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ const authHeader = req.headers.get("authorization");
+ const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7).trim() : null;
+
+ if (!token) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
+ const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "";
+
+ // Pass the user's JWT so RLS can resolve auth.uid()
+ const supabase = createClient(url, key, {
+ auth: { persistSession: false, autoRefreshToken: false },
+ global: { headers: { Authorization: `Bearer ${token}` } },
+ });
+
+ const { data, error } = await supabase
+ .from("prompt_versions")
+ .select("*")
+ .eq("prompt_id", params.id)
+ .order("version_number", { ascending: false });
+
+ if (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+
+ const versions = (data ?? []).map((v) => ({
+ id: v.id,
+ promptId: v.prompt_id,
+ title: v.title,
+ content: v.content,
+ versionNumber: v.version_number,
+ createdAt: v.created_at,
+ }));
+
+ return NextResponse.json(versions);
+}
diff --git a/app/docs/page.tsx b/app/docs/page.tsx
index 94853ba..e283cc4 100644
--- a/app/docs/page.tsx
+++ b/app/docs/page.tsx
@@ -2,7 +2,6 @@ import Link from "next/link";
import { Header } from "@/components/Header";
import { Layout } from "@/components/Layout";
-
export const metadata = {
title: "Documentation - closedNote",
description: "Architecture, features, and deployment guide for closedNote.",
@@ -10,6 +9,7 @@ export const metadata = {
const toc = [
{ id: "why", label: "Why closedNote?" },
+ { id: "version-history", label: "Version History" },
{ id: "architecture", label: "Architecture" },
{ id: "ocr", label: "OCR & AI Refinement" },
{ id: "ai-provider", label: "AI Provider" },
@@ -29,13 +29,12 @@ export default function DocsPage() {
Engineering
-
+
How closedNote works
- A technical overview of the architecture, features, and design decisions
- behind closedNote - a prompt engineering platform built for developers,
- students, and anyone working with AI.
+ Technical overview of the architecture, features, and design decisions behind closedNote β
+ the only prompt manager that tracks how your prompts evolve.
@@ -43,7 +42,7 @@ export default function DocsPage() {
- The problem is simple: you craft a great prompt, get excellent results,
- and two weeks later you're scrolling through chat history trying to
- find it. closedNote is the permanent home for those prompts.
+ PromptBase stores prompts. Notion organizes them. FlowGPT shares them.
+ None of them remember how they got there.
+
+
+ In real life, prompts evolve. You tweak your "code review prompt" three
+ times, and by the fourth iteration you've forgotten what made version 2
+ actually work. closedNote is built on one thesis:{" "}
+
+ a prompt is not a sticky note β it's a document with a history.
+
- Beyond storage, closedNote adds structure. Prompts can be tagged,
- organized into collections, chained into multi-step workflows, and
- improved with AI refinement - all in one place with a clean interface
- that doesn't get in the way.
+ Beyond versioning, closedNote adds structure: prompts organized into collections,
+ chained into multi-step workflows, refined by AI, and importable from any image
+ via OCR β all private by default, all in one place.
+ {/* Version History */}
+
+
+
+ Version History
+
+
+ new
+
+
+
+
+ Every time a user saves an edit to a prompt, closedNote snapshots it
+ into a prompt_versions table.
+ A version history panel on the prompt detail page shows the full timeline.
+ Clicking any version renders a live diff against the current content using
+ Google's{" "}
+ diff-match-patch{" "}
+ library β additions in green, removals in red.
+
+
+ Restoring a version updates the prompt content without creating a new version
+ entry β preserving the history chain exactly as it was. A new version is only
+ created when the user edits and saves content that differs from the last saved state.
+
+
+
+
+
+
+
+
Behaviour
+
Creates new version?
+
+
+
+ {[
+ ["Save with changed content or title", "Yes"],
+ ["Save with no changes", "No"],
+ ["Restore a previous version", "No"],
+ ["Edit after restore, content differs", "Yes"],
+ ].map(([behaviour, creates]) => (
+
lib/promptData.ts- savePrompt() with skipVersion flag
+
app/api/prompts/[id]/versions/route.ts- authenticated GET endpoint
+
components/VersionHistory.tsx- timeline + diff UI
+
+
+
+
+
+
{/* Architecture */}
@@ -101,26 +181,27 @@ export default function DocsPage() {
closedNote is a Next.js 14 App Router application with a Supabase backend.
- Almost all pages are client components; only this page is server-rendered.
+ Almost all pages are client components; only the docs page is server-rendered.
Frontend
-
Next.js 14 - App Router, RSC
-
React 18 - hooks, client components
-
Tailwind CSS 3.4 - utility-first styling
-
TypeScript 5.5 - full type safety
+
Next.js 14 β App Router, RSC
+
React 18 β hooks, client components
+
Tailwind CSS 3.4 β utility-first styling
+
TypeScript 5.5 β full type safety
+
diff-match-patch β version diff engine
Backend
-
Supabase - PostgreSQL + Auth
-
PKCE flow - secure auth
-
Row Level Security - per-user isolation
-
Vercel - edge deployment
+
Supabase β PostgreSQL + Auth
+
PKCE flow β secure auth
+
Row Level Security β per-user isolation
+
Vercel β edge deployment
@@ -128,12 +209,14 @@ export default function DocsPage() {
Key files
-
lib/hooks/usePrompts.ts- data fetching
-
lib/promptData.ts- CRUD operations
+
lib/hooks/usePrompts.ts- data fetching hook
+
lib/promptData.ts- prompt CRUD + versioning
lib/chainData.ts- chain CRUD
components/AuthProvider.tsx- auth context
-
app/api/chat/route.ts- AI chat proxy
+
components/VersionHistory.tsx- version timeline + diff
+
app/api/chat/route.ts- AI refinement proxy
app/api/ocr/route.ts- OCR endpoint
+
app/api/prompts/[id]/versions/route.ts- version history endpoint
@@ -146,7 +229,7 @@ export default function DocsPage() {
OCR & AI Refinement
- Upload a screenshot, photo, or scan - GPT-4o Vision extracts the text,
+ Upload a screenshot, photo, or scan β GPT-4o Vision extracts the text,
and the AI refinement step restructures it into a clean, reusable prompt.
A Tesseract.js fallback handles offline cases.
@@ -182,7 +265,7 @@ export default function DocsPage() {
AI Provider
- Chain runs and chat refinement use HuggingFace's Zephyr-7B by default -
+ Chain runs and chat refinement use HuggingFace's Zephyr-7B by default β
free, no billing required. If you add your own OpenAI key in{" "}
Settings
@@ -191,8 +274,10 @@ export default function DocsPage() {
OPENAI_API_KEY=sk-...# optional β enables GPT-4o OCR + AI
+
HUGGINGFACE_API_KEY=hf_...# optional β free AI fallback
@@ -204,7 +289,7 @@ export default function DocsPage() {
Database
- Five tables in PostgreSQL via Supabase. All have RLS enabled - every
+ Six tables in PostgreSQL via Supabase. All have RLS enabled β every
query is automatically scoped to the authenticated user.
@@ -219,12 +304,20 @@ export default function DocsPage() {
{[
["users", "Auth profile, synced from Supabase Auth on signup"],
["prompts", "Title, content, model, collection, user_id"],
+ ["prompt_versions", "Versioned snapshots of each prompt β powers the diff view"],
["tags", "Many-to-many; cascade-deletes with prompt"],
["prompt_chains", "Titled sequences of steps, owned by user"],
- ["chain_steps", "Ordered steps with content, output variables"],
+ ["chain_steps", "Ordered steps with content and output variables"],
].map(([table, desc]) => (
))}
@@ -242,9 +335,10 @@ export default function DocsPage() {
{[
- ["Row Level Security", "All tables enforce RLS. Every query is automatically filtered to the authenticated user's data at the database level."],
- ["PKCE Auth Flow", "Supabase Auth uses PKCE (Proof Key for Code Exchange). Sessions are stored ephemerally - cleared when the browser closes."],
+ ["Row Level Security", "All tables enforce RLS. Every query is automatically filtered to the authenticated user's data at the database level β including prompt_versions."],
+ ["PKCE Auth Flow", "Supabase Auth uses PKCE (Proof Key for Code Exchange). Sessions are stored ephemerally β cleared when the browser closes."],
["API key privacy", "User-supplied OpenAI keys are stored only in localStorage and passed per-request. They are never persisted server-side."],
+ ["Authenticated API routes", "The /api/prompts/[id]/versions endpoint validates the user's JWT before any query. No RLS bypass is possible."],
["HTTPS", "All traffic is encrypted in transit. Enforced by Vercel on production."],
].map(([title, desc]) => (
@@ -274,9 +368,9 @@ export default function DocsPage() {
{[
- ["Fork the repo", "Clone or fork from GitHub."],
- ["Create a Supabase project", "Run the three migration files in order from supabase/migrations/ via the SQL Editor."],
- ["Set environment variables", "Add NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, and HUGGINGFACE_API_KEY to your deployment environment."],
+ ["Fork the repo", "Clone or fork from GitHub: github.com/aboderinsamuel/closedNote"],
+ ["Create a Supabase project", "Run all four migration files in order from supabase/migrations/ via the Supabase SQL Editor. The fourth migration (004_prompt_versions.sql) creates the version history table."],
+ ["Set environment variables", "Add NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY. Optionally add OPENAI_API_KEY for GPT-4o OCR."],
["Deploy to Vercel", "Connect the repo, add the env vars, and deploy. Update your Supabase Auth redirect URLs to match your production domain."],
].map(([title, desc], i) => (
@@ -304,7 +398,14 @@ export default function DocsPage() {
a feature, contributions are welcome.
- {["AI-powered tag suggestions", "Team sharing & collaboration", "Prompt version history", "Export to PDF / Markdown", "Browser extension", "Pagination & infinite scroll"].map((idea) => (
+ {[
+ "AI-powered tag suggestions",
+ "Team sharing & collaboration",
+ "Export to PDF / Markdown",
+ "Browser extension",
+ "Pagination & infinite scroll",
+ "Prompt templates with variables",
+ ].map((idea) => (
{idea}
@@ -313,7 +414,7 @@ export default function DocsPage() {