Skip to content

Commit 8ab582a

Browse files
Merge pull request #7 from aboderinsamuel/feature/prompt-versioning
Feature/prompt versioning
2 parents 86f898e + 3d98476 commit 8ab582a

15 files changed

Lines changed: 1043 additions & 216 deletions

File tree

README.md

Lines changed: 76 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,57 @@
1-
# 🗒️ closedNote
1+
# closedNote
22

3-
> *"Because even ChatGPT forgets sometimes..."*
3+
> **Prompts are living documents. closedNote is the only prompt manager that remembers how they evolved.**
44
5-
### 👉 [closednote.vercel.app](https://closednote.vercel.app) — try it live
5+
[![Live](https://img.shields.io/badge/live-closednote.vercel.app-black?style=flat-square)](https://closednote.vercel.app)
6+
[![Next.js](https://img.shields.io/badge/Next.js-14-black?style=flat-square&logo=next.js)](https://nextjs.org)
7+
[![TypeScript](https://img.shields.io/badge/TypeScript-5.5-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org)
8+
[![Supabase](https://img.shields.io/badge/Supabase-PostgreSQL-3ECF8E?style=flat-square&logo=supabase&logoColor=white)](https://supabase.com)
9+
[![License: MIT](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE)
10+
[![Deployed on Vercel](https://img.shields.io/badge/deployed-Vercel-black?style=flat-square&logo=vercel)](https://vercel.com)
611

712
---
813

9-
## 👋 What is closedNote?
14+
## Explanation
1015

11-
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.
16+
PromptBase stores prompts. Notion organizes them. FlowGPT shares them. None of them remember how they got there.
1217

13-
---
18+
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.
19+
20+
closedNote is built on one thesis: **a prompt is not a sticky note. It's a document with a history.**
1421

15-
## 💡 The Story
22+
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.
23+
24+
---
1625

17-
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.
26+
## Version History — Git for Your Prompts
1827

19-
Meanwhile, prompt engineers were dropping crazy tips on X (Twitter) and Stack Overflow, but I had nowhere to store them neatly.
28+
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.
2029

21-
So, I built one. That's what closedNote is all about — a small home to make prompt saving easier for everyone. 🙂
30+
![Version History](./screenshots/versioning01.png)
2231

23-
Completely open source, open to contributions, and continuously improving.
32+
- Full version timeline on every prompt
33+
- Visual diff — additions in green, removals in red
34+
- Restore any version without losing the history chain
35+
- Versions only created when content actually changes — no noise
2436

2537
---
2638

27-
## 🧠 Features
39+
## All Features
2840

29-
- 🔍 **Instant Search** — command palette (`⌘K`) across all prompts
30-
- 📁 **Collections** — group prompts by topic, project, or vibe
31-
- 🖼️ **Image to Text (OCR)** — upload screenshots → extract text → save as prompt
32-
-**AI Refinement** — clean up extracted text into a polished, reusable prompt
33-
- 💾 **One-Click Copy** — paste straight into ChatGPT, Claude, Cursor, whatever
34-
- 🌗 **Dark Mode** — because your eyes matter
35-
- 📱 **Fully Responsive** — works on mobile without crying
36-
- 🔒 **Private by Default** — RLS ensures your data stays yours
41+
- **Version History** — track every draft with a visual diff and one-click restore *(new)*
42+
- **Instant Search** — command palette (`⌘K`) across your entire library
43+
- **Collections** — group prompts by topic, project, or use case
44+
- **AI Refinement** — clean up rough ideas into polished, reusable prompts using your own API key
45+
- **OCR Import** — upload a screenshot or photo, extract the text, save it as a prompt
46+
- **Prompt Chains** — link prompts into multi-step workflows where each output feeds the next
47+
- **One-Click Copy** — paste straight into ChatGPT, Claude, Cursor, or wherever you work
48+
- **Private by Default** — row-level security ensures your data is never accessible to others
49+
- **Dark Mode** — full theme support, system-aware
50+
- **Fully Responsive** — works on mobile without crying
3751

3852
---
3953

40-
## 🖥️ Demo
54+
## Demo
4155

4256
### Dashboard
4357

@@ -53,29 +67,29 @@ Completely open source, open to contributions, and continuously improving.
5367

5468
![OCR Feature](./screenshots/OCR.png)
5569

56-
### 📱 Mobile
70+
### Mobile
5771

58-
| | |
59-
| ------------------------------------------------- | ------------------------------------------------- |
72+
| | |
73+
|---|---|
6074
| ![Mobile Screenshot 1](./screenshots/mobile1.png) | ![Mobile Screenshot 2](./screenshots/mobile2.png) |
6175

6276
---
6377

64-
## ⚙️ Tech Stack
65-
66-
**Frontend:** Next.js 14 · React 18 · TypeScript · Tailwind CSS
67-
68-
**Backend:** Supabase (PostgreSQL + PKCE Auth + RLS) · Next.js API Routes
69-
70-
**AI / OCR:** OpenAI GPT-4o-mini · HuggingFace Zephyr-7b · Tesseract.js (offline fallback)
78+
## Tech Stack
7179

72-
**Deployment:** Vercel
80+
| Layer | Technology |
81+
|---|---|
82+
| Frontend | Next.js 14 (App Router) · React 18 · TypeScript 5.5 · Tailwind CSS 3.4 |
83+
| Backend | Supabase (PostgreSQL + PKCE Auth + Row-Level Security) · Next.js API Routes |
84+
| AI / OCR | OpenAI GPT-4o-mini · HuggingFace Zephyr-7b · Tesseract.js (offline fallback) |
85+
| Diff Engine | Google diff-match-patch |
86+
| Deployment | Vercel |
7387

74-
Users without API keys still get full prompt management + offline OCR. AI features unlock when they add a key in Settings.
88+
Users without API keys get full prompt management + offline OCR. AI features unlock when they add their own key in Settings.
7589

7690
---
7791

78-
## 🧪 Tests
92+
## Tests
7993

8094
![Test Results](./screenshots/test.png)
8195

@@ -87,14 +101,24 @@ npm test
87101

88102
---
89103

90-
## ⚡ Run Locally
104+
## The Story
105+
106+
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.
107+
108+
Meanwhile, prompt engineers were dropping tips on X and Stack Overflow, but nobody had a good place to store, iterate on, and *remember* them.
109+
110+
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.
111+
112+
---
113+
114+
## Run Locally
91115

92116
```bash
93117
git clone https://github.com/aboderinsamuel/closedNote.git
94118
cd closedNote
95119
npm install
96120
cp .env.example .env.local
97-
# Fill in your Supabase keys in .env.local
121+
# Fill in your Supabase keys
98122
npm run dev
99123
```
100124

@@ -104,43 +128,44 @@ NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
104128
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
105129
```
106130

107-
AI features are optional — users add their own OpenAI or HuggingFace key in Settings.
131+
**Supabase setup:** run the four migration files in [`/supabase/migrations`](./supabase/migrations) in order inside the Supabase SQL editor.
108132

109133
---
110134

111-
## 🚀 Deploy
135+
## Deploy
112136

113137
1. Fork this repo
114138
2. Import to [Vercel](https://vercel.com) and add the two env vars above
115139
3. In Supabase → Authentication → URL Configuration, add your Vercel domain to Redirect URLs
116140

117141
---
118142

119-
## 🛣️ Open Issues & Roadmap
143+
## Contributing
120144

121-
See the [open issues](https://github.com/aboderinsamuel/closedNote_v0.01/issues) for what's being worked on.
145+
Got ideas? Contributions welcome.
122146

123-
Got ideas? Dark mode themes, AI tag suggestions, team sharing, prompt history — contributions welcome!
147+
1. Fork this repo
148+
2. Create a branch: `git checkout -b feature/your-idea`
149+
3. Commit and push
150+
4. Open a pull request
124151

125-
1. Fork this repo 🍴
126-
2. Create a branch (`feature/my-new-idea`)
127-
3. Commit, push, and open a pull request 🚀
152+
See [open issues](https://github.com/aboderinsamuel/closedNote/issues) for what's being worked on.
128153

129154
---
130155

131-
## 👨🏽‍🎓 About
156+
## Built by
132157

133-
Built by [**Samuel Aboderin**](https://github.com/aboderinsamuel),
134-
Computer Engineering student at **UNILAG 🇳🇬**
158+
**Samuel Aboderin** — Computer Engineering, UNILAG 🇳🇬
135159

136-
[LinkedIn](https://www.linkedin.com/in/samuelaboderin) · [GitHub](https://github.com/aboderinsamuel)
160+
[![GitHub](https://img.shields.io/badge/GitHub-aboderinsamuel-black?style=flat-square&logo=github)](https://github.com/aboderinsamuel)
161+
[![LinkedIn](https://img.shields.io/badge/LinkedIn-samuelaboderin-0A66C2?style=flat-square&logo=linkedin&logoColor=white)](https://www.linkedin.com/in/samuelaboderin)
137162

138163
---
139164

140-
## 🧾 License
165+
## License
141166

142-
MIT — use it, remix it, improve it. Just don't lock it behind a paywall. 🙏🏽
167+
MIT — use it, remix it, improve it.
143168

144169
---
145170

146-
*closedNote — because your prompts deserve better than browser history.*
171+
*closedNote — because your prompts deserve better than browser history.*

__tests__/components/PromptForm.test.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ describe("PromptForm – rendering", () => {
5656
it("renders all form fields", () => {
5757
renderWithAuth();
5858
expect(screen.getByPlaceholderText("Give your prompt a name")).toBeInTheDocument();
59-
expect(screen.getByPlaceholderText("Enter your prompt here...")).toBeInTheDocument();
59+
expect(screen.getByPlaceholderText("Paste or type your prompt here...")).toBeInTheDocument();
6060
expect(screen.getByPlaceholderText("e.g. coding")).toBeInTheDocument();
6161
expect(screen.getByRole("button", { name: /save prompt/i })).toBeInTheDocument();
6262
});
@@ -72,7 +72,7 @@ describe("PromptForm – unauthenticated behavior", () => {
7272
it("redirects to /login on submit when user is not authenticated", async () => {
7373
renderWithAuth(null);
7474
await userEvent.type(screen.getByPlaceholderText("Give your prompt a name"), "Test Prompt");
75-
await userEvent.type(screen.getByPlaceholderText("Enter your prompt here..."), "Some content");
75+
await userEvent.type(screen.getByPlaceholderText("Paste or type your prompt here..."), "Some content");
7676
await userEvent.click(screen.getByRole("button", { name: /save prompt/i }));
7777

7878
expect(mockPush).toHaveBeenCalledWith("/login");
@@ -90,7 +90,7 @@ describe("PromptForm – authenticated submission", () => {
9090
it("calls addOptimistic and navigates home on valid submit", async () => {
9191
renderWithAuth();
9292
await userEvent.type(screen.getByPlaceholderText("Give your prompt a name"), "My Prompt");
93-
await userEvent.type(screen.getByPlaceholderText("Enter your prompt here..."), "Prompt content");
93+
await userEvent.type(screen.getByPlaceholderText("Paste or type your prompt here..."), "Prompt content");
9494
await userEvent.click(screen.getByRole("button", { name: /save prompt/i }));
9595

9696
expect(mockAddOptimistic).toHaveBeenCalledWith(
@@ -102,7 +102,7 @@ describe("PromptForm – authenticated submission", () => {
102102
it("defaults collection to 'uncategorized' when left blank", async () => {
103103
renderWithAuth();
104104
await userEvent.type(screen.getByPlaceholderText("Give your prompt a name"), "Prompt");
105-
await userEvent.type(screen.getByPlaceholderText("Enter your prompt here..."), "Content");
105+
await userEvent.type(screen.getByPlaceholderText("Paste or type your prompt here..."), "Content");
106106
await userEvent.click(screen.getByRole("button", { name: /save prompt/i }));
107107

108108
expect(mockAddOptimistic).toHaveBeenCalledWith(
@@ -113,7 +113,7 @@ describe("PromptForm – authenticated submission", () => {
113113
it("uses the entered collection name when provided", async () => {
114114
renderWithAuth();
115115
await userEvent.type(screen.getByPlaceholderText("Give your prompt a name"), "Prompt");
116-
await userEvent.type(screen.getByPlaceholderText("Enter your prompt here..."), "Content");
116+
await userEvent.type(screen.getByPlaceholderText("Paste or type your prompt here..."), "Content");
117117
await userEvent.type(screen.getByPlaceholderText("e.g. coding"), "engineering");
118118
await userEvent.click(screen.getByRole("button", { name: /save prompt/i }));
119119

@@ -125,7 +125,7 @@ describe("PromptForm – authenticated submission", () => {
125125
it("calls savePrompt asynchronously after optimistic update", async () => {
126126
renderWithAuth();
127127
await userEvent.type(screen.getByPlaceholderText("Give your prompt a name"), "Prompt");
128-
await userEvent.type(screen.getByPlaceholderText("Enter your prompt here..."), "Content");
128+
await userEvent.type(screen.getByPlaceholderText("Paste or type your prompt here..."), "Content");
129129
await userEvent.click(screen.getByRole("button", { name: /save prompt/i }));
130130

131131
await waitFor(() => {
@@ -138,7 +138,7 @@ describe("PromptForm – authenticated submission", () => {
138138
it("calls refresh after savePrompt resolves", async () => {
139139
renderWithAuth();
140140
await userEvent.type(screen.getByPlaceholderText("Give your prompt a name"), "Prompt");
141-
await userEvent.type(screen.getByPlaceholderText("Enter your prompt here..."), "Content");
141+
await userEvent.type(screen.getByPlaceholderText("Paste or type your prompt here..."), "Content");
142142
await userEvent.click(screen.getByRole("button", { name: /save prompt/i }));
143143

144144
await waitFor(() => expect(mockRefresh).toHaveBeenCalled());
@@ -150,7 +150,7 @@ describe("PromptForm – authenticated submission", () => {
150150
jest.spyOn(console, "error").mockImplementation(() => {});
151151
renderWithAuth();
152152
await userEvent.type(screen.getByPlaceholderText("Give your prompt a name"), "Prompt");
153-
await userEvent.type(screen.getByPlaceholderText("Enter your prompt here..."), "Content");
153+
await userEvent.type(screen.getByPlaceholderText("Paste or type your prompt here..."), "Content");
154154
await userEvent.click(screen.getByRole("button", { name: /save prompt/i }));
155155

156156
await waitFor(() => expect(mockRefresh).toHaveBeenCalled());
@@ -159,7 +159,7 @@ describe("PromptForm – authenticated submission", () => {
159159
it("generated prompt has a non-empty id and ISO timestamp", async () => {
160160
renderWithAuth();
161161
await userEvent.type(screen.getByPlaceholderText("Give your prompt a name"), "Prompt");
162-
await userEvent.type(screen.getByPlaceholderText("Enter your prompt here..."), "Content");
162+
await userEvent.type(screen.getByPlaceholderText("Paste or type your prompt here..."), "Content");
163163
await userEvent.click(screen.getByRole("button", { name: /save prompt/i }));
164164

165165
const optimisticArg = mockAddOptimistic.mock.calls[0][0];
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { createClient } from "@supabase/supabase-js";
3+
4+
export async function GET(
5+
req: NextRequest,
6+
{ params }: { params: { id: string } }
7+
) {
8+
const authHeader = req.headers.get("authorization");
9+
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7).trim() : null;
10+
11+
if (!token) {
12+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
13+
}
14+
15+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
16+
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "";
17+
18+
// Pass the user's JWT so RLS can resolve auth.uid()
19+
const supabase = createClient(url, key, {
20+
auth: { persistSession: false, autoRefreshToken: false },
21+
global: { headers: { Authorization: `Bearer ${token}` } },
22+
});
23+
24+
const { data, error } = await supabase
25+
.from("prompt_versions")
26+
.select("*")
27+
.eq("prompt_id", params.id)
28+
.order("version_number", { ascending: false });
29+
30+
if (error) {
31+
return NextResponse.json({ error: error.message }, { status: 500 });
32+
}
33+
34+
const versions = (data ?? []).map((v) => ({
35+
id: v.id,
36+
promptId: v.prompt_id,
37+
title: v.title,
38+
content: v.content,
39+
versionNumber: v.version_number,
40+
createdAt: v.created_at,
41+
}));
42+
43+
return NextResponse.json(versions);
44+
}

0 commit comments

Comments
 (0)