From 5c49ca8b2afdedd33179586df122d8addac1a5ec Mon Sep 17 00:00:00 2001 From: Samuel Aboderin Date: Mon, 16 Mar 2026 10:20:31 +0100 Subject: [PATCH 01/10] feat: add prompt version history with diff view --- README.md | 2 +- app/api/prompts/[id]/versions/route.ts | 39 ++++++ app/docs/page.tsx | 2 +- components/PromptDetail.tsx | 50 ++++++- components/VersionHistory.tsx | 145 ++++++++++++++++++++ lib/promptData.ts | 19 +++ lib/types.ts | 9 ++ package-lock.json | 15 ++ package.json | 2 + supabase/migrations/004_prompt_versions.sql | 30 ++++ 10 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 app/api/prompts/[id]/versions/route.ts create mode 100644 components/VersionHistory.tsx create mode 100644 supabase/migrations/004_prompt_versions.sql diff --git a/README.md b/README.md index 3ef8b49..57483ea 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ AI features are optional β€” users add their own OpenAI or HuggingFace key in Se ## πŸ›£οΈ Open Issues & Roadmap -See the [open issues](https://github.com/aboderinsamuel/closedNote_v0.01/issues) for what's being worked on. +See the [open issues](https://github.com/aboderinsamuel/closedNote/issues) for what's being worked on. Got ideas? Dark mode themes, AI tag suggestions, team sharing, prompt history β€” contributions welcome! diff --git a/app/api/prompts/[id]/versions/route.ts b/app/api/prompts/[id]/versions/route.ts new file mode 100644 index 0000000..0d5479b --- /dev/null +++ b/app/api/prompts/[id]/versions/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; + +export async function GET( + _req: NextRequest, + { params }: { params: { id: string } } +) { + const supabase = createRouteHandlerClient({ cookies }); + + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + 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..be35adb 100644 --- a/app/docs/page.tsx +++ b/app/docs/page.tsx @@ -313,7 +313,7 @@ export default function DocsPage() {
diff --git a/components/PromptDetail.tsx b/components/PromptDetail.tsx index e9c5cce..5d9ce78 100644 --- a/components/PromptDetail.tsx +++ b/components/PromptDetail.tsx @@ -2,10 +2,11 @@ import Link from "next/link"; import { useState } from "react"; -import { Prompt } from "@/lib/types"; +import { Prompt, PromptVersion } from "@/lib/types"; import { deletePrompt, savePrompt } from "@/lib/promptData"; import { useRouter } from "next/navigation"; import { usePrompts } from "@/lib/hooks/usePrompts"; +import { VersionHistory } from "@/components/VersionHistory"; interface PromptDetailProps { prompt: Prompt; @@ -19,6 +20,8 @@ export function PromptDetail({ prompt }: PromptDetailProps) { const [content, setContent] = useState(prompt.content); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const [showHistory, setShowHistory] = useState(false); + const [historyKey, setHistoryKey] = useState(0); const router = useRouter(); const { removeOptimistic, updateOptimistic } = usePrompts(); @@ -58,6 +61,7 @@ export function PromptDetail({ prompt }: PromptDetailProps) { setLocalPrompt(updated); updateOptimistic(updated); setEditing(false); + setHistoryKey((k) => k + 1); } catch (err) { setError("Failed to save changes. Please try again."); console.error("Error saving prompt:", err); @@ -66,6 +70,29 @@ export function PromptDetail({ prompt }: PromptDetailProps) { } }; + const handleRestore = async (version: PromptVersion) => { + if (!confirm(`Restore v${version.versionNumber}? This will save it as a new version.`)) return; + const restored = { + ...localPrompt, + title: version.title, + content: version.content, + }; + setTitle(restored.title); + setContent(restored.content); + setSaving(true); + try { + await savePrompt({ ...restored, updatedAt: new Date().toISOString() }); + setLocalPrompt(restored); + updateOptimistic(restored); + setHistoryKey((k) => k + 1); + } catch (err) { + setError("Failed to restore version."); + console.error(err); + } finally { + setSaving(false); + } + }; + return (
Edit +
+ + {showHistory && ( +
+

+ Version History +

+ +
+ )}
); } diff --git a/components/VersionHistory.tsx b/components/VersionHistory.tsx new file mode 100644 index 0000000..cb49470 --- /dev/null +++ b/components/VersionHistory.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { PromptVersion } from "@/lib/types"; +import { diff_match_patch } from "diff-match-patch"; + +interface VersionHistoryProps { + promptId: string; + currentContent: string; + currentTitle: string; + onRestore: (version: PromptVersion) => void; +} + +export function VersionHistory({ + promptId, + currentContent, + currentTitle, + onRestore, +}: VersionHistoryProps) { + const [versions, setVersions] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedVersion, setSelectedVersion] = useState(null); + const [diff, setDiff] = useState<{ html: string } | null>(null); + + useEffect(() => { + const fetchVersions = async () => { + setLoading(true); + try { + const res = await fetch(`/api/prompts/${promptId}/versions`); + if (res.ok) { + const data: PromptVersion[] = await res.json(); + setVersions(data); + } + } catch (err) { + console.error("[VersionHistory] Failed to fetch versions:", err); + } finally { + setLoading(false); + } + }; + + fetchVersions(); + }, [promptId]); + + const handleSelectVersion = (version: PromptVersion) => { + setSelectedVersion(version); + + const dmp = new diff_match_patch(); + const diffs = dmp.diff_main(version.content, currentContent); + dmp.diff_cleanupSemantic(diffs); + + const html = diffs + .map(([op, text]) => { + const escaped = text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\n/g, "
"); + if (op === 1) return `${escaped}`; + if (op === -1) return `${escaped}`; + return `${escaped}`; + }) + .join(""); + + setDiff({ html }); + }; + + if (loading) { + return ( +
+ Loading history... +
+ ); + } + + if (versions.length === 0) { + return ( +
+ No versions yet. Save an edit to start tracking history. +
+ ); + } + + return ( +
+ + +
+ {/* Timeline */} +
+ {versions.map((v) => ( + + ))} +
+ + {/* Diff Panel */} +
+ {selectedVersion ? ( +
+
+ + v{selectedVersion.versionNumber} vs current + + +
+
+            
+ ) : ( +
+ Select a version to see what changed. +
+ )} +
+
+
+ ); +} diff --git a/lib/promptData.ts b/lib/promptData.ts index 8803ff3..e4b931e 100644 --- a/lib/promptData.ts +++ b/lib/promptData.ts @@ -80,6 +80,25 @@ export async function savePrompt(prompt: Prompt): Promise { }); if (upsertError) throw upsertError; + + // Count existing versions to determine next version number + const { count } = await supabase + .from("prompt_versions") + .select("*", { count: "exact", head: true }) + .eq("prompt_id", prompt.id); + + const { error: versionError } = await supabase + .from("prompt_versions") + .insert({ + prompt_id: prompt.id, + title: prompt.title, + content: prompt.content, + version_number: (count ?? 0) + 1, + }); + + if (versionError) { + console.error("[promptData] Failed to save version:", versionError); + } } catch (err) { console.error("[promptData] Error saving prompt:", err); throw err; diff --git a/lib/types.ts b/lib/types.ts index 43a018e..4df642a 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -40,3 +40,12 @@ export interface ChainStep { inputMapping?: Record createdAt: string } + +export interface PromptVersion { + id: string + promptId: string + title: string + content: string + versionNumber: number + createdAt: string +} diff --git a/package-lock.json b/package-lock.json index 1629475..303a03e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@supabase/supabase-js": "^2.80.0", "@vercel/analytics": "^1.5.0", + "diff-match-patch": "^1.0.5", "next": "^14.2.0", "openai": "^6.27.0", "react": "^18.3.1", @@ -20,6 +21,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/diff-match-patch": "^1.0.36", "@types/jest": "^30.0.0", "@types/node": "^20.14.0", "@types/react": "^18.3.0", @@ -2210,6 +2212,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/diff-match-patch": { + "version": "1.0.36", + "resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", + "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -4205,6 +4214,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "license": "Apache-2.0" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", diff --git a/package.json b/package.json index a4a52ac..c934ffb 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "@supabase/supabase-js": "^2.80.0", "@vercel/analytics": "^1.5.0", + "diff-match-patch": "^1.0.5", "next": "^14.2.0", "openai": "^6.27.0", "react": "^18.3.1", @@ -23,6 +24,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/diff-match-patch": "^1.0.36", "@types/jest": "^30.0.0", "@types/node": "^20.14.0", "@types/react": "^18.3.0", diff --git a/supabase/migrations/004_prompt_versions.sql b/supabase/migrations/004_prompt_versions.sql new file mode 100644 index 0000000..e6f9443 --- /dev/null +++ b/supabase/migrations/004_prompt_versions.sql @@ -0,0 +1,30 @@ +create table prompt_versions ( + id uuid default gen_random_uuid() primary key, + prompt_id uuid references prompts(id) on delete cascade not null, + title text not null, + content text not null, + version_number int not null, + created_at timestamptz default now() not null +); + +alter table prompt_versions enable row level security; + +create policy "Users can read their own prompt versions" + on prompt_versions for select + using ( + exists ( + select 1 from prompts + where prompts.id = prompt_versions.prompt_id + and prompts.user_id = auth.uid() + ) + ); + +create policy "Users can insert their own prompt versions" + on prompt_versions for insert + with check ( + exists ( + select 1 from prompts + where prompts.id = prompt_versions.prompt_id + and prompts.user_id = auth.uid() + ) + ); From 3287ed091c764e487cf6a223d87a704af6d6d6bd Mon Sep 17 00:00:00 2001 From: Samuel Aboderin Date: Mon, 16 Mar 2026 10:34:00 +0100 Subject: [PATCH 02/10] feat: add prompt version history with diff view --- app/api/prompts/[id]/versions/route.ts | 25 ++++++++++++++---------- components/VersionHistory.tsx | 8 +++++++- lib/database.types.ts | 27 ++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/app/api/prompts/[id]/versions/route.ts b/app/api/prompts/[id]/versions/route.ts index 0d5479b..55559fb 100644 --- a/app/api/prompts/[id]/versions/route.ts +++ b/app/api/prompts/[id]/versions/route.ts @@ -1,21 +1,26 @@ import { NextRequest, NextResponse } from "next/server"; -import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; -import { cookies } from "next/headers"; +import { createClient } from "@supabase/supabase-js"; +import { getUserFromRequest } from "@/lib/supabase-server"; + +function createServerSupabase() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ""; + const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ""; + return createClient(url, key, { + auth: { persistSession: false, autoRefreshToken: false }, + }); +} export async function GET( - _req: NextRequest, + req: NextRequest, { params }: { params: { id: string } } ) { - const supabase = createRouteHandlerClient({ cookies }); - - const { - data: { session }, - } = await supabase.auth.getSession(); - - if (!session?.user) { + const user = await getUserFromRequest(req); + if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const supabase = createServerSupabase(); + const { data, error } = await supabase .from("prompt_versions") .select("*") diff --git a/components/VersionHistory.tsx b/components/VersionHistory.tsx index cb49470..a6294dc 100644 --- a/components/VersionHistory.tsx +++ b/components/VersionHistory.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { PromptVersion } from "@/lib/types"; import { diff_match_patch } from "diff-match-patch"; +import { supabase } from "@/lib/supabase"; interface VersionHistoryProps { promptId: string; @@ -26,7 +27,12 @@ export function VersionHistory({ const fetchVersions = async () => { setLoading(true); try { - const res = await fetch(`/api/prompts/${promptId}/versions`); + const { data: { session } } = await supabase.auth.getSession(); + const res = await fetch(`/api/prompts/${promptId}/versions`, { + headers: session?.access_token + ? { Authorization: `Bearer ${session.access_token}` } + : {}, + }); if (res.ok) { const data: PromptVersion[] = await res.json(); setVersions(data); diff --git a/lib/database.types.ts b/lib/database.types.ts index ae71a5a..9a9adeb 100644 --- a/lib/database.types.ts +++ b/lib/database.types.ts @@ -153,6 +153,33 @@ export interface Database { } Relationships: [] } + prompt_versions: { + Row: { + id: string + prompt_id: string + title: string + content: string + version_number: number + created_at: string + } + Insert: { + id?: string + prompt_id: string + title: string + content: string + version_number: number + created_at?: string + } + Update: { + id?: string + prompt_id?: string + title?: string + content?: string + version_number?: number + created_at?: string + } + Relationships: [] + } } Views: { [_ in never]: never From fb736b1c5f8d09697fe83abfb827d7dd4a806e9e Mon Sep 17 00:00:00 2001 From: Samuel Aboderin Date: Mon, 16 Mar 2026 10:45:24 +0100 Subject: [PATCH 03/10] fix: pass user JWT to supabase in versions route for RLS --- app/api/prompts/[id]/versions/route.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/app/api/prompts/[id]/versions/route.ts b/app/api/prompts/[id]/versions/route.ts index 55559fb..5b7b21c 100644 --- a/app/api/prompts/[id]/versions/route.ts +++ b/app/api/prompts/[id]/versions/route.ts @@ -1,25 +1,25 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@supabase/supabase-js"; -import { getUserFromRequest } from "@/lib/supabase-server"; - -function createServerSupabase() { - const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ""; - const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ""; - return createClient(url, key, { - auth: { persistSession: false, autoRefreshToken: false }, - }); -} export async function GET( req: NextRequest, { params }: { params: { id: string } } ) { - const user = await getUserFromRequest(req); - if (!user) { + 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 supabase = createServerSupabase(); + 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") From 1c539b94f9fa21c1d01e18a60738e9078dea5f77 Mon Sep 17 00:00:00 2001 From: Samuel Aboderin Date: Mon, 16 Mar 2026 10:54:44 +0100 Subject: [PATCH 04/10] improve: auto-resize textarea, keyboard shortcuts, paste cleanup --- components/PromptDetail.tsx | 135 +++++++++++++++++++++++++++++------- components/PromptForm.tsx | 73 +++++++++++++++---- 2 files changed, 168 insertions(+), 40 deletions(-) diff --git a/components/PromptDetail.tsx b/components/PromptDetail.tsx index 5d9ce78..237ad91 100644 --- a/components/PromptDetail.tsx +++ b/components/PromptDetail.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useState } from "react"; +import { useState, useRef, useEffect, useCallback } from "react"; import { Prompt, PromptVersion } from "@/lib/types"; import { deletePrompt, savePrompt } from "@/lib/promptData"; import { useRouter } from "next/navigation"; @@ -24,16 +24,52 @@ export function PromptDetail({ prompt }: PromptDetailProps) { const [historyKey, setHistoryKey] = useState(0); const router = useRouter(); const { removeOptimistic, updateOptimistic } = usePrompts(); + const titleRef = useRef(null); + const textareaRef = useRef(null); + + // Auto-resize textarea to fit content + const resizeTextarea = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = el.scrollHeight + "px"; + }, []); + + // Auto-focus title and resize textarea when entering edit mode + useEffect(() => { + if (editing) { + titleRef.current?.focus(); + setTimeout(resizeTextarea, 0); + } + }, [editing, resizeTextarea]); + + // Keyboard shortcuts: Cmd/Ctrl+S to save, Escape to cancel + useEffect(() => { + if (!editing) return; + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "s") { + e.preventDefault(); + handleSave(); + } + if (e.key === "Escape") { + setTitle(localPrompt.title); + setContent(localPrompt.content); + setEditing(false); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editing, localPrompt]); const handleCopy = async () => { - await navigator.clipboard.writeText(prompt.content); + await navigator.clipboard.writeText(localPrompt.content); setCopied(true); setTimeout(() => setCopied(false), 2000); }; const handleDelete = async () => { if (!confirm("Delete this prompt? This cannot be undone.")) return; - try { removeOptimistic(prompt.id); await deletePrompt(prompt.id); @@ -47,16 +83,14 @@ export function PromptDetail({ prompt }: PromptDetailProps) { const handleSave = async () => { setSaving(true); setError(null); - try { const now = new Date().toISOString(); const updated = { ...prompt, - title: title.trim() || prompt.title, - content: content.trim() || prompt.content, + title: title.trim() || localPrompt.title, + content: content.trim() || localPrompt.content, updatedAt: now, }; - await savePrompt(updated); setLocalPrompt(updated); updateOptimistic(updated); @@ -101,23 +135,30 @@ export function PromptDetail({ prompt }: PromptDetailProps) { > ← Back to prompts + {error && (
{error}
)} +
+ {/* Title */} {editing ? ( setTitle(e.target.value)} - className="w-full px-4 py-2 mb-3 bg-neutral-100 dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-700 rounded-md text-neutral-900 dark:text-neutral-100 text-2xl font-semibold" + placeholder="Prompt title" + className="w-full px-3 py-2 mb-3 bg-neutral-100 dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-700 rounded-md text-neutral-900 dark:text-neutral-100 text-2xl font-semibold focus:outline-none focus:ring-2 focus:ring-neutral-400 dark:focus:ring-neutral-600" /> ) : (

{localPrompt.title}

)} + + {/* Metadata badges */}
{localPrompt.collection} @@ -127,25 +168,62 @@ export function PromptDetail({ prompt }: PromptDetailProps) {
+ {/* Content area */}
Prompt - + {!editing && ( + + )}
+ {editing ? ( -