diff --git a/README.md b/README.md index 8afb38b..0b829f1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ closedNote +

+ # closedNote > **Prompts are living documents. closedNote is the only prompt manager that remembers how they evolved.** @@ -9,73 +13,118 @@ [![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) +**πŸ“Š Project Scope: 13 API Routes Β· 17 React Components Β· 25 Passing Tests** + --- -## Explanation +## 🧠 The Story -PromptBase stores prompts. Notion organizes them. FlowGPT shares them. None of them remember how they got there. +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. -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. +Meanwhile, prompt engineers were dropping tips on X and Stack Overflow, but nobody had a good place to store, iterate on, and *remember* them. -closedNote is built on one thesis: **a prompt is not a sticky note. It's a document with a history.** +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. 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. --- -## Version History, git for your prompts +## βš–οΈ How We Compare -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. +

+ compare +

-![Version History](./screenshots/versioning01.png) +--- -- 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 +## πŸ—οΈ Architecture & Fault Tolerance ---- +closedNote is a full-stack **Next.js 14** application backed by **Supabase (PostgreSQL + Auth)**. The core architectural goal is simple: **every feature that matters (writing, versioning, change tracking, searching, chaining) must feel instant, safe, and impossible to lose**. + +### Versioning and Change Tracking (Core System) + +- **Automatic version snapshots** are created only when content actually changes (no noisy duplicates). +- Every version is **comparable and restorable**, so you can experiment freely without fear of losing a working draft. + +### Prompt Threads (Multi-step Workflows) + +- **Threads** model prompts as ordered steps where each step can be refined independently. +- Each step has its **own version history**, so workflows evolve without breaking the whole chain. -## All Features +### Search & Navigation (Fast Retrieval) -- **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 +- A global **command palette (`⌘K` / `Ctrl+K`)** provides fast navigation across prompts, collections, and threads. +- Search is designed to keep your library usable even as it scales. + +### Privacy and Security by Design + +- **Supabase Row-Level Security (RLS)** enforces access control at the database layer. +- Every prompt, version, collection, and thread is strictly scoped to the authenticated user’s ID. --- -## Demo +## ✨ Core Features -### Dashboard +### 1. Prompt Threads (Chains) -![Desktop Screenshot 1](./screenshots/desktop1.png) +This is closedNote's biggest differentiator. Instead of writing one massive, brittle prompt, you can break complex tasks into discrete, testable steps. Chain prompts into multi-step workflows where each output feeds the next. -![Desktop Screenshot 2](./screenshots/desktop2.png) +

+ Prompt chain workflow showing sequential steps +

-### Prompt Editor +### 2. Version History (Git for Prompts) -![Desktop Screenshot 3](./screenshots/desktop3.png) +Every edit is automatically snapshotted. Powered by Google's `diff-match-patch` algorithm, you get a visual, character-level diff of what changed (additions in green, removals in red) and one-click restoration without losing your timeline. -### Image to Text (OCR) +

+ Version history with visual character diffs +

+ +### 3. AI Refinement & Organization + +Turn rough ideas into structured prompts using your own API keys. Group prompts into Collections, and instantly search your entire library via the `⌘K` command palette. + +--- + +## βœ… All Features -![OCR Feature](./screenshots/OCR.png) +- **Version History** - track every draft with a visual diff and one-click restore +- **Prompt Threads** - link prompts into multi-step workflows where each output feeds the next +- **OCR Import** - upload a screenshot or photo, extract the text, save it as a prompt +- **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 +- **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 -### Mobile +--- + +## 🎬 Demo + +### Dashboard | | | -|---|---| -| ![Mobile Screenshot 1](./screenshots/mobile1.png) | ![Mobile Screenshot 2](./screenshots/mobile2.png) | +|--|--| +| ![Desktop Screenshot 1](./screenshots/dashboard-light.png) | ![Desktop Screenshot 2](./screenshots/dashboard-dark.png) | + +### Prompt Editor + +

+ prompt editor +

+ +### Image to Text (OCR) + +

+ Version history with visual character diffs +

