Nexova commited on
Commit ·
debd125
0
Parent(s):
init: nexova - notes crud + sqlite + hf backup
Browse files- .dockerignore +4 -0
- .gitignore +4 -0
- AGENTS.md +5 -0
- CLAUDE.md +1 -0
- Dockerfile +34 -0
- README.md +9 -0
- eslint.config.mjs +18 -0
- next-env.d.ts +6 -0
- next.config.ts +8 -0
- package-lock.json +0 -0
- package.json +28 -0
- postcss.config.mjs +7 -0
- public/file.svg +1 -0
- public/globe.svg +1 -0
- public/next.svg +1 -0
- public/vercel.svg +1 -0
- public/window.svg +1 -0
- scripts/backup.sh +28 -0
- scripts/restore.sh +27 -0
- scripts/start.sh +12 -0
- src/app/api/backup/route.ts +40 -0
- src/app/api/health/route.ts +18 -0
- src/app/api/notes/[id]/route.ts +24 -0
- src/app/api/notes/route.ts +17 -0
- src/app/api/stats/route.ts +6 -0
- src/app/globals.css +10 -0
- src/app/layout.tsx +20 -0
- src/app/page.tsx +134 -0
- src/components/DbInfo.tsx +87 -0
- src/components/NoteCard.tsx +83 -0
- src/components/NoteForm.tsx +83 -0
- src/components/StatsBar.tsx +33 -0
- src/lib/db.ts +48 -0
- src/lib/notes.ts +99 -0
- src/types/sql.d.ts +1 -0
- tsconfig.json +34 -0
.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 |
+
}
|