Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
127 changes: 76 additions & 51 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
[![Live](https://img.shields.io/badge/live-closednote.vercel.app-black?style=flat-square)](https://closednote.vercel.app)
[![Next.js](https://img.shields.io/badge/Next.js-14-black?style=flat-square&logo=next.js)](https://nextjs.org)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.5-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org)
[![Supabase](https://img.shields.io/badge/Supabase-PostgreSQL-3ECF8E?style=flat-square&logo=supabase&logoColor=white)](https://supabase.com)
[![License: MIT](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE)
[![Deployed on Vercel](https://img.shields.io/badge/deployed-Vercel-black?style=flat-square&logo=vercel)](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. πŸ™‚
![Version History](./screenshots/versioning01.png)

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

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

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

### πŸ“± Mobile
### Mobile

| | |
| ------------------------------------------------- | ------------------------------------------------- |
| | |
|---|---|
| ![Mobile Screenshot 1](./screenshots/mobile1.png) | ![Mobile Screenshot 2](./screenshots/mobile2.png) |

---

## βš™οΈ 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

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

Expand All @@ -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
```

Expand All @@ -104,43 +128,44 @@ 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
3. In Supabase β†’ Authentication β†’ URL Configuration, add your Vercel domain to Redirect URLs

---

## πŸ›£οΈ 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)
[![GitHub](https://img.shields.io/badge/GitHub-aboderinsamuel-black?style=flat-square&logo=github)](https://github.com/aboderinsamuel)
[![LinkedIn](https://img.shields.io/badge/LinkedIn-samuelaboderin-0A66C2?style=flat-square&logo=linkedin&logoColor=white)](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.*
18 changes: 9 additions & 9 deletions __tests__/components/PromptForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -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");
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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 }));

Expand All @@ -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(() => {
Expand All @@ -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());
Expand All @@ -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());
Expand All @@ -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];
Expand Down
44 changes: 44 additions & 0 deletions app/api/prompts/[id]/versions/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading