Nexova / src /components /NotesClient.tsx
Nexova
refactor: server actions + SSR, fix backup/restore scripts, responsive
4a70373
"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";