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 +[![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 @@ -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) @@ -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) +[![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.* 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() {

Samuel Aboderin

-

Computer Engineering Β· UNILAG Β· v1.0

+

Computer Engineering Β· UNILAG Β· v1.1

@@ -64,6 +63,11 @@ export default function DocsPage() { {String(i + 1).padStart(2, "0")} {item.label} + {item.id === "version-history" && ( + + new + + )} ))} @@ -79,21 +83,97 @@ 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. +

+
+ +
+ + + + + + + + + {[ + ["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]) => ( + + + + + ))} + +
BehaviourCreates new version?
{behaviour} + + {creates} + +
+
+ +
+

Key files

+ +
+
+ +
+ {/* 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

@@ -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() {

# .env.local

-

HUGGINGFACE_API_KEY=hf_...# required - free

-

OPENAI_API_KEY=sk-...# optional - enables OCR

+

NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co# required

+

NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...# required

+

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]) => ( - {table} + + {table} + {table === "prompt_versions" && ( + + new + + )} + {desc} ))} @@ -242,9 +335,10 @@ export default function DocsPage() {