diff --git a/README.md b/backend/.env.example similarity index 100% rename from README.md rename to backend/.env.example diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..57d2439 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --production + +COPY . . + +RUN mkdir -p /app/data /app/uploads + +EXPOSE 4000 + +CMD ["node", "server.js"] \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..aa53864 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,22 @@ +{ + "name": "inknote-server", + "version": "1.0.0", + "description": "InkNote — Cross-platform note-taking backend with sync", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.0.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "multer": "^1.4.5-lts.1", + "uuid": "^9.0.1", + "ws": "^8.17.0" + } +} \ No newline at end of file diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..16d6a1a --- /dev/null +++ b/backend/server.js @@ -0,0 +1,476 @@ +/* + * InkNote — Backend Sync Server + * ───────────────────────────── + * Node.js + Express + SQLite + * + * Features: + * • User registration / login (JWT) + * • CRUD for notes, notebooks, PDFs + * • Conflict-aware sync with vector clocks + * • WebSocket real-time push + * • Encrypted storage at rest (AES-256) + * + * Install: + * npm init -y + * npm install express better-sqlite3 bcryptjs jsonwebtoken ws uuid cors helmet dotenv multer + * + * Run: + * node server.js + */ + +require("dotenv").config(); +const express = require("express"); +const cors = require("cors"); +const helmet = require("helmet"); +const bcrypt = require("bcryptjs"); +const jwt = require("jsonwebtoken"); +const { WebSocketServer } = require("ws"); +const Database = require("better-sqlite3"); +const { v4: uuid } = require("uuid"); +const crypto = require("crypto"); +const multer = require("multer"); +const path = require("path"); +const fs = require("fs"); +const http = require("http"); + +/* ─────────── Config ─────────── */ +const PORT = process.env.PORT || 4000; +const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production-" + crypto.randomBytes(16).toString("hex"); +const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || crypto.randomBytes(32).toString("hex"); +const UPLOAD_DIR = path.join(__dirname, "uploads"); +if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + +/* ─────────── Database ─────────── */ +const db = new Database(path.join(__dirname, "inknote.db")); +db.pragma("journal_mode = WAL"); +db.pragma("foreign_keys = ON"); + +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS notebooks ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + cover_index INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + deleted_at TEXT, + version INTEGER DEFAULT 1, + device_id TEXT + ); + + CREATE TABLE IF NOT EXISTS notes ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + notebook_id TEXT REFERENCES notebooks(id) ON DELETE SET NULL, + type TEXT NOT NULL DEFAULT 'text', -- text | drawing | pdf + title TEXT NOT NULL DEFAULT 'Untitled', + content_encrypted BLOB, + drawing_data_path TEXT, + pdf_path TEXT, + annotations_path TEXT, + starred INTEGER DEFAULT 0, + tags TEXT DEFAULT '[]', + sort_order INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + deleted_at TEXT, + version INTEGER DEFAULT 1, + device_id TEXT, + vector_clock TEXT DEFAULT '{}' + ); + + CREATE TABLE IF NOT EXISTS sync_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + entity_type TEXT NOT NULL, -- note | notebook + entity_id TEXT NOT NULL, + action TEXT NOT NULL, -- create | update | delete + payload TEXT, + device_id TEXT, + timestamp TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS devices ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT, + platform TEXT, + last_sync TEXT DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id); + CREATE INDEX IF NOT EXISTS idx_notes_notebook ON notes(notebook_id); + CREATE INDEX IF NOT EXISTS idx_notebooks_user ON notebooks(user_id); + CREATE INDEX IF NOT EXISTS idx_sync_user ON sync_log(user_id, timestamp); +`); + +/* ─────────── Encryption helpers ─────────── */ +function encrypt(text) { + const iv = crypto.randomBytes(16); + const key = Buffer.from(ENCRYPTION_KEY, "hex"); + const cipher = crypto.createCipheriv("aes-256-cbc", key, iv); + let encrypted = cipher.update(text, "utf8", "hex"); + encrypted += cipher.final("hex"); + return iv.toString("hex") + ":" + encrypted; +} + +function decrypt(data) { + const [ivHex, encryptedHex] = data.split(":"); + const iv = Buffer.from(ivHex, "hex"); + const key = Buffer.from(ENCRYPTION_KEY, "hex"); + const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv); + let decrypted = decipher.update(encryptedHex, "hex", "utf8"); + decrypted += decipher.final("utf8"); + return decrypted; +} + +/* ─────────── Vector clock conflict resolution ─────────── */ +function mergeVectorClocks(clockA, clockB) { + const merged = { ...clockA }; + for (const [device, time] of Object.entries(clockB)) { + merged[device] = Math.max(merged[device] || 0, time); + } + return merged; +} + +function isClockNewer(clockA, clockB) { + // Returns true if clockA is strictly newer than clockB + let aHasNewer = false; + for (const [device, time] of Object.entries(clockA)) { + if (time > (clockB[device] || 0)) aHasNewer = true; + } + for (const [device, time] of Object.entries(clockB)) { + if (time > (clockA[device] || 0)) return false; // conflict or older + } + return aHasNewer; +} + +/* ─────────── Auth middleware ─────────── */ +function authMiddleware(req, res, next) { + const header = req.headers.authorization; + if (!header || !header.startsWith("Bearer ")) { + return res.status(401).json({ error: "Authentication required" }); + } + try { + const token = header.slice(7); + const payload = jwt.verify(token, JWT_SECRET); + req.userId = payload.userId; + req.deviceId = req.headers["x-device-id"] || "unknown"; + next(); + } catch { + res.status(401).json({ error: "Invalid token" }); + } +} + +/* ─────────── Express app ─────────── */ +const app = express(); +app.use(helmet()); +app.use(cors()); +app.use(express.json({ limit: "50mb" })); + +const upload = multer({ + dest: UPLOAD_DIR, + limits: { fileSize: 100 * 1024 * 1024 }, // 100MB + fileFilter: (req, file, cb) => { + if (file.mimetype === "application/pdf") cb(null, true); + else cb(new Error("Only PDF files allowed")); + }, +}); + +/* ─── Auth routes ─── */ +app.post("/api/auth/register", (req, res) => { + const { email, password, displayName } = req.body; + if (!email || !password) return res.status(400).json({ error: "Email and password required" }); + + const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email); + if (existing) return res.status(409).json({ error: "Email already registered" }); + + const id = uuid(); + const hash = bcrypt.hashSync(password, 12); + db.prepare("INSERT INTO users (id, email, password_hash, display_name) VALUES (?, ?, ?, ?)").run(id, email, hash, displayName || email.split("@")[0]); + + const token = jwt.sign({ userId: id }, JWT_SECRET, { expiresIn: "30d" }); + res.json({ token, user: { id, email, displayName: displayName || email.split("@")[0] } }); +}); + +app.post("/api/auth/login", (req, res) => { + const { email, password } = req.body; + const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email); + if (!user || !bcrypt.compareSync(password, user.password_hash)) { + return res.status(401).json({ error: "Invalid credentials" }); + } + const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" }); + res.json({ token, user: { id: user.id, email: user.email, displayName: user.display_name } }); +}); + +/* ─── Notebook routes ─── */ +app.get("/api/notebooks", authMiddleware, (req, res) => { + const notebooks = db.prepare("SELECT * FROM notebooks WHERE user_id = ? AND deleted_at IS NULL ORDER BY sort_order").all(req.userId); + res.json(notebooks); +}); + +app.post("/api/notebooks", authMiddleware, (req, res) => { + const { name, coverIndex } = req.body; + const id = uuid(); + db.prepare("INSERT INTO notebooks (id, user_id, name, cover_index, device_id) VALUES (?, ?, ?, ?, ?)") + .run(id, req.userId, name, coverIndex || 0, req.deviceId); + + db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, device_id) VALUES (?, 'notebook', ?, 'create', ?)") + .run(req.userId, id, req.deviceId); + + broadcastToUser(req.userId, { type: "notebook_created", data: { id, name, coverIndex } }, req.deviceId); + res.json({ id, name, coverIndex }); +}); + +app.put("/api/notebooks/:id", authMiddleware, (req, res) => { + const { name, coverIndex, sortOrder } = req.body; + const nb = db.prepare("SELECT * FROM notebooks WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); + if (!nb) return res.status(404).json({ error: "Not found" }); + + db.prepare("UPDATE notebooks SET name = COALESCE(?, name), cover_index = COALESCE(?, cover_index), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), version = version + 1 WHERE id = ?") + .run(name, coverIndex, sortOrder, req.params.id); + + broadcastToUser(req.userId, { type: "notebook_updated", data: { id: req.params.id, name, coverIndex } }, req.deviceId); + res.json({ success: true }); +}); + +app.delete("/api/notebooks/:id", authMiddleware, (req, res) => { + db.prepare("UPDATE notebooks SET deleted_at = datetime('now') WHERE id = ? AND user_id = ?").run(req.params.id, req.userId); + broadcastToUser(req.userId, { type: "notebook_deleted", data: { id: req.params.id } }, req.deviceId); + res.json({ success: true }); +}); + +/* ─── Note routes ─── */ +app.get("/api/notes", authMiddleware, (req, res) => { + const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, tags, sort_order, created_at, updated_at, version, vector_clock FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId); + res.json(notes); +}); + +app.get("/api/notes/:id", authMiddleware, (req, res) => { + const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); + if (!note) return res.status(404).json({ error: "Not found" }); + + // Decrypt content + if (note.content_encrypted) { + try { + note.content = decrypt(note.content_encrypted.toString()); + } catch { + note.content = ""; + } + } + delete note.content_encrypted; + res.json(note); +}); + +app.post("/api/notes", authMiddleware, (req, res) => { + const { type, title, content, notebookId, starred, tags } = req.body; + const id = uuid(); + const encContent = content ? encrypt(content) : null; + const clock = JSON.stringify({ [req.deviceId]: 1 }); + + db.prepare(`INSERT INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, tags, device_id, vector_clock) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(id, req.userId, notebookId, type || "text", title || "Untitled", encContent, starred ? 1 : 0, JSON.stringify(tags || []), req.deviceId, clock); + + db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, device_id) VALUES (?, 'note', ?, 'create', ?)") + .run(req.userId, id, req.deviceId); + + broadcastToUser(req.userId, { type: "note_created", data: { id, type, title } }, req.deviceId); + res.json({ id }); +}); + +app.put("/api/notes/:id", authMiddleware, (req, res) => { + const { title, content, notebookId, starred, tags, vectorClock } = req.body; + const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); + if (!note) return res.status(404).json({ error: "Not found" }); + + // Conflict detection + const serverClock = JSON.parse(note.vector_clock || "{}"); + const clientClock = vectorClock ? JSON.parse(vectorClock) : {}; + + if (Object.keys(clientClock).length > 0 && !isClockNewer(clientClock, serverClock)) { + // Conflict! Return both versions + return res.status(409).json({ + error: "Conflict detected", + serverVersion: { ...note, vectorClock: serverClock }, + clientVersion: req.body, + mergedClock: mergeVectorClocks(serverClock, clientClock), + }); + } + + const encContent = content ? encrypt(content) : note.content_encrypted; + const newClock = { ...mergeVectorClocks(serverClock, clientClock), [req.deviceId]: (serverClock[req.deviceId] || 0) + 1 }; + + db.prepare(`UPDATE notes SET + title = COALESCE(?, title), + content_encrypted = COALESCE(?, content_encrypted), + notebook_id = COALESCE(?, notebook_id), + starred = COALESCE(?, starred), + tags = COALESCE(?, tags), + updated_at = datetime('now'), + version = version + 1, + vector_clock = ?, + device_id = ? + WHERE id = ?`) + .run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, tags ? JSON.stringify(tags) : null, JSON.stringify(newClock), req.deviceId, req.params.id); + + broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, title } }, req.deviceId); + res.json({ success: true, vectorClock: newClock }); +}); + +app.delete("/api/notes/:id", authMiddleware, (req, res) => { + db.prepare("UPDATE notes SET deleted_at = datetime('now') WHERE id = ? AND user_id = ?").run(req.params.id, req.userId); + broadcastToUser(req.userId, { type: "note_deleted", data: { id: req.params.id } }, req.deviceId); + res.json({ success: true }); +}); + +/* ─── Drawing data route ─── */ +app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => { + const { drawingData } = req.body; + const note = db.prepare("SELECT id FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); + if (!note) return res.status(404).json({ error: "Not found" }); + + const filePath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.png`); + const base64Data = drawingData.replace(/^data:image\/\w+;base64,/, ""); + fs.writeFileSync(filePath, Buffer.from(base64Data, "base64")); + + db.prepare("UPDATE notes SET drawing_data_path = ?, updated_at = datetime('now'), version = version + 1 WHERE id = ?") + .run(filePath, req.params.id); + + res.json({ success: true }); +}); + +/* ─── PDF upload route ─── */ +app.post("/api/notes/pdf", authMiddleware, upload.single("pdf"), (req, res) => { + if (!req.file) return res.status(400).json({ error: "No file uploaded" }); + + const id = uuid(); + const clock = JSON.stringify({ [req.deviceId]: 1 }); + + db.prepare(`INSERT INTO notes (id, user_id, type, title, pdf_path, device_id, vector_clock) + VALUES (?, ?, 'pdf', ?, ?, ?, ?)`) + .run(id, req.userId, req.file.originalname, req.file.path, req.deviceId, clock); + + res.json({ id, title: req.file.originalname }); +}); + +/* ─── Sync endpoint ─── */ +app.post("/api/sync", authMiddleware, (req, res) => { + const { lastSyncTimestamp, deviceId, changes } = req.body; + const results = { pushed: [], pulled: [], conflicts: [] }; + + // Apply client changes + if (changes && changes.length > 0) { + for (const change of changes) { + try { + if (change.entityType === "note") { + if (change.action === "create") { + const encContent = change.data.content ? encrypt(change.data.content) : null; + db.prepare(`INSERT OR REPLACE INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, device_id, vector_clock, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`) + .run(change.data.id, req.userId, change.data.notebookId, change.data.type, change.data.title, encContent, change.data.starred ? 1 : 0, deviceId, JSON.stringify(change.data.vectorClock || {})); + results.pushed.push({ id: change.data.id, status: "ok" }); + } else if (change.action === "update") { + const existing = db.prepare("SELECT vector_clock FROM notes WHERE id = ? AND user_id = ?").get(change.data.id, req.userId); + if (existing) { + const serverClock = JSON.parse(existing.vector_clock || "{}"); + const clientClock = change.data.vectorClock || {}; + if (!isClockNewer(clientClock, serverClock)) { + results.conflicts.push({ id: change.data.id, serverClock, clientClock }); + continue; + } + } + results.pushed.push({ id: change.data.id, status: "ok" }); + } + } + } catch (err) { + results.pushed.push({ id: change.data?.id, status: "error", error: err.message }); + } + } + } + + // Get server changes since last sync + const serverChanges = db.prepare(` + SELECT s.*, n.title, n.type, n.updated_at + FROM sync_log s + LEFT JOIN notes n ON s.entity_id = n.id + WHERE s.user_id = ? AND s.timestamp > COALESCE(?, '1970-01-01') AND s.device_id != ? + ORDER BY s.timestamp + `).all(req.userId, lastSyncTimestamp, deviceId); + + results.pulled = serverChanges; + results.syncTimestamp = new Date().toISOString(); + + // Update device last sync + db.prepare("INSERT OR REPLACE INTO devices (id, user_id, last_sync) VALUES (?, ?, datetime('now'))").run(deviceId, req.userId); + + res.json(results); +}); + +/* ─── WebSocket for real-time sync ─── */ +const server = http.createServer(app); +const wss = new WebSocketServer({ server, path: "/ws" }); +const userConnections = new Map(); // userId -> Set + +wss.on("connection", (ws, req) => { + let userId = null; + + ws.on("message", (data) => { + try { + const msg = JSON.parse(data); + if (msg.type === "auth") { + const payload = jwt.verify(msg.token, JWT_SECRET); + userId = payload.userId; + const deviceId = msg.deviceId || "unknown"; + + if (!userConnections.has(userId)) userConnections.set(userId, new Map()); + userConnections.get(userId).set(deviceId, ws); + ws._deviceId = deviceId; + + ws.send(JSON.stringify({ type: "auth_ok" })); + } + } catch { + ws.send(JSON.stringify({ type: "error", message: "Invalid message" })); + } + }); + + ws.on("close", () => { + if (userId && userConnections.has(userId)) { + userConnections.get(userId).delete(ws._deviceId); + if (userConnections.get(userId).size === 0) userConnections.delete(userId); + } + }); +}); + +function broadcastToUser(userId, message, excludeDeviceId) { + if (!userConnections.has(userId)) return; + const connections = userConnections.get(userId); + for (const [deviceId, ws] of connections) { + if (deviceId !== excludeDeviceId && ws.readyState === 1) { + ws.send(JSON.stringify(message)); + } + } +} + +/* ─── Health check ─── */ +app.get("/api/health", (req, res) => { + res.json({ status: "ok", version: "1.0.0", uptime: process.uptime() }); +}); + +/* ─── Start server ─── */ +server.listen(PORT, () => { + console.log(`\n ✏️ InkNote Server running on http://localhost:${PORT}`); + console.log(` 📡 WebSocket available at ws://localhost:${PORT}/ws`); + console.log(` 🔒 JWT Secret: ${JWT_SECRET.slice(0, 12)}...`); + console.log(` 💾 Database: ${path.join(__dirname, "inknote.db")}\n`); +}); \ No newline at end of file diff --git a/backend/sync-client.js b/backend/sync-client.js new file mode 100644 index 0000000..9e8fd64 --- /dev/null +++ b/backend/sync-client.js @@ -0,0 +1,311 @@ +/* + * InkNote — Client Sync Engine + * ───────────────────────────── + * Handles offline-first sync with conflict resolution. + * Works in React Native, Flutter (via JS bridge), Electron, and web. + * + * Usage: + * import { SyncEngine } from './sync-client'; + * const sync = new SyncEngine('https://your-server.com', deviceId); + * await sync.authenticate(email, password); + * sync.start(); // begins periodic sync + WebSocket listener + * sync.stop(); // stops sync + */ + +class SyncEngine { + constructor(serverUrl, deviceId) { + this.serverUrl = serverUrl.replace(/\/$/, ""); + this.deviceId = deviceId; + this.token = null; + this.ws = null; + this.syncInterval = null; + this.pendingChanges = []; + this.lastSyncTimestamp = null; + this.listeners = new Map(); + this.isOnline = true; + this.isSyncing = false; + } + + /* ─── Authentication ─── */ + async authenticate(email, password) { + const res = await fetch(`${this.serverUrl}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + if (!res.ok) throw new Error("Authentication failed"); + const data = await res.json(); + this.token = data.token; + return data.user; + } + + async register(email, password, displayName) { + const res = await fetch(`${this.serverUrl}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, displayName }), + }); + if (!res.ok) throw new Error("Registration failed"); + const data = await res.json(); + this.token = data.token; + return data.user; + } + + /* ─── Event system ─── */ + on(event, callback) { + if (!this.listeners.has(event)) this.listeners.set(event, []); + this.listeners.get(event).push(callback); + return () => { + const cbs = this.listeners.get(event); + const idx = cbs.indexOf(callback); + if (idx > -1) cbs.splice(idx, 1); + }; + } + + emit(event, data) { + const cbs = this.listeners.get(event) || []; + cbs.forEach((cb) => cb(data)); + } + + /* ─── Start / Stop ─── */ + start(intervalMs = 30000) { + this.connectWebSocket(); + this.syncInterval = setInterval(() => this.sync(), intervalMs); + this.sync(); // immediate first sync + + // Listen for online/offline + if (typeof window !== "undefined") { + window.addEventListener("online", () => { + this.isOnline = true; + this.emit("status", "online"); + this.sync(); + }); + window.addEventListener("offline", () => { + this.isOnline = false; + this.emit("status", "offline"); + }); + } + } + + stop() { + if (this.syncInterval) clearInterval(this.syncInterval); + if (this.ws) this.ws.close(); + this.ws = null; + } + + /* ─── WebSocket ─── */ + connectWebSocket() { + const wsUrl = this.serverUrl.replace(/^http/, "ws") + "/ws"; + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + this.ws.send(JSON.stringify({ + type: "auth", + token: this.token, + deviceId: this.deviceId, + })); + }; + + this.ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === "auth_ok") { + this.emit("status", "connected"); + } else if (msg.type.endsWith("_created") || msg.type.endsWith("_updated") || msg.type.endsWith("_deleted")) { + this.emit("remote_change", msg); + this.sync(); // re-sync on remote change + } + } catch { /* ignore parse errors */ } + }; + + this.ws.onclose = () => { + this.emit("status", "disconnected"); + // Auto-reconnect after 5s + setTimeout(() => { + if (this.token) this.connectWebSocket(); + }, 5000); + }; + + this.ws.onerror = () => { + this.emit("status", "error"); + }; + } + + /* ─── Queue changes for sync ─── */ + queueChange(entityType, entityId, action, data) { + const change = { + entityType, + entityId, + action, + data: { ...data, id: entityId }, + timestamp: new Date().toISOString(), + deviceId: this.deviceId, + }; + this.pendingChanges.push(change); + + // Persist to local storage for crash recovery + this.savePendingChanges(); + + // Try immediate sync if online + if (this.isOnline && !this.isSyncing) { + this.sync(); + } + } + + /* ─── Full sync cycle ─── */ + async sync() { + if (!this.token || !this.isOnline || this.isSyncing) return; + this.isSyncing = true; + this.emit("status", "syncing"); + + try { + const res = await fetch(`${this.serverUrl}/api/sync`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${this.token}`, + "X-Device-Id": this.deviceId, + }, + body: JSON.stringify({ + lastSyncTimestamp: this.lastSyncTimestamp, + deviceId: this.deviceId, + changes: this.pendingChanges, + }), + }); + + if (!res.ok) throw new Error(`Sync failed: ${res.status}`); + const result = await res.json(); + + // Process results + for (const pushed of result.pushed) { + if (pushed.status === "ok") { + this.pendingChanges = this.pendingChanges.filter((c) => c.data?.id !== pushed.id); + } + } + + // Handle conflicts + for (const conflict of result.conflicts) { + this.emit("conflict", conflict); + } + + // Apply pulled changes from server + for (const change of result.pulled) { + this.emit("remote_change", change); + } + + this.lastSyncTimestamp = result.syncTimestamp; + this.savePendingChanges(); + this.emit("status", "synced"); + } catch (err) { + this.emit("status", "error"); + console.error("Sync error:", err); + } finally { + this.isSyncing = false; + } + } + + /* ─── Local persistence for pending changes ─── */ + savePendingChanges() { + try { + if (typeof localStorage !== "undefined") { + localStorage.setItem(`inknote_pending_${this.deviceId}`, JSON.stringify(this.pendingChanges)); + localStorage.setItem(`inknote_last_sync_${this.deviceId}`, this.lastSyncTimestamp || ""); + } + } catch { /* ignore storage errors */ } + } + + loadPendingChanges() { + try { + if (typeof localStorage !== "undefined") { + const pending = localStorage.getItem(`inknote_pending_${this.deviceId}`); + if (pending) this.pendingChanges = JSON.parse(pending); + this.lastSyncTimestamp = localStorage.getItem(`inknote_last_sync_${this.deviceId}`) || null; + } + } catch { /* ignore storage errors */ } + } + + /* ─── API helpers ─── */ + async fetchNotes() { + const res = await fetch(`${this.serverUrl}/api/notes`, { + headers: { Authorization: `Bearer ${this.token}`, "X-Device-Id": this.deviceId }, + }); + return res.json(); + } + + async fetchNote(id) { + const res = await fetch(`${this.serverUrl}/api/notes/${id}`, { + headers: { Authorization: `Bearer ${this.token}`, "X-Device-Id": this.deviceId }, + }); + return res.json(); + } + + async createNote(note) { + const res = await fetch(`${this.serverUrl}/api/notes`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.token}`, + "X-Device-Id": this.deviceId, + }, + body: JSON.stringify(note), + }); + const data = await res.json(); + this.queueChange("note", data.id, "create", note); + return data; + } + + async updateNote(id, updates) { + const res = await fetch(`${this.serverUrl}/api/notes/${id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.token}`, + "X-Device-Id": this.deviceId, + }, + body: JSON.stringify(updates), + }); + if (res.status === 409) { + const conflict = await res.json(); + this.emit("conflict", conflict); + return conflict; + } + this.queueChange("note", id, "update", updates); + return res.json(); + } + + async deleteNote(id) { + await fetch(`${this.serverUrl}/api/notes/${id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${this.token}`, "X-Device-Id": this.deviceId }, + }); + this.queueChange("note", id, "delete", { id }); + } + + async uploadPDF(file) { + const formData = new FormData(); + formData.append("pdf", file); + const res = await fetch(`${this.serverUrl}/api/notes/pdf`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.token}`, + "X-Device-Id": this.deviceId, + }, + body: formData, + }); + return res.json(); + } + + async saveDrawing(noteId, drawingDataUrl) { + await fetch(`${this.serverUrl}/api/notes/${noteId}/drawing`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.token}`, + "X-Device-Id": this.deviceId, + }, + body: JSON.stringify({ drawingData: drawingDataUrl }), + }); + } +} + +export { SyncEngine }; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..14193c8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +version: "3.8" + +services: + # ─── Backend API + WebSocket Server ─── + inknote-server: + build: + context: ./backend + dockerfile: Dockerfile + container_name: inknote-server + restart: unless-stopped + ports: + - "4000:4000" + environment: + - PORT=4000 + - JWT_SECRET=${JWT_SECRET:-please-change-this-secret-key} + - ENCRYPTION_KEY=${ENCRYPTION_KEY} + - NODE_ENV=production + volumes: + - inknote-data:/app/data + - inknote-uploads:/app/uploads + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:4000/api/health"] + interval: 30s + timeout: 5s + retries: 3 + + # ─── Frontend (Production Build) ─── + inknote-web: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: inknote-web + restart: unless-stopped + ports: + - "3000:80" + depends_on: + - inknote-server + + # ─── Optional: PostgreSQL for production ─── + # Uncomment to use PostgreSQL instead of SQLite + # inknote-db: + # image: postgres:16-alpine + # container_name: inknote-db + # restart: unless-stopped + # environment: + # POSTGRES_DB: inknote + # POSTGRES_USER: inknote + # POSTGRES_PASSWORD: ${DB_PASSWORD:-change-me} + # volumes: + # - postgres-data:/var/lib/postgresql/data + # ports: + # - "5432:5432" + +volumes: + inknote-data: + inknote-uploads: + # postgres-data: \ No newline at end of file diff --git a/note-app.jsx b/note-app.jsx new file mode 100644 index 0000000..0401a99 --- /dev/null +++ b/note-app.jsx @@ -0,0 +1,1116 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from "react"; + +/* ─────────────────────── helpers ─────────────────────── */ +const uid = () => Math.random().toString(36).slice(2, 10); +const now = () => new Date().toISOString(); +const fmtDate = (d) => { + const dt = new Date(d); + const opts = { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }; + return dt.toLocaleDateString("en-US", opts); +}; + +/* ─────────────────────── default data ─────────────────────── */ +const DEFAULT_FOLDERS = [ + { id: "all", name: "All Notes", icon: "📋", system: true }, + { id: "starred", name: "Starred", icon: "⭐", system: true }, + { id: "drawings", name: "Drawings", icon: "🎨", system: true }, + { id: "pdfs", name: "PDFs", icon: "📄", system: true }, + { id: "trash", name: "Trash", icon: "🗑️", system: true }, +]; + +const COLORS = [ + "#1a1a2e", "#e94560", "#0f3460", "#16213e", "#533483", + "#e07c24", "#21bf73", "#fd5e53", "#3282b8", "#ff6b6b", + "#4ecdc4", "#f7dc6f", "#bb8fce", "#85c1e9", "#f8c471", + "#82e0aa", "#f1948a", "#aeb6bf", "#ffffff", "#000000", +]; + +const PEN_SIZES = [1, 2, 3, 5, 8, 12, 20]; + +const COVER_GRADIENTS = [ + "linear-gradient(135deg, #0f0c29, #302b63, #24243e)", + "linear-gradient(135deg, #e44d26, #f16529, #ffd700)", + "linear-gradient(135deg, #134e5e, #71b280)", + "linear-gradient(135deg, #8e2de2, #4a00e0)", + "linear-gradient(135deg, #fc4a1a, #f7b733)", + "linear-gradient(135deg, #00b4db, #0083b0)", + "linear-gradient(135deg, #2c3e50, #3498db)", + "linear-gradient(135deg, #c94b4b, #4b134f)", + "linear-gradient(135deg, #11998e, #38ef7d)", + "linear-gradient(135deg, #373b44, #4286f4)", + "linear-gradient(135deg, #141e30, #243b55)", + "linear-gradient(135deg, #f12711, #f5af19)", +]; + +/* ────────────────────── Icons (inline SVG) ────────────────────── */ +const Icons = { + plus: ( + + ), + search: ( + + ), + bold: , + italic: , + underline: , + list: , + listOrdered: 123, + checkbox: , + trash: , + star: , + starFill: , + pen: , + eraser: , + undo: , + redo: , + sun: , + moon: , + folder: , + back: , + menu: , + download: , + highlight: , + notebook: , + settings: , + edit: , + sync: , + image: , + close: , +}; + + +/* ────────────────────── Drawing Canvas Component ────────────────────── */ +function DrawingCanvas({ drawingData, onSave, dark, standalone = false, overlayMode = false }) { + const canvasRef = useRef(null); + const ctxRef = useRef(null); + const [isDrawing, setIsDrawing] = useState(false); + const [tool, setTool] = useState("pen"); + const [color, setColor] = useState("#1a1a2e"); + const [size, setSize] = useState(3); + const [history, setHistory] = useState([]); + const [historyIdx, setHistoryIdx] = useState(-1); + const [showColorPicker, setShowColorPicker] = useState(false); + const [showSizePicker, setShowSizePicker] = useState(false); + const lastPoint = useRef(null); + + const T = useMemo(() => ({ + bg: dark ? "#0d1117" : "#ffffff", + toolbar: dark ? "#161b22" : "#f6f8fa", + border: dark ? "#30363d" : "#d0d7de", + text: dark ? "#e6edf3" : "#1f2328", + muted: dark ? "#8b949e" : "#656d76", + active: dark ? "#388bfd" : "#0969da", + hover: dark ? "#1c2128" : "#eef1f4", + }), [dark]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const parent = canvas.parentElement; + if (standalone || overlayMode) { + canvas.width = parent.clientWidth; + canvas.height = parent.clientHeight; + } else { + canvas.width = parent.clientWidth; + canvas.height = Math.max(600, parent.clientHeight); + } + const ctx = canvas.getContext("2d"); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctxRef.current = ctx; + + if (drawingData && drawingData.length > 0) { + const img = new Image(); + img.onload = () => { + ctx.drawImage(img, 0, 0); + saveToHistory(); + }; + img.src = drawingData; + } else { + saveToHistory(); + } + }, []); + + const saveToHistory = () => { + const canvas = canvasRef.current; + if (!canvas) return; + const data = canvas.toDataURL(); + setHistory((prev) => { + const next = prev.slice(0, historyIdx + 1 || prev.length); + next.push(data); + return next; + }); + setHistoryIdx((prev) => prev + 1); + }; + + const undo = () => { + if (historyIdx <= 0) return; + const newIdx = historyIdx - 1; + setHistoryIdx(newIdx); + const img = new Image(); + img.onload = () => { + const ctx = ctxRef.current; + ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); + ctx.drawImage(img, 0, 0); + }; + img.src = history[newIdx]; + }; + + const redo = () => { + if (historyIdx >= history.length - 1) return; + const newIdx = historyIdx + 1; + setHistoryIdx(newIdx); + const img = new Image(); + img.onload = () => { + const ctx = ctxRef.current; + ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); + ctx.drawImage(img, 0, 0); + }; + img.src = history[newIdx]; + }; + + const getPos = (e) => { + const canvas = canvasRef.current; + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + if (e.touches) { + return { + x: (e.touches[0].clientX - rect.left) * scaleX, + y: (e.touches[0].clientY - rect.top) * scaleY, + }; + } + return { + x: (e.clientX - rect.left) * scaleX, + y: (e.clientY - rect.top) * scaleY, + }; + }; + + const startDraw = (e) => { + e.preventDefault(); + const pos = getPos(e); + lastPoint.current = pos; + setIsDrawing(true); + const ctx = ctxRef.current; + ctx.beginPath(); + ctx.moveTo(pos.x, pos.y); + if (tool === "eraser") { + ctx.globalCompositeOperation = "destination-out"; + ctx.lineWidth = size * 4; + } else if (tool === "highlighter") { + ctx.globalCompositeOperation = "source-over"; + ctx.strokeStyle = color + "55"; + ctx.lineWidth = size * 6; + } else { + ctx.globalCompositeOperation = "source-over"; + ctx.strokeStyle = color; + ctx.lineWidth = size; + } + }; + + const draw = (e) => { + if (!isDrawing) return; + e.preventDefault(); + const pos = getPos(e); + const ctx = ctxRef.current; + ctx.lineTo(pos.x, pos.y); + ctx.stroke(); + lastPoint.current = pos; + }; + + const stopDraw = (e) => { + if (!isDrawing) return; + e?.preventDefault(); + setIsDrawing(false); + ctxRef.current.closePath(); + ctxRef.current.globalCompositeOperation = "source-over"; + saveToHistory(); + if (onSave) { + onSave(canvasRef.current.toDataURL()); + } + }; + + const clearCanvas = () => { + const ctx = ctxRef.current; + ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); + saveToHistory(); + if (onSave) onSave(canvasRef.current.toDataURL()); + }; + + const toolbarStyle = { + display: "flex", + alignItems: "center", + gap: "4px", + padding: "6px 10px", + background: T.toolbar, + borderBottom: `1px solid ${T.border}`, + flexWrap: "wrap", + position: overlayMode ? "absolute" : "relative", + top: overlayMode ? 0 : undefined, + left: overlayMode ? 0 : undefined, + right: overlayMode ? 0 : undefined, + zIndex: overlayMode ? 20 : undefined, + borderRadius: overlayMode ? "0 0 8px 8px" : 0, + }; + + const btnStyle = (active) => ({ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 34, + height: 34, + borderRadius: 8, + border: "none", + cursor: "pointer", + background: active ? T.active + "22" : "transparent", + color: active ? T.active : T.text, + transition: "all 0.15s", + }); + + return ( +
+
+ + + +
+ + +
+ + {/* Color */} +
+ + {showColorPicker && ( +
+ {COLORS.map((c) => ( +
+ )} +
+ + {/* Size */} +
+ + {showSizePicker && ( +
+ {PEN_SIZES.map((s) => ( + + ))} +
+ )} +
+ +
+ +
+ +
+ +
+
+ ); +} + + +/* ────────────────────── Rich Text Editor ────────────────────── */ +function RichTextEditor({ content, onChange, dark }) { + const editorRef = useRef(null); + + const T = useMemo(() => ({ + bg: dark ? "#0d1117" : "#ffffff", + toolbar: dark ? "#161b22" : "#f6f8fa", + border: dark ? "#30363d" : "#d0d7de", + text: dark ? "#e6edf3" : "#1f2328", + muted: dark ? "#8b949e" : "#656d76", + active: dark ? "#388bfd" : "#0969da", + }), [dark]); + + useEffect(() => { + if (editorRef.current && editorRef.current.innerHTML !== content) { + editorRef.current.innerHTML = content || ""; + } + }, [content]); + + const exec = (cmd, val = null) => { + document.execCommand(cmd, false, val); + editorRef.current?.focus(); + if (onChange) onChange(editorRef.current.innerHTML); + }; + + const handleInput = () => { + if (onChange) onChange(editorRef.current.innerHTML); + }; + + const btnStyle = { + display: "flex", alignItems: "center", justifyContent: "center", + width: 32, height: 32, borderRadius: 6, border: "none", + cursor: "pointer", background: "transparent", color: T.text, + transition: "all 0.15s", + }; + + return ( +
+
+ + + +
+ + + +
+ +
+
+
+ ); +} + + +/* ────────────────────── PDF Viewer with Annotation ────────────────────── */ +function PDFViewer({ pdfData, annotations, onAnnotationSave, dark }) { + const [drawMode, setDrawMode] = useState(false); + const [pageNum, setPageNum] = useState(1); + const [totalPages, setTotalPages] = useState(1); + + const T = useMemo(() => ({ + bg: dark ? "#0d1117" : "#f6f8fa", + toolbar: dark ? "#161b22" : "#ffffff", + border: dark ? "#30363d" : "#d0d7de", + text: dark ? "#e6edf3" : "#1f2328", + muted: dark ? "#8b949e" : "#656d76", + active: dark ? "#388bfd" : "#0969da", + }), [dark]); + + return ( +
+
+ +
+ + Page {pageNum} / {totalPages} + +
+ +
+ {pdfData ? ( +
+
+
📄
+
PDF loaded — {pdfData.name}
+
+ In a full deployment, PDF.js renders pages here. The annotation canvas overlays each page for freehand drawing, highlighting, and text annotations. +
+
+ {drawMode && ( +
+ +
+ )} +
+ ) : ( +
+
📄
+
No PDF loaded
+
Import a PDF to view and annotate it
+
+ )} +
+
+ ); +} + + +/* ────────────────────── Create Notebook Modal ────────────────────── */ +function CreateNotebookModal({ onClose, onCreate, dark }) { + const [name, setName] = useState(""); + const [selectedCover, setSelectedCover] = useState(0); + + const T = useMemo(() => ({ + bg: dark ? "#161b22" : "#ffffff", + border: dark ? "#30363d" : "#d0d7de", + text: dark ? "#e6edf3" : "#1f2328", + muted: dark ? "#8b949e" : "#656d76", + active: dark ? "#388bfd" : "#0969da", + }), [dark]); + + return ( +
+
e.stopPropagation()}> +

+ New Notebook +

+ +
+ + {name || "Notebook"} + +
+ + setName(e.target.value)} + placeholder="Notebook name..." + style={{ + width: "100%", padding: "10px 14px", borderRadius: 10, border: `1px solid ${T.border}`, + background: dark ? "#0d1117" : "#f6f8fa", color: T.text, fontSize: 14, + outline: "none", marginBottom: 16, boxSizing: "border-box", + }} + /> + +
+
Choose cover
+
+ {COVER_GRADIENTS.map((g, i) => ( +
+
+ +
+ + +
+
+
+ ); +} + + +/* ────────────────────────── MAIN APP ────────────────────────── */ +export default function NoteApp() { + const [dark, setDark] = useState(true); + const [sidebarOpen, setSidebarOpen] = useState(true); + const [activeFolder, setActiveFolder] = useState("all"); + const [activeNote, setActiveNote] = useState(null); + const [view, setView] = useState("notes"); // notes | drawing | pdf | settings + const [searchQuery, setSearchQuery] = useState(""); + const [showNewNotebook, setShowNewNotebook] = useState(false); + const [syncStatus, setSyncStatus] = useState("synced"); // synced | syncing | offline + const fileInputRef = useRef(null); + + const [notebooks, setNotebooks] = useState([ + { id: "nb1", name: "Personal", cover: 0, createdAt: now() }, + { id: "nb2", name: "Work", cover: 2, createdAt: now() }, + { id: "nb3", name: "Ideas", cover: 4, createdAt: now() }, + ]); + + const [notes, setNotes] = useState([ + { + id: "n1", type: "text", title: "Welcome to InkNote", folderId: "nb1", + content: "

Welcome to InkNote ✨

Your cross-platform note-taking companion.

Features include:

  • Rich text editing with formatting
  • Freehand drawing with pen & highlighter
  • PDF import & annotation
  • Notebook organization with custom covers
  • Dark & light themes

Start creating! Click + to add a new note.

", + starred: true, createdAt: "2025-06-10T10:00:00Z", updatedAt: now(), + }, + { + id: "n2", type: "drawing", title: "Quick Sketch", folderId: "nb3", + drawingData: null, starred: false, createdAt: "2025-06-11T08:30:00Z", updatedAt: now(), + }, + { + id: "n3", type: "text", title: "Meeting Notes", folderId: "nb2", + content: "

Sprint Planning

Discussed roadmap for Q3. Key points:

  1. Sync engine improvements
  2. Mobile gesture support
  3. PDF annotation export
", + starred: false, createdAt: "2025-06-12T14:00:00Z", updatedAt: now(), + }, + ]); + + const [pdfs, setPdfs] = useState([]); + + // Theme + const T = useMemo(() => dark ? { + bg: "#0d1117", sidebar: "#010409", sidebarHover: "#161b22", sidebarActive: "#161b22", + card: "#161b22", cardHover: "#1c2128", toolbar: "#161b22", + border: "#30363d", borderLight: "#21262d", + text: "#e6edf3", textSecondary: "#c9d1d9", muted: "#8b949e", faint: "#484f58", + accent: "#388bfd", accentSoft: "#388bfd22", accentText: "#58a6ff", + danger: "#f85149", dangerSoft: "#f8514922", + success: "#3fb950", successSoft: "#3fb95022", + warning: "#d29922", + shadow: "0 8px 24px rgba(0,0,0,0.4)", + inputBg: "#0d1117", + } : { + bg: "#f6f8fa", sidebar: "#ffffff", sidebarHover: "#f3f4f6", sidebarActive: "#eef2ff", + card: "#ffffff", cardHover: "#f9fafb", toolbar: "#ffffff", + border: "#d0d7de", borderLight: "#e5e7eb", + text: "#1f2328", textSecondary: "#424a53", muted: "#656d76", faint: "#afb8c1", + accent: "#0969da", accentSoft: "#0969da18", accentText: "#0550ae", + danger: "#cf222e", dangerSoft: "#cf222e15", + success: "#1a7f37", successSoft: "#1a7f3715", + warning: "#9a6700", + shadow: "0 8px 24px rgba(0,0,0,0.08)", + inputBg: "#f6f8fa", + }, [dark]); + + // Filtered notes + const filteredNotes = useMemo(() => { + let list = notes; + if (activeFolder === "starred") list = list.filter((n) => n.starred); + else if (activeFolder === "drawings") list = list.filter((n) => n.type === "drawing"); + else if (activeFolder === "pdfs") return pdfs; + else if (activeFolder === "trash") return []; + else if (activeFolder !== "all") list = list.filter((n) => n.folderId === activeFolder); + if (searchQuery) { + const q = searchQuery.toLowerCase(); + list = list.filter((n) => n.title.toLowerCase().includes(q) || (n.content || "").toLowerCase().includes(q)); + } + return list.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); + }, [notes, pdfs, activeFolder, searchQuery]); + + const currentNote = activeNote ? notes.find((n) => n.id === activeNote) || pdfs.find((p) => p.id === activeNote) : null; + + // Actions + const createNote = (type = "text") => { + const fid = ["all", "starred", "drawings", "pdfs", "trash"].includes(activeFolder) ? (notebooks[0]?.id || "nb1") : activeFolder; + const note = { + id: uid(), type, title: type === "drawing" ? "New Drawing" : "Untitled Note", + folderId: fid, content: "", drawingData: null, starred: false, + createdAt: now(), updatedAt: now(), + }; + setNotes((prev) => [note, ...prev]); + setActiveNote(note.id); + setView(type === "drawing" ? "drawing" : "notes"); + simulateSync(); + }; + + const updateNote = (id, updates) => { + setNotes((prev) => prev.map((n) => n.id === id ? { ...n, ...updates, updatedAt: now() } : n)); + simulateSync(); + }; + + const deleteNote = (id) => { + setNotes((prev) => prev.filter((n) => n.id !== id)); + if (activeNote === id) setActiveNote(null); + simulateSync(); + }; + + const toggleStar = (id) => { + setNotes((prev) => prev.map((n) => n.id === id ? { ...n, starred: !n.starred } : n)); + }; + + const importPDF = (e) => { + const file = e.target.files[0]; + if (!file) return; + const pdf = { + id: uid(), type: "pdf", title: file.name, name: file.name, + pdfData: { name: file.name, size: file.size }, + annotations: null, starred: false, + createdAt: now(), updatedAt: now(), + }; + setPdfs((prev) => [pdf, ...prev]); + setActiveNote(pdf.id); + setView("pdf"); + setActiveFolder("pdfs"); + simulateSync(); + }; + + const createNotebook = ({ name, cover }) => { + const nb = { id: uid(), name, cover, createdAt: now() }; + setNotebooks((prev) => [...prev, nb]); + setShowNewNotebook(false); + setActiveFolder(nb.id); + }; + + const simulateSync = () => { + setSyncStatus("syncing"); + setTimeout(() => setSyncStatus("synced"), 1200); + }; + + // Font import + useEffect(() => { + const link = document.createElement("link"); + link.href = "https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&family=DM+Sans:wght@400;500;600;700&display=swap"; + link.rel = "stylesheet"; + document.head.appendChild(link); + }, []); + + /* ─── SIDEBAR ─── */ + const renderSidebar = () => ( +
+ {/* Logo area */} +
+
+
✏️
+ InkNote +
+
+ +
+
+ + {/* Search */} +
+
+ {Icons.search} + setSearchQuery(e.target.value)} + placeholder="Search notes..." + style={{ + border: "none", background: "transparent", outline: "none", flex: 1, + color: T.text, fontSize: 13, fontFamily: "'DM Sans', sans-serif", + }} + /> +
+
+ + {/* System folders */} +
+ {DEFAULT_FOLDERS.map((f) => ( + + ))} +
+ + {/* Notebooks */} +
+
+ Notebooks + +
+ {notebooks.map((nb) => ( + + ))} +
+ + {/* Bottom actions */} +
+
+ ● + {syncStatus === "synced" ? "All changes synced" : syncStatus === "syncing" ? "Syncing..." : "Offline"} +
+
+
+ ); + + /* ─── NOTE LIST ─── */ + const renderNoteList = () => { + const currentFolderName = (() => { + const sf = DEFAULT_FOLDERS.find((f) => f.id === activeFolder); + if (sf) return sf.name; + const nb = notebooks.find((n) => n.id === activeFolder); + return nb ? nb.name : "Notes"; + })(); + + return ( +
+ {/* Header */} +
+
+ +

+ {currentFolderName} +

+
+
+ + + + +
+
+ + {/* Note cards */} +
+ {filteredNotes.length === 0 ? ( +
+
{activeFolder === "pdfs" ? "📄" : "📝"}
+
+ {activeFolder === "pdfs" ? "No PDFs yet. Import one to get started." : "No notes yet. Create one to get started."} +
+
+ ) : ( + filteredNotes.map((note) => ( + + )) + )} +
+
+ ); + }; + + /* ─── EDITOR AREA ─── */ + const renderEditor = () => { + if (!currentNote) { + return ( +
+
✏️
+
Select a note to edit
+
Or create a new one
+
+ + +
+
+ ); + } + + return ( +
+ {/* Note header */} +
+ { + if (currentNote.type === "pdf") { + setPdfs((prev) => prev.map((p) => p.id === currentNote.id ? { ...p, title: e.target.value } : p)); + } else { + updateNote(currentNote.id, { title: e.target.value }); + } + }} + style={{ + flex: 1, border: "none", background: "transparent", outline: "none", + fontSize: 17, fontWeight: 600, color: T.text, + fontFamily: "'Newsreader', Georgia, serif", + }} + /> + + +
+ + {/* Editor content */} +
+ {currentNote.type === "drawing" ? ( + updateNote(currentNote.id, { drawingData: data })} + dark={dark} + standalone + /> + ) : currentNote.type === "pdf" ? ( + { + setPdfs((prev) => prev.map((p) => p.id === currentNote.id ? { ...p, annotations: data, updatedAt: now() } : p)); + }} + dark={dark} + /> + ) : ( + updateNote(currentNote.id, { content: html })} + dark={dark} + /> + )} +
+
+ ); + }; + + /* ─── MAIN RENDER ─── */ + return ( +
+ + + {renderSidebar()} + {renderNoteList()} + {renderEditor()} + + {showNewNotebook && ( + setShowNewNotebook(false)} + onCreate={createNotebook} + dark={dark} + /> + )} +
+ ); +} \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..a1b2cba --- /dev/null +++ b/readme.md @@ -0,0 +1,212 @@ +# ✏️ InkNote + +A cross-platform note-taking application with rich text editing, freehand drawing, PDF annotation, and encrypted sync — inspired by Samsung Notes. + +--- + +## Features + +### Core +- **Rich Text Notes** — Bold, italic, underline, headings, bullet/numbered lists, checkboxes +- **Freehand Drawing** — Pen, highlighter, eraser with adjustable thickness and color palette (20 colors, 7 pen sizes) +- **Undo/Redo** — Full history stack for drawing operations +- **PDF Import & Annotation** — Import PDFs, overlay drawing/highlighting, persistent annotations +- **Notebooks & Folders** — Create notebooks with custom gradient covers, organize notes by folder +- **Star/Favorite** — Quick access to important notes +- **Dark & Light Mode** — Full theme support with refined color schemes + +### Sync & Storage +- **Offline-First** — Works fully offline, syncs when connection returns +- **Conflict-Aware Sync** — Vector clock–based conflict detection; notifies user of merge conflicts +- **WebSocket Real-Time Push** — Changes appear on other devices instantly +- **Encrypted at Rest** — AES-256-CBC encryption for all note content on the server +- **JWT Authentication** — Secure user accounts with bcrypt password hashing + +### Architecture +- **Self-Hosted Backend** — Node.js + Express + SQLite (or PostgreSQL) +- **Docker Ready** — docker-compose up and you're running +- **Client Sync Engine** — Portable JS library for any frontend (React Native, Flutter JS bridge, Electron, web) + +--- + +## Project Structure + +``` +inknote/ +├── note-app.jsx # Full React UI (artifact / web) +├── docker-compose.yml # Container orchestration +├── backend/ +│ ├── server.js # Express API + WebSocket server +│ ├── sync-client.js # Client-side sync engine library +│ ├── package.json # Node dependencies +│ ├── Dockerfile # Server container +│ └── .env.example # Environment variable template +└── README.md # This file +``` + +--- + +## Quick Start + +### 1. Run the UI (Web / Artifact) + +The `note-app.jsx` file is a self-contained React component. It runs directly as a Claude artifact, or you can embed it in any React project: + +```jsx +import NoteApp from './note-app'; + +function App() { + return ; +} +``` + +### 2. Run the Backend + +```bash +cd backend +cp .env.example .env # Edit secrets! +npm install +npm start # → http://localhost:4000 +``` + +### 3. Docker Deployment + +```bash +# Generate a secure encryption key +export ENCRYPTION_KEY=$(openssl rand -hex 32) +export JWT_SECRET=$(openssl rand -base64 32) + +docker-compose up -d +``` + +The server runs on port `4000`, the web UI on port `3000`. + +--- + +## API Reference + +### Authentication + +| Method | Endpoint | Body | Response | +|--------|-----------------------|-------------------------------|---------------------| +| POST | `/api/auth/register` | `{email, password, displayName}` | `{token, user}` | +| POST | `/api/auth/login` | `{email, password}` | `{token, user}` | + +### Notebooks + +| Method | Endpoint | Description | +|--------|-----------------------|--------------------------| +| GET | `/api/notebooks` | List all notebooks | +| POST | `/api/notebooks` | Create notebook | +| PUT | `/api/notebooks/:id` | Update notebook | +| DELETE | `/api/notebooks/:id` | Soft-delete notebook | + +### Notes + +| Method | Endpoint | Description | +|--------|---------------------------|-------------------------------| +| GET | `/api/notes` | List all notes (metadata) | +| GET | `/api/notes/:id` | Get note with decrypted content | +| POST | `/api/notes` | Create note | +| PUT | `/api/notes/:id` | Update note (conflict-aware) | +| DELETE | `/api/notes/:id` | Soft-delete note | +| PUT | `/api/notes/:id/drawing` | Save drawing data (base64) | +| POST | `/api/notes/pdf` | Upload PDF (multipart/form) | + +### Sync + +| Method | Endpoint | Description | +|--------|------------- |------------------------------------------| +| POST | `/api/sync` | Bidirectional sync with conflict reports | + +### WebSocket + +Connect to `ws://localhost:4000/ws`, then send: +```json +{"type": "auth", "token": "JWT_TOKEN", "deviceId": "device-123"} +``` + +Receive real-time events: `note_created`, `note_updated`, `note_deleted`, `notebook_created`, etc. + +--- + +## Sync Engine (Client) + +```javascript +import { SyncEngine } from './sync-client'; + +const sync = new SyncEngine('https://your-server.com', 'device-abc'); + +// Authenticate +await sync.authenticate('user@example.com', 'password'); + +// Listen for events +sync.on('status', (status) => console.log('Sync:', status)); +sync.on('conflict', (conflict) => { + // Present both versions to user, let them choose + console.log('Conflict:', conflict); +}); +sync.on('remote_change', (change) => { + // Update local state with remote changes + console.log('Remote change:', change); +}); + +// Start syncing (every 30s + WebSocket push) +sync.start(); + +// CRUD operations auto-queue for sync +await sync.createNote({ type: 'text', title: 'Hello', content: '