--- -## Tech Stack +## 🧱 Tech Stack | Layer | Technology | |---|---| @@ -89,7 +138,7 @@ Users without API keys get full prompt management + offline OCR. AI features unl --- -## Tests +## πŸ§ͺ Tests ![Test Results](./screenshots/test.png) @@ -98,20 +147,9 @@ Users without API keys get full prompt management + offline OCR. AI features unl ```bash npm test ``` - ---- - -## 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 +## ⚑ Run Locally ```bash git clone https://github.com/aboderinsamuel/closedNote.git @@ -132,7 +170,7 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key --- -## Deploy +## πŸš€ Deploy 1. Fork this repo 2. Import to [Vercel](https://vercel.com) and add the two env vars above @@ -140,7 +178,7 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key --- -## Contributing +## 🀝 Contributing Got ideas? Contributions welcome. @@ -153,7 +191,7 @@ See [open issues](https://github.com/aboderinsamuel/closedNote/issues) for what' --- -## Built by +## πŸ‘€ Built by **Samuel Aboderin**, Computer Engineering, UNILAG πŸ‡³πŸ‡¬ diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 89c2651..187e470 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -76,7 +76,6 @@ async function callOpenAI( export async function POST(req: Request) { try { - // Verify the caller is an authenticated user before doing any work const user = await getUserFromRequest(req); if (!user) { return NextResponse.json( diff --git a/app/api/ocr/route.ts b/app/api/ocr/route.ts index 5e742f4..4a0d495 100644 --- a/app/api/ocr/route.ts +++ b/app/api/ocr/route.ts @@ -41,7 +41,6 @@ async function callOpenAIVision(apiKey: string, arrayBuf: ArrayBuffer, mimeType: export async function POST(request: Request) { try { - // Verify the caller is an authenticated user before doing any work const user = await getUserFromRequest(request); if (!user) { return NextResponse.json( diff --git a/app/auth/reset-password/page.tsx b/app/auth/reset-password/page.tsx index 93e14fd..783cc9d 100644 --- a/app/auth/reset-password/page.tsx +++ b/app/auth/reset-password/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import Image from "next/image"; import { useRouter } from "next/navigation"; import { supabase } from "@/lib/supabase"; @@ -8,17 +9,18 @@ type Stage = "exchanging" | "form" | "success" | "error"; const MIN_PASSWORD_LENGTH = 6; -const EyeIcon = ({ open }: { open: boolean }) => - open ? ( - +function EyeIcon({ open, style }: { open: boolean; style?: React.CSSProperties }) { + return open ? ( + ) : ( - + ); +} export default function ResetPasswordPage() { const router = useRouter(); @@ -34,22 +36,14 @@ export default function ResetPasswordPage() { const code = new URLSearchParams(window.location.search).get("code"); if (code) { - // PKCE flow: email contains ?code= supabase.auth.exchangeCodeForSession(code).then(({ error }) => { - if (error) { - setErrorMsg(error.message); - setStage("error"); - } else { - setStage("form"); - } + if (error) { setErrorMsg(error.message); setStage("error"); } + else { setStage("form"); } }); return; } - // Implicit flow: email contains #access_token=...&type=recovery - // detectSessionInUrl:true parses the hash and fires PASSWORD_RECOVERY let timeoutId: ReturnType; - const { data: { subscription } } = supabase.auth.onAuthStateChange((event) => { if (event === "PASSWORD_RECOVERY") { clearTimeout(timeoutId); @@ -64,61 +58,83 @@ export default function ResetPasswordPage() { setStage("error"); }, 10000); - return () => { - clearTimeout(timeoutId); - subscription.unsubscribe(); - }; + return () => { clearTimeout(timeoutId); subscription.unsubscribe(); }; }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (password.length < MIN_PASSWORD_LENGTH) return; if (password !== confirm) return; - setSaving(true); const { error } = await supabase.auth.updateUser({ password }); setSaving(false); - - if (error) { - setErrorMsg(error.message); - setStage("error"); - } else { - setStage("success"); - setTimeout(() => router.push("/dashboard"), 2500); - } + if (error) { setErrorMsg(error.message); setStage("error"); } + else { setStage("success"); setTimeout(() => router.push("/dashboard"), 2500); } }; const meetsLength = password.length >= MIN_PASSWORD_LENGTH; const meetsMatch = password === confirm && confirm.length > 0; - const inputClass = - "w-full px-3.5 py-2.5 bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-neutral-400 dark:focus:ring-neutral-500 transition-shadow"; + const inputStyle: React.CSSProperties = { + width: "100%", padding: "10px 14px", + background: "var(--cn-bg-s2)", border: "1px solid var(--cn-border)", + borderRadius: 10, fontSize: 14, color: "var(--cn-text)", + outline: "none", transition: "border-color 0.15s", + boxSizing: "border-box", + }; + + const labelStyle: React.CSSProperties = { + display: "block", fontSize: 11, fontWeight: 700, + textTransform: "uppercase", letterSpacing: "0.08em", + color: "var(--cn-muted)", marginBottom: 6, + }; return ( -
-
-
+
+
+ {/* Logo */} +
+ closedNote +
+ +
{stage === "exchanging" && ( -
-
-

Verifying link...

+
+
+

Verifying link...

)} {stage === "form" && ( <> -
-

Set new password

-

Choose a strong password for your account.

+
+

+ Set new password +

+

+ Choose a strong password for your account. +

-
+
- -
+ +
-
- +
+ {password.length > 0 && ( - meetsLength - ? - : + + {meetsLength + ? + : + } + )} - + At least {MIN_PASSWORD_LENGTH} characters
- -
+ +
{confirm.length > 0 && !meetsMatch && ( -

Passwords don't match

+

Passwords don't match

)}
@@ -195,35 +220,36 @@ export default function ResetPasswordPage() { )} {stage === "success" && ( -
-
- +
+
+
-

Password updated

-

Redirecting you to your dashboard...

+

Password updated

+

Redirecting you to your dashboard...

)} {stage === "error" && ( -
diff --git a/app/chains/[id]/page.tsx b/app/chains/[id]/page.tsx index 2d2745b..042474e 100644 --- a/app/chains/[id]/page.tsx +++ b/app/chains/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import Link from "next/link"; import { useRouter, useParams } from "next/navigation"; import { Header } from "@/components/Header"; @@ -189,96 +189,96 @@ export default function ChainDetailPage() { return null; } + const labelStyle: React.CSSProperties = { + display: "block", fontSize: 11, fontWeight: 700, + textTransform: "uppercase", letterSpacing: "0.08em", + color: "var(--cn-muted)", marginBottom: 6, + }; + const inputStyle: React.CSSProperties = { + width: "100%", padding: "9px 12px", + background: "var(--cn-bg-s2)", border: "1px solid var(--cn-border)", + borderRadius: 8, fontSize: 13, color: "var(--cn-text)", + outline: "none", transition: "border-color 0.15s", + boxSizing: "border-box", + }; + return ( - } sidebar={null}> -
- - ← Back to Chains - - - {/* Loading state */} + } sidebar={null}> +
+
+ (e.currentTarget.style.color = "var(--cn-text)")} + onMouseLeave={e => (e.currentTarget.style.color = "var(--cn-muted)")} + > + ← Back to Threads + +
+ + {/* Loading */} {loading && ( -
-
- Loading chain... -
+
+ Loading thread...
)} - {/* Error state (when chain not found) */} + {/* Error - not found */} {!loading && error && !chain && ( -
-

- {error} -

+
+

{error}

- Back to Chains + Back to Threads
)} - {/* Chain content */} + {/* Content */} {!loading && chain && ( <> - {/* Inline error banner */} {error && ( -
+
{error}
)} - {/* ====== EDIT MODE ====== */} + {/* ── EDIT MODE ── */} {editing ? ( <> -
+
- + setEditTitle(e.target.value)} - placeholder="Chain title" - className="w-full px-3 py-2 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-neutral-400 dark:focus:ring-neutral-600" + placeholder="Thread title" + style={inputStyle} />
-