Nexova commited on
Commit
debd125
·
0 Parent(s):

init: nexova - notes crud + sqlite + hf backup

Browse files
.dockerignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ node_modules
2
+ .next
3
+ data
4
+ *.db
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ node_modules
2
+ .next
3
+ data
4
+ *.db
AGENTS.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ <!-- BEGIN:nextjs-agent-rules -->
2
+ # This is NOT the Next.js you know
3
+
4
+ This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
5
+ <!-- END:nextjs-agent-rules -->
CLAUDE.md ADDED
@@ -0,0 +1 @@
 
 
1
+ @AGENTS.md
Dockerfile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine AS base
2
+
3
+ FROM base AS deps
4
+ WORKDIR /app
5
+ COPY package.json package-lock.json ./
6
+ RUN npm ci --omit=dev
7
+
8
+ FROM base AS build
9
+ WORKDIR /app
10
+ COPY package.json package-lock.json ./
11
+ RUN npm ci
12
+ COPY . .
13
+ RUN npm run build
14
+
15
+ FROM base AS runner
16
+ WORKDIR /app
17
+ ENV NODE_ENV=production
18
+ ENV PORT=7860
19
+ ENV HOSTNAME=0.0.0.0
20
+
21
+ RUN addgroup -g 1001 -S app && adduser -S app -u 1001
22
+ RUN mkdir -p /app/data && chown -R app:app /app
23
+
24
+ COPY --from=deps /app/node_modules ./node_modules
25
+ COPY --from=build /app/.next/standalone ./
26
+ COPY --from=build /app/.next/static ./.next/static
27
+ COPY --from=build /app/public ./public
28
+ COPY scripts ./scripts
29
+ RUN chmod +x scripts/*.sh
30
+
31
+ USER app
32
+ EXPOSE 7860
33
+
34
+ CMD ["bash", "scripts/start.sh"]
README.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Nexova
3
+ emoji: ◆
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
eslint.config.mjs ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
next-env.d.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+ import "./.next/types/routes.d.ts";
4
+
5
+ // NOTE: This file should not be edited
6
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
next.config.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {
4
+ output: "standalone",
5
+ serverExternalPackages: ["sql.js"],
6
+ };
7
+
8
+ export default nextConfig;
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "nexova",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "eslint"
10
+ },
11
+ "dependencies": {
12
+ "next": "16.2.6",
13
+ "react": "19.2.4",
14
+ "react-dom": "19.2.4",
15
+ "sql.js": "^1.14.1",
16
+ "uuid": "^14.0.0"
17
+ },
18
+ "devDependencies": {
19
+ "@tailwindcss/postcss": "^4",
20
+ "@types/node": "^20",
21
+ "@types/react": "^19",
22
+ "@types/react-dom": "^19",
23
+ "eslint": "^9",
24
+ "eslint-config-next": "16.2.6",
25
+ "tailwindcss": "^4",
26
+ "typescript": "^5"
27
+ }
28
+ }
postcss.config.mjs ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ };
6
+
7
+ export default config;
public/file.svg ADDED
public/globe.svg ADDED
public/next.svg ADDED
public/vercel.svg ADDED
public/window.svg ADDED
scripts/backup.sh ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ REPO="${HF_DATASET:-jay-hank/Nexova-storage}"
5
+ TOKEN="${HF_TOKEN}"
6
+ DB_DIR="./data"
7
+ DB_FILE="nexova.db"
8
+ INTERVAL="${BACKUP_INTERVAL:-1800}"
9
+
10
+ if [ -z "$TOKEN" ]; then
11
+ echo "[backup] HF_TOKEN not set, disabled"
12
+ exit 0
13
+ fi
14
+
15
+ echo "[backup] loop every ${INTERVAL}s → $REPO"
16
+
17
+ while true; do
18
+ sleep "$INTERVAL"
19
+ if [ -f "$DB_DIR/$DB_FILE" ]; then
20
+ SIZE=$(stat -c%s "$DB_DIR/$DB_FILE" 2>/dev/null || echo 0)
21
+ echo "[backup] uploading $DB_FILE ($SIZE bytes)..."
22
+ curl -s -X POST "https://huggingface.co/api/datasets/$REPO/upload/main" \
23
+ -H "Authorization: Bearer $TOKEN" \
24
+ -F "file=@$DB_DIR/$DB_FILE;filename=$DB_FILE" \
25
+ -o /dev/null -w "HTTP %{http_code}\n"
26
+ echo "[backup] done at $(date -u +%FT%TZ)"
27
+ fi
28
+ done
scripts/restore.sh ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ REPO="${HF_DATASET:-jay-hank/Nexova-storage}"
5
+ TOKEN="${HF_TOKEN}"
6
+ DB_DIR="./data"
7
+ DB_FILE="nexova.db"
8
+
9
+ mkdir -p "$DB_DIR"
10
+
11
+ if [ -z "$TOKEN" ]; then
12
+ echo "[restore] HF_TOKEN not set, skip"
13
+ exit 0
14
+ fi
15
+
16
+ echo "[restore] downloading $DB_FILE from $REPO..."
17
+ STATUS=$(curl -s -o "$DB_DIR/$DB_FILE" -w "%{http_code}" \
18
+ "https://huggingface.co/api/datasets/$REPO/raw/$DB_FILE" \
19
+ -H "Authorization: Bearer $TOKEN")
20
+
21
+ if [ "$STATUS" = "200" ]; then
22
+ SIZE=$(stat -c%s "$DB_DIR/$DB_FILE" 2>/dev/null || echo 0)
23
+ echo "[restore] done ($SIZE bytes)"
24
+ else
25
+ echo "[restore] no backup found (HTTP $STATUS), starting fresh"
26
+ rm -f "$DB_DIR/$DB_FILE"
27
+ fi
scripts/start.sh ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ echo "=== Nexova Start ==="
5
+
6
+ bash ./scripts/restore.sh
7
+
8
+ echo "[start] launching backup daemon..."
9
+ bash ./scripts/backup.sh &
10
+
11
+ echo "[start] starting Next.js..."
12
+ exec node server.js
src/app/api/backup/route.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { DB_PATH, DB_DIR } from "@/lib/db";
2
+ import { existsSync, readdirSync, statSync } from "fs";
3
+ import { join } from "path";
4
+ import { execSync } from "child_process";
5
+
6
+ export async function POST(req: Request) {
7
+ const { action } = await req.json().catch(() => ({ action: "backup" }));
8
+
9
+ if (action === "info") {
10
+ const files = existsSync(DB_DIR)
11
+ ? readdirSync(DB_DIR).map((f) => {
12
+ const s = statSync(join(DB_DIR, f));
13
+ return { name: f, size: `${(s.size / 1024).toFixed(1)}KB`, modified: s.mtime.toISOString() };
14
+ })
15
+ : [];
16
+ return Response.json({ ok: true, data: { dir: DB_DIR, files } });
17
+ }
18
+
19
+ if (action === "backup") {
20
+ if (!existsSync(DB_PATH)) {
21
+ return Response.json({ ok: false, error: "no database file" }, { status: 400 });
22
+ }
23
+ try {
24
+ const token = process.env.HF_TOKEN;
25
+ const repo = process.env.HF_DATASET ?? "jay-hank/Nexova-storage";
26
+ if (!token) return Response.json({ ok: false, error: "HF_TOKEN not set" }, { status: 500 });
27
+
28
+ const cmd = `curl -s -X PUT "https://huggingface.co/api/datasets/${repo}/upload" `
29
+ + `-H "Authorization: Bearer ${token}" `
30
+ + `-F "file=@${DB_PATH};filename=nexova.db"`;
31
+ const out = execSync(cmd, { timeout: 30000 }).toString();
32
+ return Response.json({ ok: true, message: "backup done", detail: out });
33
+ } catch (e: unknown) {
34
+ const msg = e instanceof Error ? e.message : String(e);
35
+ return Response.json({ ok: false, error: msg }, { status: 500 });
36
+ }
37
+ }
38
+
39
+ return Response.json({ ok: false, error: `unknown action: ${action}` }, { status: 400 });
40
+ }
src/app/api/health/route.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { existsSync, statSync } from "fs";
2
+ import { DB_PATH } from "@/lib/db";
3
+
4
+ export async function GET() {
5
+ const dbExists = existsSync(DB_PATH);
6
+ const dbSize = dbExists ? statSync(DB_PATH).size : 0;
7
+ return Response.json({
8
+ ok: true,
9
+ data: {
10
+ status: "running",
11
+ db: dbExists ? "connected" : "missing",
12
+ dbSize: `${(dbSize / 1024).toFixed(1)}KB`,
13
+ uptime: process.uptime().toFixed(0) + "s",
14
+ memory: `${(process.memoryUsage.rss() / 1024 / 1024).toFixed(1)}MB`,
15
+ timestamp: new Date().toISOString(),
16
+ },
17
+ });
18
+ }
src/app/api/notes/[id]/route.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getNote, updateNote, deleteNote } from "@/lib/notes";
2
+ import { NextRequest } from "next/server";
3
+
4
+ export async function GET(_req: NextRequest, ctx: RouteContext<"/api/notes/[id]">) {
5
+ const { id } = await ctx.params;
6
+ const note = await getNote(id);
7
+ if (!note) return Response.json({ ok: false, error: "not found" }, { status: 404 });
8
+ return Response.json({ ok: true, data: note });
9
+ }
10
+
11
+ export async function PUT(req: NextRequest, ctx: RouteContext<"/api/notes/[id]">) {
12
+ const { id } = await ctx.params;
13
+ const body = await req.json();
14
+ const note = await updateNote(id, body);
15
+ if (!note) return Response.json({ ok: false, error: "not found" }, { status: 404 });
16
+ return Response.json({ ok: true, data: note });
17
+ }
18
+
19
+ export async function DELETE(_req: NextRequest, ctx: RouteContext<"/api/notes/[id]">) {
20
+ const { id } = await ctx.params;
21
+ const ok = await deleteNote(id);
22
+ if (!ok) return Response.json({ ok: false, error: "not found" }, { status: 404 });
23
+ return Response.json({ ok: true });
24
+ }
src/app/api/notes/route.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { listNotes, createNote } from "@/lib/notes";
2
+ import { NextRequest } from "next/server";
3
+
4
+ export async function GET(req: NextRequest) {
5
+ const q = req.nextUrl.searchParams.get("q") ?? undefined;
6
+ const notes = await listNotes(q);
7
+ return Response.json({ ok: true, data: notes, count: notes.length });
8
+ }
9
+
10
+ export async function POST(req: NextRequest) {
11
+ const body = await req.json();
12
+ if (!body.title?.trim()) {
13
+ return Response.json({ ok: false, error: "title required" }, { status: 400 });
14
+ }
15
+ const note = await createNote(body);
16
+ return Response.json({ ok: true, data: note }, { status: 201 });
17
+ }
src/app/api/stats/route.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { getStats } from "@/lib/notes";
2
+
3
+ export async function GET() {
4
+ const stats = await getStats();
5
+ return Response.json({ ok: true, data: stats });
6
+ }
src/app/globals.css ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ :root {
4
+ --font-main: "Geist", sans-serif;
5
+ }
6
+
7
+ * {
8
+ scrollbar-width: thin;
9
+ scrollbar-color: #3f3f46 transparent;
10
+ }
src/app/layout.tsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Geist } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const font = Geist({ subsets: ["latin"], variable: "--font-main" });
6
+
7
+ export const metadata: Metadata = {
8
+ title: "Nexova",
9
+ description: "Next.js + SQLite on HF Space",
10
+ };
11
+
12
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
13
+ return (
14
+ <html lang="en" className="dark">
15
+ <body className={`${font.variable} font-sans antialiased bg-zinc-950 text-zinc-100 min-h-screen`}>
16
+ {children}
17
+ </body>
18
+ </html>
19
+ );
20
+ }
src/app/page.tsx ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import NoteCard from "@/components/NoteCard";
5
+ import NoteForm from "@/components/NoteForm";
6
+ import StatsBar from "@/components/StatsBar";
7
+ import DbInfo from "@/components/DbInfo";
8
+
9
+ type Note = {
10
+ id: string;
11
+ title: string;
12
+ content: string;
13
+ color: string;
14
+ pinned: number;
15
+ created_at: string;
16
+ updated_at: string;
17
+ };
18
+
19
+ export default function Home() {
20
+ const [notes, setNotes] = useState<Note[]>([]);
21
+ const [search, setSearch] = useState("");
22
+ const [editing, setEditing] = useState<Note | null>(null);
23
+ const [showForm, setShowForm] = useState(false);
24
+ const [tab, setTab] = useState<"notes" | "db">("notes");
25
+
26
+ const load = useCallback(async () => {
27
+ const q = search ? `?q=${encodeURIComponent(search)}` : "";
28
+ const res = await fetch(`/api/notes${q}`);
29
+ const json = await res.json();
30
+ if (json.ok) setNotes(json.data);
31
+ }, [search]);
32
+
33
+ useEffect(() => {
34
+ load();
35
+ }, [load]);
36
+
37
+ const onSave = async (data: { title: string; content: string; color: string; pinned: boolean }) => {
38
+ const url = editing ? `/api/notes/${editing.id}` : "/api/notes";
39
+ const method = editing ? "PUT" : "POST";
40
+ await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) });
41
+ setEditing(null);
42
+ setShowForm(false);
43
+ load();
44
+ };
45
+
46
+ const onDelete = async (id: string) => {
47
+ await fetch(`/api/notes/${id}`, { method: "DELETE" });
48
+ load();
49
+ };
50
+
51
+ const onPin = async (note: Note) => {
52
+ await fetch(`/api/notes/${note.id}`, {
53
+ method: "PUT",
54
+ headers: { "Content-Type": "application/json" },
55
+ body: JSON.stringify({ pinned: !note.pinned }),
56
+ });
57
+ load();
58
+ };
59
+
60
+ return (
61
+ <main className="max-w-5xl mx-auto px-4 py-8">
62
+ <header className="flex items-center justify-between mb-6">
63
+ <h1 className="text-3xl font-bold tracking-tight">
64
+ <span className="text-emerald-400">◆</span> Nexova
65
+ </h1>
66
+ <div className="flex gap-2">
67
+ <button
68
+ onClick={() => setTab("notes")}
69
+ className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${tab === "notes" ? "bg-emerald-600 text-white" : "bg-zinc-800 text-zinc-400 hover:text-white"}`}
70
+ >
71
+ Notes
72
+ </button>
73
+ <button
74
+ onClick={() => setTab("db")}
75
+ className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${tab === "db" ? "bg-emerald-600 text-white" : "bg-zinc-800 text-zinc-400 hover:text-white"}`}
76
+ >
77
+ Database
78
+ </button>
79
+ </div>
80
+ </header>
81
+
82
+ {tab === "notes" ? (
83
+ <>
84
+ <StatsBar />
85
+
86
+ <div className="flex gap-3 mb-6">
87
+ <input
88
+ type="text"
89
+ placeholder="Search notes..."
90
+ value={search}
91
+ onChange={(e) => setSearch(e.target.value)}
92
+ className="flex-1 bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:border-emerald-500 transition"
93
+ />
94
+ <button
95
+ onClick={() => { setEditing(null); setShowForm(true); }}
96
+ className="bg-emerald-600 hover:bg-emerald-500 px-4 py-2.5 rounded-lg text-sm font-medium transition"
97
+ >
98
+ + New
99
+ </button>
100
+ </div>
101
+
102
+ {showForm && (
103
+ <NoteForm
104
+ initial={editing}
105
+ onSave={onSave}
106
+ onCancel={() => { setShowForm(false); setEditing(null); }}
107
+ />
108
+ )}
109
+
110
+ {notes.length === 0 ? (
111
+ <div className="text-center py-20 text-zinc-500">
112
+ <p className="text-4xl mb-3">📝</p>
113
+ <p>No notes yet. Create one!</p>
114
+ </div>
115
+ ) : (
116
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
117
+ {notes.map((n) => (
118
+ <NoteCard
119
+ key={n.id}
120
+ note={n}
121
+ onEdit={() => { setEditing(n); setShowForm(true); }}
122
+ onDelete={() => onDelete(n.id)}
123
+ onPin={() => onPin(n)}
124
+ />
125
+ ))}
126
+ </div>
127
+ )}
128
+ </>
129
+ ) : (
130
+ <DbInfo />
131
+ )}
132
+ </main>
133
+ );
134
+ }
src/components/DbInfo.tsx ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+
5
+ type Health = { status: string; db: string; dbSize: string; uptime: string; memory: string; timestamp: string };
6
+ type FileInfo = { name: string; size: string; modified: string };
7
+
8
+ export default function DbInfo() {
9
+ const [health, setHealth] = useState<Health | null>(null);
10
+ const [files, setFiles] = useState<FileInfo[]>([]);
11
+ const [backing, setBacking] = useState(false);
12
+ const [msg, setMsg] = useState("");
13
+
14
+ const loadHealth = () => fetch("/api/health").then((r) => r.json()).then((j) => j.ok && setHealth(j.data));
15
+ const loadFiles = () =>
16
+ fetch("/api/backup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "info" }) })
17
+ .then((r) => r.json())
18
+ .then((j) => j.ok && setFiles(j.data.files));
19
+
20
+ useEffect(() => { loadHealth(); loadFiles(); }, []);
21
+
22
+ const backup = async () => {
23
+ setBacking(true);
24
+ setMsg("");
25
+ const res = await fetch("/api/backup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "backup" }) });
26
+ const j = await res.json();
27
+ setMsg(j.ok ? "✅ Backup success!" : `❌ ${j.error}`);
28
+ setBacking(false);
29
+ loadFiles();
30
+ };
31
+
32
+ return (
33
+ <div className="space-y-6">
34
+ <section className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
35
+ <h2 className="text-lg font-semibold mb-4">🏥 Health</h2>
36
+ {health ? (
37
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
38
+ {Object.entries(health).map(([k, v]) => (
39
+ <div key={k} className="bg-zinc-800/50 rounded-lg p-3">
40
+ <div className="text-[10px] text-zinc-500 uppercase tracking-wider">{k}</div>
41
+ <div className="text-emerald-400 font-mono mt-1">{v}</div>
42
+ </div>
43
+ ))}
44
+ </div>
45
+ ) : (
46
+ <p className="text-zinc-500 text-sm">Loading...</p>
47
+ )}
48
+ </section>
49
+
50
+ <section className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
51
+ <div className="flex items-center justify-between mb-4">
52
+ <h2 className="text-lg font-semibold">💾 Disk Files</h2>
53
+ <button
54
+ onClick={backup}
55
+ disabled={backing}
56
+ className="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded-lg text-sm font-medium transition"
57
+ >
58
+ {backing ? "Backing up..." : "Backup to HF"}
59
+ </button>
60
+ </div>
61
+ {msg && <p className="text-sm mb-3">{msg}</p>}
62
+ {files.length === 0 ? (
63
+ <p className="text-zinc-500 text-sm">No files in data/</p>
64
+ ) : (
65
+ <table className="w-full text-sm">
66
+ <thead>
67
+ <tr className="text-zinc-500 text-left text-xs">
68
+ <th className="pb-2">File</th>
69
+ <th className="pb-2">Size</th>
70
+ <th className="pb-2">Modified</th>
71
+ </tr>
72
+ </thead>
73
+ <tbody>
74
+ {files.map((f) => (
75
+ <tr key={f.name} className="border-t border-zinc-800">
76
+ <td className="py-2 font-mono text-emerald-400">{f.name}</td>
77
+ <td className="py-2">{f.size}</td>
78
+ <td className="py-2 text-zinc-400">{new Date(f.modified).toLocaleString()}</td>
79
+ </tr>
80
+ ))}
81
+ </tbody>
82
+ </table>
83
+ )}
84
+ </section>
85
+ </div>
86
+ );
87
+ }
src/components/NoteCard.tsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ type Note = {
4
+ id: string;
5
+ title: string;
6
+ content: string;
7
+ color: string;
8
+ pinned: number;
9
+ created_at: string;
10
+ updated_at: string;
11
+ };
12
+
13
+ const COLORS: Record<string, string> = {
14
+ "#fef08a": "bg-yellow-200/10 border-yellow-500/30",
15
+ "#86efac": "bg-emerald-200/10 border-emerald-500/30",
16
+ "#93c5fd": "bg-blue-200/10 border-blue-500/30",
17
+ "#f9a8d4": "bg-pink-200/10 border-pink-500/30",
18
+ "#c4b5fd": "bg-violet-200/10 border-violet-500/30",
19
+ };
20
+
21
+ export default function NoteCard({
22
+ note,
23
+ onEdit,
24
+ onDelete,
25
+ onPin,
26
+ }: {
27
+ note: Note;
28
+ onEdit: () => void;
29
+ onDelete: () => void;
30
+ onPin: () => void;
31
+ }) {
32
+ const cls = COLORS[note.color] ?? "bg-zinc-800/50 border-zinc-700";
33
+ const ago = timeAgo(note.updated_at);
34
+
35
+ return (
36
+ <div className={`border rounded-xl p-4 ${cls} transition hover:scale-[1.02] group`}>
37
+ <div className="flex items-start justify-between mb-2">
38
+ <h3 className="font-semibold text-sm leading-tight flex-1 mr-2">
39
+ {note.pinned ? "📌 " : ""}{note.title}
40
+ </h3>
41
+ <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
42
+ <Btn onClick={onPin} title={note.pinned ? "Unpin" : "Pin"}>
43
+ {note.pinned ? "◇" : "◆"}
44
+ </Btn>
45
+ <Btn onClick={onEdit} title="Edit">✎</Btn>
46
+ <Btn onClick={onDelete} title="Delete">✕</Btn>
47
+ </div>
48
+ </div>
49
+ {note.content && (
50
+ <p className="text-xs text-zinc-400 leading-relaxed line-clamp-4 mb-3">{note.content}</p>
51
+ )}
52
+ <div className="flex items-center justify-between">
53
+ <span
54
+ className="w-3 h-3 rounded-full inline-block"
55
+ style={{ backgroundColor: note.color }}
56
+ />
57
+ <span className="text-[10px] text-zinc-500">{ago}</span>
58
+ </div>
59
+ </div>
60
+ );
61
+ }
62
+
63
+ function Btn({ onClick, title, children }: { onClick: () => void; title: string; children: React.ReactNode }) {
64
+ return (
65
+ <button
66
+ onClick={onClick}
67
+ title={title}
68
+ className="w-6 h-6 flex items-center justify-center rounded text-xs text-zinc-400 hover:text-white hover:bg-zinc-700 transition"
69
+ >
70
+ {children}
71
+ </button>
72
+ );
73
+ }
74
+
75
+ function timeAgo(iso: string): string {
76
+ const diff = Date.now() - new Date(iso).getTime();
77
+ const mins = Math.floor(diff / 60000);
78
+ if (mins < 1) return "just now";
79
+ if (mins < 60) return `${mins}m ago`;
80
+ const hrs = Math.floor(mins / 60);
81
+ if (hrs < 24) return `${hrs}h ago`;
82
+ return `${Math.floor(hrs / 24)}d ago`;
83
+ }
src/components/NoteForm.tsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+
5
+ const PALETTE = ["#fef08a", "#86efac", "#93c5fd", "#f9a8d4", "#c4b5fd"];
6
+
7
+ type Note = {
8
+ id: string;
9
+ title: string;
10
+ content: string;
11
+ color: string;
12
+ pinned: number;
13
+ };
14
+
15
+ export default function NoteForm({
16
+ initial,
17
+ onSave,
18
+ onCancel,
19
+ }: {
20
+ initial: Note | null;
21
+ onSave: (data: { title: string; content: string; color: string; pinned: boolean }) => void;
22
+ onCancel: () => void;
23
+ }) {
24
+ const [title, setTitle] = useState(initial?.title ?? "");
25
+ const [content, setContent] = useState(initial?.content ?? "");
26
+ const [color, setColor] = useState(initial?.color ?? PALETTE[0]);
27
+ const [pinned, setPinned] = useState(!!initial?.pinned);
28
+
29
+ const submit = (e: React.FormEvent) => {
30
+ e.preventDefault();
31
+ if (!title.trim()) return;
32
+ onSave({ title: title.trim(), content: content.trim(), color, pinned });
33
+ };
34
+
35
+ return (
36
+ <form onSubmit={submit} className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 mb-6 space-y-4">
37
+ <input
38
+ autoFocus
39
+ placeholder="Title"
40
+ value={title}
41
+ onChange={(e) => setTitle(e.target.value)}
42
+ className="w-full bg-transparent text-lg font-semibold focus:outline-none placeholder:text-zinc-600"
43
+ />
44
+ <textarea
45
+ placeholder="Write something..."
46
+ value={content}
47
+ onChange={(e) => setContent(e.target.value)}
48
+ rows={4}
49
+ className="w-full bg-transparent text-sm text-zinc-300 focus:outline-none resize-none placeholder:text-zinc-600"
50
+ />
51
+ <div className="flex items-center justify-between">
52
+ <div className="flex gap-2 items-center">
53
+ {PALETTE.map((c) => (
54
+ <button
55
+ key={c}
56
+ type="button"
57
+ onClick={() => setColor(c)}
58
+ className={`w-6 h-6 rounded-full transition ${color === c ? "ring-2 ring-white scale-110" : "opacity-60 hover:opacity-100"}`}
59
+ style={{ backgroundColor: c }}
60
+ />
61
+ ))}
62
+ <label className="flex items-center gap-1.5 ml-3 text-xs text-zinc-400 cursor-pointer">
63
+ <input
64
+ type="checkbox"
65
+ checked={pinned}
66
+ onChange={(e) => setPinned(e.target.checked)}
67
+ className="accent-emerald-500"
68
+ />
69
+ Pin
70
+ </label>
71
+ </div>
72
+ <div className="flex gap-2">
73
+ <button type="button" onClick={onCancel} className="px-3 py-1.5 text-sm text-zinc-400 hover:text-white transition">
74
+ Cancel
75
+ </button>
76
+ <button type="submit" className="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-500 rounded-lg text-sm font-medium transition">
77
+ {initial ? "Update" : "Create"}
78
+ </button>
79
+ </div>
80
+ </div>
81
+ </form>
82
+ );
83
+ }
src/components/StatsBar.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+
5
+ type Stats = { total: number; pinned: number; latest: string | null; colors: { color: string; count: number }[] };
6
+
7
+ export default function StatsBar() {
8
+ const [stats, setStats] = useState<Stats | null>(null);
9
+
10
+ useEffect(() => {
11
+ fetch("/api/stats").then((r) => r.json()).then((j) => j.ok && setStats(j.data));
12
+ }, []);
13
+
14
+ if (!stats) return null;
15
+
16
+ return (
17
+ <div className="flex gap-4 mb-6 text-xs text-zinc-500">
18
+ <span>📊 {stats.total} notes</span>
19
+ <span>📌 {stats.pinned} pinned</span>
20
+ {stats.latest && <span>🕐 Last: {new Date(stats.latest).toLocaleString()}</span>}
21
+ <div className="flex gap-1 ml-auto">
22
+ {stats.colors.map((c) => (
23
+ <span
24
+ key={String(c.color)}
25
+ className="w-3 h-3 rounded-full inline-block"
26
+ style={{ backgroundColor: String(c.color) }}
27
+ title={`${c.count}`}
28
+ />
29
+ ))}
30
+ </div>
31
+ </div>
32
+ );
33
+ }
src/lib/db.ts ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import initSqlJs from "sql.js";
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
3
+ import { join } from "path";
4
+
5
+ const DB_DIR = join(process.cwd(), "data");
6
+ const DB_PATH = join(DB_DIR, "nexova.db");
7
+
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
+ let db: any = null;
10
+
11
+ function ensureDir() {
12
+ if (!existsSync(DB_DIR)) mkdirSync(DB_DIR, { recursive: true });
13
+ }
14
+
15
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
16
+ async function getDb(): Promise<any> {
17
+ if (db) return db;
18
+
19
+ const SQL = await initSqlJs();
20
+ ensureDir();
21
+
22
+ db = existsSync(DB_PATH)
23
+ ? new SQL.Database(readFileSync(DB_PATH))
24
+ : new SQL.Database();
25
+
26
+ db.run(`
27
+ CREATE TABLE IF NOT EXISTS notes (
28
+ id TEXT PRIMARY KEY,
29
+ title TEXT NOT NULL,
30
+ content TEXT DEFAULT '',
31
+ color TEXT DEFAULT '#fef08a',
32
+ pinned INTEGER DEFAULT 0,
33
+ created_at TEXT DEFAULT (datetime('now')),
34
+ updated_at TEXT DEFAULT (datetime('now'))
35
+ )
36
+ `);
37
+
38
+ save();
39
+ return db;
40
+ }
41
+
42
+ function save() {
43
+ if (!db) return;
44
+ ensureDir();
45
+ writeFileSync(DB_PATH, Buffer.from(db.export()));
46
+ }
47
+
48
+ export { getDb, save, DB_PATH, DB_DIR };
src/lib/notes.ts ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getDb, save } from "./db";
2
+
3
+ export type Note = {
4
+ id: string;
5
+ title: string;
6
+ content: string;
7
+ color: string;
8
+ pinned: number;
9
+ created_at: string;
10
+ updated_at: string;
11
+ };
12
+
13
+ type NoteInput = {
14
+ title: string;
15
+ content?: string;
16
+ color?: string;
17
+ pinned?: boolean;
18
+ };
19
+
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ function toNote(columns: string[], values: any[]): Note {
22
+ const obj: Record<string, unknown> = {};
23
+ columns.forEach((c: string, i: number) => (obj[c] = values[i]));
24
+ return obj as unknown as Note;
25
+ }
26
+
27
+ export async function listNotes(q?: string): Promise<Note[]> {
28
+ const db = await getDb();
29
+ const sql = q
30
+ ? `SELECT * FROM notes WHERE title LIKE ? OR content LIKE ? ORDER BY pinned DESC, updated_at DESC`
31
+ : `SELECT * FROM notes ORDER BY pinned DESC, updated_at DESC`;
32
+ const params = q ? [`%${q}%`, `%${q}%`] : [];
33
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
+ const res = db.exec(sql, params) as any[];
35
+ if (!res.length) return [];
36
+ return res[0].values.map((v: unknown[]) => toNote(res[0].columns, v));
37
+ }
38
+
39
+ export async function getNote(id: string): Promise<Note | null> {
40
+ const db = await getDb();
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ const res = db.exec(`SELECT * FROM notes WHERE id = ?`, [id]) as any[];
43
+ if (!res.length || !res[0].values.length) return null;
44
+ return toNote(res[0].columns, res[0].values[0]);
45
+ }
46
+
47
+ export async function createNote(input: NoteInput): Promise<Note> {
48
+ const db = await getDb();
49
+ const id = crypto.randomUUID();
50
+ const now = new Date().toISOString();
51
+ db.run(
52
+ `INSERT INTO notes (id, title, content, color, pinned, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
53
+ [id, input.title, input.content ?? "", input.color ?? "#fef08a", input.pinned ? 1 : 0, now, now]
54
+ );
55
+ save();
56
+ return (await getNote(id))!;
57
+ }
58
+
59
+ export async function updateNote(id: string, input: Partial<NoteInput>): Promise<Note | null> {
60
+ const existing = await getNote(id);
61
+ if (!existing) return null;
62
+ const db = await getDb();
63
+ const now = new Date().toISOString();
64
+ db.run(
65
+ `UPDATE notes SET title=?, content=?, color=?, pinned=?, updated_at=? WHERE id=?`,
66
+ [
67
+ input.title ?? existing.title,
68
+ input.content ?? existing.content,
69
+ input.color ?? existing.color,
70
+ input.pinned !== undefined ? (input.pinned ? 1 : 0) : existing.pinned,
71
+ now,
72
+ id,
73
+ ]
74
+ );
75
+ save();
76
+ return getNote(id);
77
+ }
78
+
79
+ export async function deleteNote(id: string): Promise<boolean> {
80
+ const existing = await getNote(id);
81
+ if (!existing) return false;
82
+ const db = await getDb();
83
+ db.run(`DELETE FROM notes WHERE id = ?`, [id]);
84
+ save();
85
+ return true;
86
+ }
87
+
88
+ export async function getStats() {
89
+ const db = await getDb();
90
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
91
+ const exec = (sql: string) => db.exec(sql) as any[];
92
+ const total = exec(`SELECT COUNT(*) FROM notes`)[0]?.values[0][0] ?? 0;
93
+ const pinned = exec(`SELECT COUNT(*) FROM notes WHERE pinned = 1`)[0]?.values[0][0] ?? 0;
94
+ const latest = exec(`SELECT updated_at FROM notes ORDER BY updated_at DESC LIMIT 1`)[0]?.values[0]?.[0] ?? null;
95
+ const cRes = exec(`SELECT color, COUNT(*) as cnt FROM notes GROUP BY color ORDER BY cnt DESC`);
96
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
97
+ const colors = cRes.length ? cRes[0].values.map((v: any) => ({ color: v[0], count: v[1] })) : [];
98
+ return { total, pinned, latest, colors };
99
+ }
src/types/sql.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ declare module "sql.js";
tsconfig.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./src/*"]
23
+ }
24
+ },
25
+ "include": [
26
+ "next-env.d.ts",
27
+ "**/*.ts",
28
+ "**/*.tsx",
29
+ ".next/types/**/*.ts",
30
+ ".next/dev/types/**/*.ts",
31
+ "**/*.mts"
32
+ ],
33
+ "exclude": ["node_modules"]
34
+ }