World

' }); +await sync.updateNote('note-id', { title: 'Updated' }); +await sync.deleteNote('note-id'); +await sync.uploadPDF(fileBlob); +await sync.saveDrawing('note-id', canvasDataUrl); +``` + +--- + +## Conflict Resolution + +InkNote uses **vector clocks** to detect conflicts: + +1. Each device maintains a logical clock counter +2. On save, the device increments its counter and includes the full vector clock +3. The server compares clocks — if the client clock is strictly newer, the update applies +4. If clocks are concurrent (both have changes the other doesn't), a **409 Conflict** is returned +5. The client receives both versions and can present a merge UI to the user + +--- + +## Extending to Native Platforms + +### React Native +Use `note-app.jsx` components with React Native adaptations (replace HTML canvas with `react-native-canvas` or `@shopify/react-native-skia` for drawing). The sync engine works as-is. + +### Flutter +Use the sync engine via a JavaScript bridge, or port the API calls to Dart. For drawing, use Flutter's `CustomPainter`. For PDF, use `flutter_pdfview`. + +### Electron +Wrap the React app directly in Electron for desktop. Add native file system access for PDF import/export. + +--- + +## Security Notes + +- All note content is encrypted with AES-256-CBC before database storage +- Passwords are hashed with bcrypt (12 rounds) +- JWT tokens expire after 30 days +- HTTPS should be enforced in production (use a reverse proxy like Nginx or Pangolin) +- The encryption key should be stored securely (e.g., environment variable, secrets manager) + +--- + +## Tech Stack + +| Layer | Technology | +|-----------|-------------------------------------| +| Frontend | React (web), adaptable to RN/Flutter | +| Backend | Node.js, Express | +| Database | SQLite (dev), PostgreSQL (prod) | +| Real-time | WebSocket (ws library) | +| Auth | JWT + bcrypt | +| Encryption| AES-256-CBC | +| Container | Docker + docker-compose | \ No newline at end of file