Download src/components/NotesClient.tsx from jay-hank/Nexova: direct link, hf CLI and curl.
- Browser
- Download file 7.2 kB
-
https://huggingface.co/spaces/jay-hank/Nexova/resolve/main/src/components/NotesClient.tsx
- Command line
-
hf download hf://spaces/jay-hank/Nexova/src/components/NotesClient.tsx
-
curl -L -o NotesClient.tsx https://huggingface.co/spaces/jay-hank/Nexova/resolve/main/src/components/NotesClient.tsx
7.2 kB
| "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"; | |