| "use client"; |
|
|
| import { useState, useEffect, useCallback, useTransition } from "react"; |
| import NoteCard from "./NoteCard"; |
| import NoteForm from "./NoteForm"; |
| import QueryBench from "./QueryBench"; |
| import DbInfo from "./DbInfo"; |
| import { |
| fetchNotes, |
| fetchStats, |
| createNote, |
| updateNote, |
| deleteNote, |
| type Note, |
| } from "@/lib/actions"; |
|
|
| type Stats = { |
| total: number; |
| pinned: number; |
| latest: Date | null; |
| colors: { color: string; count: number }[]; |
| }; |
|
|
| type Tab = "notes" | "bench" | "db"; |
|
|
| export default function NotesClient({ |
| initialNotes, |
| initialQueryMs, |
| initialStats, |
| initialStatsMs, |
| }: { |
| initialNotes: Note[]; |
| initialQueryMs: number; |
| initialStats: Stats; |
| initialStatsMs: number; |
| }) { |
| const [notes, setNotes] = useState(initialNotes); |
| const [stats, setStats] = useState(initialStats); |
| const [queryMs, setQueryMs] = useState(initialQueryMs); |
| const [statsMs, setStatsMs] = useState(initialStatsMs); |
| const [search, setSearch] = useState(""); |
| const [editing, setEditing] = useState<Note | null>(null); |
| const [showForm, setShowForm] = useState(false); |
| const [tab, setTab] = useState<Tab>("notes"); |
| const [isPending, startTransition] = useTransition(); |
| const [lastAction, setLastAction] = useState<{ name: string; ms: number } | null>(null); |
|
|
| const reload = useCallback(() => { |
| startTransition(async () => { |
| const [nr, sr] = await Promise.all([ |
| fetchNotes(search || undefined), |
| fetchStats(), |
| ]); |
| setNotes(nr.data); |
| setQueryMs(nr.ms); |
| setStats(sr.data); |
| setStatsMs(sr.ms); |
| }); |
| }, [search]); |
|
|
| useEffect(() => { reload(); }, [reload]); |
|
|
| const onSave = async (data: { title: string; content: string; color: string; pinned: boolean }) => { |
| const r = editing |
| ? await updateNote(editing.id, data) |
| : await createNote(data); |
| setLastAction({ name: editing ? "UPDATE" : "CREATE", ms: r.ms }); |
| setEditing(null); |
| setShowForm(false); |
| reload(); |
| }; |
|
|
| const onDel = async (id: string) => { |
| const r = await deleteNote(id); |
| setLastAction({ name: "DELETE", ms: r.ms }); |
| reload(); |
| }; |
|
|
| const onPin = async (note: Note) => { |
| const r = await updateNote(note.id, { pinned: !note.pinned }); |
| setLastAction({ name: "PIN", ms: r.ms }); |
| reload(); |
| }; |
|
|
| const tabs: { key: Tab; label: string; icon: string }[] = [ |
| { key: "notes", label: "Notes", icon: "π" }, |
| { key: "bench", label: "Benchmark", icon: "β‘" }, |
| { key: "db", label: "Database", icon: "πΎ" }, |
| ]; |
|
|
| return ( |
| <main className="max-w-5xl mx-auto px-3 sm:px-4 py-6 sm:py-8"> |
| <header className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-6"> |
| <h1 className="text-2xl sm:text-3xl font-bold tracking-tight"> |
| <span className="text-emerald-400">β</span> Nexova |
| <span className="text-[10px] text-zinc-600 font-normal ml-2 align-middle">Server Actions</span> |
| </h1> |
| <div className="flex gap-1.5 sm:gap-2"> |
| {tabs.map((t) => ( |
| <button |
| key={t.key} |
| onClick={() => setTab(t.key)} |
| className={`px-3 py-1.5 rounded-lg text-xs sm:text-sm font-medium transition whitespace-nowrap ${ |
| tab === t.key ? "bg-emerald-600 text-white" : "bg-zinc-800 text-zinc-400 hover:text-white" |
| }`} |
| > |
| {t.icon} {t.label} |
| </button> |
| ))} |
| </div> |
| </header> |
| |
| {tab === "notes" && ( |
| <> |
| <div className="flex flex-wrap gap-x-4 gap-y-1 mb-3 text-xs text-zinc-500"> |
| <span>π {stats.total} notes</span> |
| <span>π {stats.pinned} pinned</span> |
| {stats.latest && ( |
| <span className="hidden sm:inline">π {new Date(stats.latest).toLocaleString()}</span> |
| )} |
| <div className="flex gap-1 ml-auto"> |
| {stats.colors.map((c) => ( |
| <span |
| key={c.color} |
| className="w-3 h-3 rounded-full inline-block" |
| style={{ backgroundColor: c.color }} |
| title={`${c.count}`} |
| /> |
| ))} |
| </div> |
| </div> |
| |
| <div className="flex flex-wrap items-center gap-2 mb-4 text-xs text-zinc-500"> |
| <span className="inline-flex items-center gap-1 bg-zinc-900 border border-zinc-800 rounded-md px-2 py-1"> |
| π Query: <span className={`font-mono font-bold ${msColor(queryMs)}`}>{queryMs}ms</span> |
| </span> |
| <span className="inline-flex items-center gap-1 bg-zinc-900 border border-zinc-800 rounded-md px-2 py-1"> |
| π Stats: <span className={`font-mono font-bold ${msColor(statsMs)}`}>{statsMs}ms</span> |
| </span> |
| {lastAction && ( |
| <span className="inline-flex items-center gap-1 bg-zinc-900 border border-emerald-900/50 rounded-md px-2 py-1"> |
| β
{lastAction.name}: <span className={`font-mono font-bold ${msColor(lastAction.ms)}`}>{lastAction.ms}ms</span> |
| </span> |
| )} |
| {isPending && <span className="text-yellow-500 animate-pulse">β³ loading...</span>} |
| <span className="text-zinc-600 ml-auto hidden sm:inline">server-side via Prisma</span> |
| </div> |
| |
| <div className="flex gap-2 sm:gap-3 mb-6"> |
| <input |
| type="text" |
| placeholder="Search notes..." |
| value={search} |
| onChange={(e) => setSearch(e.target.value)} |
| className="flex-1 min-w-0 bg-zinc-900 border border-zinc-800 rounded-lg px-3 sm:px-4 py-2.5 text-sm focus:outline-none focus:border-emerald-500 transition" |
| /> |
| <button |
| onClick={() => { setEditing(null); setShowForm(true); }} |
| className="bg-emerald-600 hover:bg-emerald-500 px-3 sm:px-4 py-2.5 rounded-lg text-sm font-medium transition shrink-0" |
| > |
| + New |
| </button> |
| </div> |
| |
| {showForm && ( |
| <NoteForm |
| initial={editing} |
| onSave={onSave} |
| onCancel={() => { setShowForm(false); setEditing(null); }} |
| /> |
| )} |
| |
| {notes.length === 0 ? ( |
| <div className="text-center py-16 sm:py-20 text-zinc-500"> |
| <p className="text-4xl mb-3">π</p> |
| <p>No notes yet. Create one!</p> |
| </div> |
| ) : ( |
| <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4"> |
| {notes.map((n) => ( |
| <NoteCard |
| key={n.id} |
| note={n} |
| onEdit={() => { setEditing(n); setShowForm(true); }} |
| onDelete={() => onDel(n.id)} |
| onPin={() => onPin(n)} |
| /> |
| ))} |
| </div> |
| )} |
| </> |
| )} |
|
|
| {tab === "bench" && <QueryBench />} |
| {tab === "db" && <DbInfo />} |
| </main> |
| ); |
| } |
|
|
| const msColor = (ms: number) => |
| ms < 1 ? "text-emerald-400" : ms < 5 ? "text-yellow-400" : ms < 20 ? "text-orange-400" : "text-red-400"; |
|
|