From a8779d7be14367428c26deb5973b54e97ab25c10 Mon Sep 17 00:00:00 2001 From: Honey Date: Wed, 18 Feb 2026 20:07:10 +0100 Subject: [PATCH] Alpha Version of Syncing and Desktop/Anroid PWA --- backend/server.js | 68 ++- frontend/index.html | 29 +- frontend/nginx.conf | 65 +++ frontend/public/manifest.json | 17 + frontend/public/sw.js | 66 +++ frontend/src/NoteApp.jsx | 1013 ++++++++++++++++++--------------- frontend/src/main.jsx | 40 +- frontend/src/offlineStore.js | 204 +++++++ frontend/src/syncEngine.js | 333 +++++++++++ 9 files changed, 1381 insertions(+), 454 deletions(-) create mode 100644 frontend/public/manifest.json create mode 100644 frontend/public/sw.js create mode 100644 frontend/src/offlineStore.js create mode 100644 frontend/src/syncEngine.js diff --git a/backend/server.js b/backend/server.js index 16d6a1a..960998f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -37,11 +37,13 @@ const http = require("http"); 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 DATA_DIR = path.join(__dirname, "data"); const UPLOAD_DIR = path.join(__dirname, "uploads"); +if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true }); /* ─────────── Database ─────────── */ -const db = new Database(path.join(__dirname, "inknote.db")); +const db = new Database(path.join(DATA_DIR, "inknote.db")); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); @@ -213,6 +215,43 @@ app.post("/api/auth/login", (req, res) => { res.json({ token, user: { id: user.id, email: user.email, displayName: user.display_name } }); }); +/* ─── Auth profile routes ─── */ +app.get("/api/auth/me", authMiddleware, (req, res) => { + const user = db.prepare("SELECT id, email, display_name FROM users WHERE id = ?").get(req.userId); + if (!user) return res.status(404).json({ error: "User not found" }); + res.json({ id: user.id, email: user.email, displayName: user.display_name }); +}); + +app.put("/api/auth/profile", authMiddleware, (req, res) => { + const { displayName } = req.body; + if (!displayName || !displayName.trim()) return res.status(400).json({ error: "Display name required" }); + db.prepare("UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?").run(displayName.trim(), req.userId); + const user = db.prepare("SELECT id, email, display_name FROM users WHERE id = ?").get(req.userId); + res.json({ id: user.id, email: user.email, displayName: user.display_name }); +}); + +app.put("/api/auth/email", authMiddleware, (req, res) => { + const { newEmail, password } = req.body; + if (!newEmail || !password) return res.status(400).json({ error: "Email and password required" }); + const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId); + if (!bcrypt.compareSync(password, user.password_hash)) return res.status(401).json({ error: "Wrong password" }); + const exists = db.prepare("SELECT id FROM users WHERE email = ? AND id != ?").get(newEmail, req.userId); + if (exists) return res.status(409).json({ error: "Email already in use" }); + db.prepare("UPDATE users SET email = ?, updated_at = datetime('now') WHERE id = ?").run(newEmail, req.userId); + res.json({ id: user.id, email: newEmail, displayName: user.display_name }); +}); + +app.put("/api/auth/password", authMiddleware, (req, res) => { + const { currentPassword, newPassword } = req.body; + if (!currentPassword || !newPassword) return res.status(400).json({ error: "Both passwords required" }); + if (newPassword.length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" }); + const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId); + if (!bcrypt.compareSync(currentPassword, user.password_hash)) return res.status(401).json({ error: "Current password is wrong" }); + const hash = bcrypt.hashSync(newPassword, 12); + db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(hash, req.userId); + res.json({ success: true }); +}); + /* ─── 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); @@ -256,6 +295,11 @@ app.get("/api/notes", authMiddleware, (req, res) => { res.json(notes); }); +app.get("/api/notes/trash", authMiddleware, (req, res) => { + const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, tags, sort_order, created_at, updated_at, deleted_at, version FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_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" }); @@ -328,12 +372,32 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => { res.json({ success: true, vectorClock: newClock }); }); +/* ─── Trash batch endpoint (MUST be before :id routes) ─── */ +app.delete("/api/notes/trash/empty", authMiddleware, (req, res) => { + db.prepare("DELETE FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL").run(req.userId); + res.json({ success: true }); +}); + 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 }); }); +/* ─── Trash endpoints ─── */ +app.put("/api/notes/:id/restore", authMiddleware, (req, res) => { + 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" }); + db.prepare("UPDATE notes SET deleted_at = NULL, updated_at = datetime('now') WHERE id = ?").run(req.params.id); + broadcastToUser(req.userId, { type: "note_restored", data: { id: req.params.id } }, req.deviceId); + res.json({ success: true }); +}); + +app.delete("/api/notes/:id/permanent", authMiddleware, (req, res) => { + db.prepare("DELETE FROM notes WHERE id = ? AND user_id = ?").run(req.params.id, req.userId); + res.json({ success: true }); +}); + /* ─── Drawing data route ─── */ app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => { const { drawingData } = req.body; @@ -472,5 +536,5 @@ 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`); + console.log(` 💾 Database: ${path.join(DATA_DIR, "inknote.db")}\n`); }); \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index beb6e3b..d89647b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,13 +2,34 @@ - - - InkNote + + + + + + + + + + + - + InkNote + +
+ +
InkNote
+
diff --git a/frontend/nginx.conf b/frontend/nginx.conf index e69de29..d9410ed 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -0,0 +1,65 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Allow large drawing data + body size + client_max_body_size 50m; + + # PWA service worker — MUST not be cached by browser/proxy + location = /sw.js { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Service-Worker-Allowed "/"; + add_header Content-Type "application/javascript"; + try_files $uri =404; + } + + # PWA manifest + location = /manifest.json { + add_header Cache-Control "public, max-age=3600"; + add_header Content-Type "application/manifest+json"; + try_files $uri =404; + } + + # Static assets — long cache with content hash from Vite + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # Icons + location ~* ^/icon-.*\.png$ { + add_header Cache-Control "public, max-age=86400"; + try_files $uri =404; + } + + # SPA fallback — all other routes serve index.html + location / { + try_files $uri $uri/ /index.html; + } + + # API proxy + location /api/ { + proxy_pass http://inknote-server:4000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 10; + proxy_send_timeout 60; + proxy_read_timeout 60; + } + + # WebSocket proxy + location /ws { + proxy_pass http://inknote-server:4000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 86400; + } +} \ No newline at end of file diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..174da8a --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "InkNote", + "short_name": "InkNote", + "description": "Write & draw — notes that sync everywhere", + "start_url": ".", + "scope": ".", + "display": "standalone", + "background_color": "#0d1117", + "theme_color": "#0d1117", + "orientation": "any", + "categories": ["productivity", "utilities"], + "icons": [ + { "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, + { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ], + "prefer_related_applications": false +} \ No newline at end of file diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..2d861b3 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,66 @@ +const CACHE_NAME = 'inknote-v2'; + +// Install — cache app shell on first load +self.addEventListener('install', (e) => { + e.waitUntil( + caches.open(CACHE_NAME).then(async (cache) => { + // Cache the main page and manifest + const urls = ['/', '/manifest.json', '/icon-192.png', '/icon-512.png']; + for (const url of urls) { + try { await cache.add(url); } catch (err) { console.warn('[SW] Failed to cache:', url); } + } + }) + ); + self.skipWaiting(); +}); + +// Activate — clean old caches +self.addEventListener('activate', (e) => { + e.waitUntil( + caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))) + ); + self.clients.claim(); +}); + +// Fetch handler +self.addEventListener('fetch', (e) => { + const url = new URL(e.request.url); + + // Skip API/WebSocket requests entirely — handled by app + IndexedDB + if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) return; + + // Skip non-GET requests + if (e.request.method !== 'GET') return; + + // Navigation (HTML pages) — network-first, fallback to cache + if (e.request.mode === 'navigate') { + e.respondWith( + fetch(e.request) + .then(r => { const c = r.clone(); caches.open(CACHE_NAME).then(cache => cache.put('/', c)); return r; }) + .catch(() => caches.match('/')) + ); + return; + } + + // JS/CSS/images — stale-while-revalidate + e.respondWith( + caches.match(e.request).then(cached => { + const fetchP = fetch(e.request).then(r => { + if (r.ok && r.type === 'basic') { + caches.open(CACHE_NAME).then(cache => cache.put(e.request, r.clone())); + } + return r; + }).catch(() => cached); + return cached || fetchP; + }) + ); +}); + +// Background sync +self.addEventListener('sync', (e) => { + if (e.tag === 'sync-changes') { + e.waitUntil(self.clients.matchAll().then(clients => { + clients.forEach(c => c.postMessage({ type: 'FLUSH_QUEUE' })); + })); + } +}); \ No newline at end of file diff --git a/frontend/src/NoteApp.jsx b/frontend/src/NoteApp.jsx index dd91f84..4767f78 100644 --- a/frontend/src/NoteApp.jsx +++ b/frontend/src/NoteApp.jsx @@ -1,37 +1,30 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import * as sync from "./syncEngine.js"; const uid = () => Math.random().toString(36).slice(2, 10); const now = () => new Date().toISOString(); -const fmtDate = (d) => new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); +const fmtDate = (d) => new Date(d).toLocaleDateString("en-US",{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"}); const COLORS = ["#0d1117","#e94560","#0f3460","#533483","#e07c24","#21bf73","#fd5e53","#3282b8","#ff6b6b","#4ecdc4","#f7dc6f","#bb8fce","#85c1e9","#f8c471","#82e0aa","#f1948a","#aeb6bf","#ffffff","#f39c12","#000000"]; -const PEN_SIZES = [1, 2, 3, 5, 8, 12, 20]; - +const PEN_SIZES = [1,2,3,5,8,12,20]; const COVERS = [ - { bg: "linear-gradient(135deg,#0f0c29,#302b63,#24243e)", spine: "#1a1540", accent: "#6c63ff" }, - { bg: "linear-gradient(135deg,#1a1a2e,#16213e,#0f3460)", spine: "#0a1628", accent: "#4fc3f7" }, - { bg: "linear-gradient(135deg,#134e5e,#71b280)", spine: "#0e3d4a", accent: "#a5d6a7" }, - { bg: "linear-gradient(135deg,#8e2de2,#4a00e0)", spine: "#3a0099", accent: "#ce93d8" }, - { bg: "linear-gradient(135deg,#fc4a1a,#f7b733)", spine: "#c43d15", accent: "#ffe082" }, - { bg: "linear-gradient(135deg,#c94b4b,#4b134f)", spine: "#3d0f40", accent: "#f48fb1" }, - { bg: "linear-gradient(135deg,#11998e,#38ef7d)", spine: "#0d7a71", accent: "#b9f6ca" }, - { bg: "linear-gradient(135deg,#373b44,#4286f4)", spine: "#2a2e35", accent: "#90caf9" }, - { bg: "linear-gradient(135deg,#141e30,#243b55)", spine: "#0e1520", accent: "#78909c" }, - { bg: "linear-gradient(135deg,#f12711,#f5af19)", spine: "#c01f0d", accent: "#fff59d" }, - { bg: "linear-gradient(135deg,#2c3e50,#3498db)", spine: "#1e2b38", accent: "#81d4fa" }, - { bg: "linear-gradient(135deg,#0f2027,#203a43,#2c5364)", spine: "#0a171c", accent: "#80cbc4" }, - { bg: "linear-gradient(135deg,#aa4b6b,#6b6b83,#3b8d99)", spine: "#7a3550", accent: "#f48fb1" }, - { bg: "linear-gradient(135deg,#1f1c2c,#928dab)", spine: "#15131f", accent: "#b39ddb" }, - { bg: "linear-gradient(135deg,#654ea3,#eaafc8)", spine: "#4e3c7e", accent: "#f8bbd0" }, + {bg:"linear-gradient(135deg,#0f0c29,#302b63,#24243e)",spine:"#1a1540",accent:"#6c63ff"}, + {bg:"linear-gradient(135deg,#1a1a2e,#16213e,#0f3460)",spine:"#0a1628",accent:"#4fc3f7"}, + {bg:"linear-gradient(135deg,#134e5e,#71b280)",spine:"#0e3d4a",accent:"#a5d6a7"}, + {bg:"linear-gradient(135deg,#8e2de2,#4a00e0)",spine:"#3a0099",accent:"#ce93d8"}, + {bg:"linear-gradient(135deg,#fc4a1a,#f7b733)",spine:"#c43d15",accent:"#ffe082"}, + {bg:"linear-gradient(135deg,#c94b4b,#4b134f)",spine:"#3d0f40",accent:"#f48fb1"}, + {bg:"linear-gradient(135deg,#11998e,#38ef7d)",spine:"#0d7a71",accent:"#b9f6ca"}, + {bg:"linear-gradient(135deg,#373b44,#4286f4)",spine:"#2a2e35",accent:"#90caf9"}, + {bg:"linear-gradient(135deg,#141e30,#243b55)",spine:"#0e1520",accent:"#78909c"}, + {bg:"linear-gradient(135deg,#f12711,#f5af19)",spine:"#c01f0d",accent:"#fff59d"}, + {bg:"linear-gradient(135deg,#2c3e50,#3498db)",spine:"#1e2b38",accent:"#81d4fa"}, + {bg:"linear-gradient(135deg,#0f2027,#203a43,#2c5364)",spine:"#0a171c",accent:"#80cbc4"}, + {bg:"linear-gradient(135deg,#aa4b6b,#6b6b83,#3b8d99)",spine:"#7a3550",accent:"#f48fb1"}, + {bg:"linear-gradient(135deg,#1f1c2c,#928dab)",spine:"#15131f",accent:"#b39ddb"}, + {bg:"linear-gradient(135deg,#654ea3,#eaafc8)",spine:"#4e3c7e",accent:"#f8bbd0"}, ]; - -const DEFAULT_FOLDERS = [ - { id: "all", name: "All Notes", icon: "📋" }, - { id: "starred", name: "Starred", icon: "⭐" }, - { id: "pdfs", name: "PDFs", icon: "📄" }, - { id: "trash", name: "Trash", icon: "🗑️" }, -]; - +const DEFAULT_FOLDERS = [{id:"all",name:"All Notes",icon:"📋"},{id:"starred",name:"Starred",icon:"⭐"},{id:"trash",name:"Trash",icon:"🗑️"}]; const I = { plus:, search:, @@ -55,345 +48,360 @@ const I = { text:, draw:, edit:, - palette:, + move:, + restore:, grip:, + logout:, + user:, + x:, + save:, }; - - -/* ═══════ CONFIRM DIALOG ═══════ */ -function ConfirmDialog({ title, message, confirmLabel="Delete", onConfirm, onCancel, dark }) { - const T = useMemo(()=>({ bg:dark?"#161b22":"#fff", border:dark?"#30363d":"#d0d7de", text:dark?"#e6edf3":"#1f2328", muted:dark?"#8b949e":"#656d76", danger:dark?"#f85149":"#cf222e" }),[dark]); - return ( -
-
e.stopPropagation()}> -
{title}
-
{message}
-
- - -
+/* ═══════ Shared Components ═══════ */ +function ConfirmDialog({title,message,confirmLabel="Delete",onConfirm,onCancel,dark}){ + const T=useMemo(()=>({bg:dark?"#161b22":"#fff",border:dark?"#30363d":"#d0d7de",text:dark?"#e6edf3":"#1f2328",muted:dark?"#8b949e":"#656d76",danger:dark?"#f85149":"#cf222e"}),[dark]); + return(
+
e.stopPropagation()}> +
{title}
+
{message}
+
+ +
- ); +
); } -/* ═══════ CONTEXT MENU ═══════ */ -function ContextMenu({ x, y, items, onClose, dark }) { - const T = useMemo(()=>({ bg:dark?"#1c2128":"#fff", border:dark?"#30363d":"#d0d7de", text:dark?"#e6edf3":"#1f2328", hover:dark?"#262c36":"#f3f4f6", danger:dark?"#f85149":"#cf222e" }),[dark]); - useEffect(()=>{ - const h=()=>onClose(); - window.addEventListener("click",h); window.addEventListener("contextmenu",h); - return ()=>{window.removeEventListener("click",h);window.removeEventListener("contextmenu",h);}; - },[onClose]); - return ( -
e.stopPropagation()}> - {items.map((item,i)=>item.divider?( -
- ):( - - ))} +function ContextMenu({x,y,items,onClose,dark}){ + const T=useMemo(()=>({bg:dark?"#1c2128":"#fff",border:dark?"#30363d":"#d0d7de",text:dark?"#e6edf3":"#1f2328",hover:dark?"#262c36":"#f3f4f6",danger:dark?"#f85149":"#cf222e"}),[dark]); + useEffect(()=>{const h=()=>onClose();window.addEventListener("click",h);window.addEventListener("contextmenu",h);return()=>{window.removeEventListener("click",h);window.removeEventListener("contextmenu",h);};},[onClose]); + return(
e.stopPropagation()}> + {items.map((item,i)=>item.divider?
:item.submenu?:( + + ))} +
); +} + +function SubMenu({item,T,onClose}){ + const [open,setOpen]=useState(false); + return(
setOpen(true)} onMouseLeave={()=>setOpen(false)}> +
+ {item.icon&&{item.icon}}{item.label}▸
- ); + {open&&
+ {item.submenu.map((sub,j)=>)} +
} +
); } -/* ═══════ EDIT NOTEBOOK MODAL ═══════ */ -function EditNotebookModal({ notebook, onClose, onSave, dark }) { - const [name,setName]=useState(notebook.name); - const [sel,setSel]=useState(notebook.cover); +function EditNotebookModal({notebook,onClose,onSave,dark}){ + const [name,setName]=useState(notebook.name);const [sel,setSel]=useState(notebook.cover); const T=useMemo(()=>({bg:dark?"#161b22":"#fff",border:dark?"#30363d":"#d0d7de",text:dark?"#e6edf3":"#1f2328",muted:dark?"#8b949e":"#656d76",active:dark?"#388bfd":"#0969da"}),[dark]); - return ( -
-
e.stopPropagation()}> -

Edit Notebook

- setName(e.target.value)} autoFocus - 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",fontFamily:"'DM Sans',sans-serif"}} - onKeyDown={e=>{if(e.key==="Enter"&&name.trim())onSave({name:name.trim(),cover:sel});}}/> -
-
Cover style
-
- {COVERS.map((c,i)=>)} -
-
-
- - + return(
+
e.stopPropagation()}> +

Edit Notebook

+ setName(e.target.value)} autoFocus 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"}} + onKeyDown={e=>{if(e.key==="Enter"&&name.trim())onSave({name:name.trim(),cover:sel});}}/> +
+
Cover style
+
+ {COVERS.map((c,i)=>)}
+
+ + +
- ); +
); } - -/* ═══════════════════════════════════════════════════════ - UNIFIED NOTE EDITOR — single toolbar, canvas area is - always the same size so drawings never shift - ═══════════════════════════════════════════════════════ */ -function NoteEditor({ note, onUpdate, dark }) { - const [mode, setMode] = useState("text"); - const editorRef = useRef(null); - const canvasRef = useRef(null); - const ctxRef = useRef(null); - const [isDrawing, setIsDrawing] = useState(false); - - // Draw tool state (lifted out of canvas) - const [tool, setTool] = useState("pen"); - const [penColor, setPenColor] = useState(dark ? "#e6edf3" : "#1a1a2e"); - const [penSize, setPenSize] = useState(3); - const [history, setHistory] = useState([]); - const [hIdx, setHIdx] = useState(-1); - const [showColors, setShowColors] = useState(false); - const [showSizes, setShowSizes] = useState(false); - - 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", - faint: dark?"#484f58":"#afb8c1", - }), [dark]); - - // Init text editor - useEffect(() => { - if (editorRef.current && editorRef.current.innerHTML !== (note.content||"")) - editorRef.current.innerHTML = note.content || ""; - }, [note.id]); - - // Init canvas when switching to draw mode - useEffect(() => { - if (mode !== "draw") return; - const c = canvasRef.current; if (!c) return; - const p = c.parentElement; - c.width = p.clientWidth; c.height = p.clientHeight; - const ctx = c.getContext("2d"); - ctx.lineCap = "round"; ctx.lineJoin = "round"; - ctxRef.current = ctx; - if (note.drawingData) { - const img = new Image(); - img.onload = () => { ctx.drawImage(img, 0, 0); snap(); }; - img.src = note.drawingData; - } else snap(); - }, [mode]); - - // Canvas helpers - const snap = () => { - if (!canvasRef.current) return; - const d = canvasRef.current.toDataURL(); - setHistory(p => { const n = [...p.slice(0, (hIdx >= 0 ? hIdx + 1 : p.length))]; n.push(d); return n; }); - setHIdx(p => p + 1); - }; - const restoreIdx = (idx) => { - setHIdx(idx); - const img = new Image(); - img.onload = () => { ctxRef.current.clearRect(0,0,canvasRef.current.width,canvasRef.current.height); ctxRef.current.drawImage(img,0,0); }; - img.src = history[idx]; - }; - const undo = () => { if (hIdx > 0) restoreIdx(hIdx - 1); }; - const redo = () => { if (hIdx < history.length - 1) restoreIdx(hIdx + 1); }; - const clearCanvas = () => { ctxRef.current?.clearRect(0,0,canvasRef.current.width,canvasRef.current.height); snap(); onUpdate({drawingData: canvasRef.current.toDataURL()}); }; - - const getPos = (e) => { - const c = canvasRef.current, r = c.getBoundingClientRect(); - const sx = c.width/r.width, sy = c.height/r.height; - const t = e.touches ? e.touches[0] : e; - return { x: (t.clientX-r.left)*sx, y: (t.clientY-r.top)*sy }; - }; - const startDraw = (e) => { - e.preventDefault(); const p = getPos(e); setIsDrawing(true); - const ctx = ctxRef.current; ctx.beginPath(); ctx.moveTo(p.x,p.y); - if (tool==="eraser"){ctx.globalCompositeOperation="destination-out";ctx.lineWidth=penSize*5;} - else if(tool==="highlighter"){ctx.globalCompositeOperation="source-over";ctx.strokeStyle=penColor+"44";ctx.lineWidth=penSize*6;} - else{ctx.globalCompositeOperation="source-over";ctx.strokeStyle=penColor;ctx.lineWidth=penSize;} - }; - const doDraw = (e) => {if(!isDrawing)return;e.preventDefault();const p=getPos(e);ctxRef.current.lineTo(p.x,p.y);ctxRef.current.stroke();}; - const stopDraw = (e) => { - if(!isDrawing)return;e?.preventDefault();setIsDrawing(false); - ctxRef.current.closePath();ctxRef.current.globalCompositeOperation="source-over"; - snap(); onUpdate({drawingData:canvasRef.current.toDataURL()}); - }; - - const exec = (cmd,val=null)=>{document.execCommand(cmd,false,val);editorRef.current?.focus();onUpdate({content:editorRef.current.innerHTML});}; - - const tbtn = (active)=>({ - display:"flex",alignItems:"center",justifyContent:"center",width:30,height:30,borderRadius:6, - border:"none",cursor:"pointer",background:active?T.active+"22":"transparent",color:active?T.active:T.text,transition:"all .12s",flexShrink:0, - }); - - const modeBtn = (m,icon,label) => ( - - ); - - return ( -
- {/* ── SINGLE UNIFIED TOOLBAR ── */} -
- {modeBtn("text",I.text,"Write")} - {modeBtn("draw",I.draw,"Draw")} -
- - {mode==="text" ? (<> - - - -
- - - -
- - ) : (<> - - - -
- - -
- {/* Color picker */} -
- - {showColors&&
- {COLORS.map(c=>
} -
- {/* Size picker */} -
- - {showSizes&&
- {PEN_SIZES.map(s=>)} -
} -
-
- - )} -
- - {/* ── CONTENT AREA — always same size regardless of mode ── */} -
- {/* Text layer — always rendered */} -
onUpdate({content:editorRef.current.innerHTML})} - style={{ - position:"absolute",inset:0,padding:"20px 24px",outline:"none",fontSize:15,lineHeight:1.7, - color:T.text,overflowY:"auto",fontFamily:"'Newsreader','Georgia',serif", - zIndex:1, pointerEvents:mode==="text"?"auto":"none", - }} - /> - - {/* Drawing image overlay — visible in WRITE mode when drawings exist */} - {mode==="text" && note.drawingData && ( - - )} - - {/* Live canvas — visible in DRAW mode */} - {mode==="draw" && ( - - )} -
-
- ); -} - - -/* ═══════ NOTEBOOK COVER ═══════ */ -function NotebookCover({notebook,noteCount,onClick,onContextMenu,isActive,isDragOver,draggable,onDragStart,onDragEnd,onDragOver,onDrop}) { - const cover=COVERS[notebook.cover%COVERS.length]; - const [hovered,setHovered]=useState(false); - return ( - - ); -} - -/* ═══════ CREATE NOTEBOOK MODAL ═══════ */ -function CreateNotebookModal({onClose,onCreate,dark}) { +function CreateNotebookModal({onClose,onCreate,dark}){ const [name,setName]=useState("");const [sel,setSel]=useState(0); const T=useMemo(()=>({bg:dark?"#161b22":"#fff",border:dark?"#30363d":"#d0d7de",text:dark?"#e6edf3":"#1f2328",muted:dark?"#8b949e":"#656d76",active:dark?"#388bfd":"#0969da"}),[dark]); - return ( -
-
e.stopPropagation()}> -

New Notebook

- setName(e.target.value)} placeholder="Notebook name..." autoFocus - 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",fontFamily:"'DM Sans',sans-serif"}} - onKeyDown={e=>{if(e.key==="Enter"&&name.trim())onCreate({name:name.trim(),cover:sel});}}/> -
-
Choose cover style
-
- {COVERS.map((c,i)=>)} -
-
-
- - + return(
+
e.stopPropagation()}> +

New Notebook

+ setName(e.target.value)} placeholder="Notebook name..." autoFocus 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"}} + onKeyDown={e=>{if(e.key==="Enter"&&name.trim())onCreate({name:name.trim(),cover:sel});}}/> +
+
Choose cover style
+
+ {COVERS.map((c,i)=>)}
+
+ + +
- ); +
); } -/* ═══════════════════════ MAIN APP ═══════════════════════ */ -export default function NoteApp() { +/* ═══════ ACCOUNT MENU ═══════ */ +function AccountMenu({user,onClose,onLogout,onUserUpdate,dark}){ + const [tab,setTab]=useState("profile"); // profile | email | password + const [displayName,setDisplayName]=useState(user?.displayName||""); + const [newEmail,setNewEmail]=useState(user?.email||""); + const [emailPass,setEmailPass]=useState(""); + const [curPass,setCurPass]=useState("");const [newPass,setNewPass]=useState("");const [newPass2,setNewPass2]=useState(""); + const [msg,setMsg]=useState("");const [err,setErr]=useState("");const [saving,setSaving]=useState(false); + const T=useMemo(()=>({bg:dark?"#161b22":"#fff",border:dark?"#30363d":"#d0d7de",text:dark?"#e6edf3":"#1f2328",muted:dark?"#8b949e":"#656d76",accent:dark?"#388bfd":"#0969da",danger:dark?"#f85149":"#cf222e",success:dark?"#3fb950":"#1a7f37",inputBg:dark?"#0d1117":"#f6f8fa"}),[dark]); + const inp={width:"100%",padding:"10px 14px",borderRadius:10,border:`1px solid ${T.border}`,background:T.inputBg,color:T.text,fontSize:14,outline:"none",boxSizing:"border-box",marginBottom:10,fontFamily:"'DM Sans',sans-serif"}; + const tbtn=(active)=>({padding:"7px 14px",borderRadius:8,border:"none",cursor:"pointer",fontSize:12,fontWeight:600,background:active?T.accent+"22":"transparent",color:active?T.accent:T.muted}); + const clear=()=>{setMsg("");setErr("");}; + + const saveProfile=async()=>{if(!displayName.trim())return;clear();setSaving(true); + try{const u=await sync.updateProfile(displayName.trim());onUserUpdate(u);setMsg("Display name updated");}catch(e){setErr(e.message);}setSaving(false);}; + const saveEmail=async()=>{if(!newEmail||!emailPass)return;clear();setSaving(true); + try{const u=await sync.updateEmail(newEmail,emailPass);onUserUpdate(u);setEmailPass("");setMsg("Email updated");}catch(e){setErr(e.message);}setSaving(false);}; + const savePassword=async()=>{if(!curPass||!newPass)return;if(newPass!==newPass2){setErr("Passwords don't match");return;}clear();setSaving(true); + try{await sync.updatePassword(curPass,newPass);setCurPass("");setNewPass("");setNewPass2("");setMsg("Password changed");}catch(e){setErr(e.message);}setSaving(false);}; + + return(
+
e.stopPropagation()}> +
+

Account

+ +
+
+ + + +
+
+ {msg&&
{msg}
} + {err&&
{err}
} + + {tab==="profile"&&<> + + setDisplayName(e.target.value)} style={inp} onKeyDown={e=>{if(e.key==="Enter")saveProfile();}}/> + + } + + {tab==="email"&&<> + + setNewEmail(e.target.value)} style={inp}/> + + setEmailPass(e.target.value)} style={inp} onKeyDown={e=>{if(e.key==="Enter")saveEmail();}}/> + + } + + {tab==="password"&&<> + + setCurPass(e.target.value)} style={inp}/> + + setNewPass(e.target.value)} style={inp}/> + + setNewPass2(e.target.value)} style={inp} onKeyDown={e=>{if(e.key==="Enter")savePassword();}}/> + + } +
+
+ +
+
+
); +} + +/* ═══════ AUTH SCREEN ═══════ */ +function AuthScreen({onLogin,dark}){ + const [isLogin,setIsLogin]=useState(true); + const [email,setEmail]=useState("");const [pass,setPass]=useState("");const [name,setName]=useState(""); + const [err,setErr]=useState("");const [loading,setLoading]=useState(false); + const T=useMemo(()=>({bg:dark?"#0d1117":"#f6f8fa",card:dark?"#161b22":"#fff",border:dark?"#30363d":"#d0d7de",text:dark?"#e6edf3":"#1f2328",muted:dark?"#8b949e":"#656d76",accent:dark?"#388bfd":"#0969da",danger:dark?"#f85149":"#cf222e"}),[dark]); + const submit=async()=>{ + if(!email||!pass){setErr("Email and password required");return;} + setLoading(true);setErr(""); + try{ + const user=isLogin?await sync.login(email,pass):await sync.register(email,pass,name||undefined); + onLogin(user); + }catch(e){setErr(e.message==="AUTH"?"Invalid credentials":e.message);} + finally{setLoading(false);} + }; + return(
+
+
+
✏️
+
InkNote
+
{isLogin?"Sign in to sync your notes":"Create your account"}
+
+ {!isLogin&&setName(e.target.value)} placeholder="Display name" style={{width:"100%",padding:"12px 16px",borderRadius:12,border:`1px solid ${T.border}`,background:T.bg,color:T.text,fontSize:14,outline:"none",marginBottom:12,boxSizing:"border-box"}}/>} + setEmail(e.target.value)} placeholder="Email" autoFocus style={{width:"100%",padding:"12px 16px",borderRadius:12,border:`1px solid ${T.border}`,background:T.bg,color:T.text,fontSize:14,outline:"none",marginBottom:12,boxSizing:"border-box"}}/> + setPass(e.target.value)} placeholder="Password" style={{width:"100%",padding:"12px 16px",borderRadius:12,border:`1px solid ${T.border}`,background:T.bg,color:T.text,fontSize:14,outline:"none",marginBottom:16,boxSizing:"border-box"}} onKeyDown={e=>{if(e.key==="Enter")submit();}}/> + {err&&
{err}
} + +
+ {isLogin?"Don't have an account? ":"Already have an account? "} + +
+
+
); +} +/* ═══════ TASK LIST (proper React state, not contentEditable) ═══════ */ +function TaskList({tasks,onChange,dark}){ + const T=useMemo(()=>({border:dark?"#30363d":"#d0d7de",text:dark?"#e6edf3":"#1f2328",muted:dark?"#8b949e":"#656d76",active:dark?"#388bfd":"#0969da",faint:dark?"#484f58":"#afb8c1",done:dark?"#484f58":"#afb8c1"}),[dark]); + const inputRefs=useRef({}); + const toggle=(i)=>{const t=[...tasks];t[i]={...t[i],done:!t[i].done};onChange(t);}; + const setText=(i,text)=>{const t=[...tasks];t[i]={...t[i],text};onChange(t);}; + const addAfter=(i)=>{const t=[...tasks];const nid=uid();t.splice(i+1,0,{id:nid,text:"",done:false});onChange(t);setTimeout(()=>inputRefs.current[nid]?.focus(),0);}; + const remove=(i)=>{ + if(tasks.length<=1&&tasks[0].text==="")return; + const t=[...tasks];t.splice(i,1);onChange(t); + if(t.length>0){const fi=Math.max(0,i-1);setTimeout(()=>{const el=inputRefs.current[t[fi]?.id];if(el){el.focus();el.selectionStart=el.value.length;}},0);} + }; + const handleKey=(e,i)=>{ + if(e.key==="Enter"){e.preventDefault();if(tasks[i].text===""){remove(i);}else{addAfter(i);}} + else if(e.key==="Backspace"&&tasks[i].text===""&&tasks.length>1){e.preventDefault();remove(i);} + }; + if(!tasks||tasks.length===0)return null; + return(
+
Tasks ({tasks.filter(t=>t.done).length}/{tasks.length})
+ {tasks.map((task,i)=>( +
+ toggle(i)} style={{width:16,height:16,accentColor:T.active,cursor:"pointer",flexShrink:0}}/> + inputRefs.current[task.id]=el} type="text" value={task.text} onChange={e=>setText(i,e.target.value)} + onKeyDown={e=>handleKey(e,i)} placeholder="Task..." + style={{flex:1,border:"none",background:"transparent",outline:"none",fontSize:14,color:task.done?T.done:T.text,textDecoration:task.done?"line-through":"none",fontFamily:"'DM Sans',sans-serif"}}/> + +
+ ))} + +
); +} + +/* ═══════ NOTE EDITOR ═══════ */ +function NoteEditor({note,onUpdate,dark}){ + const [mode,setMode]=useState("text"); + const editorRef=useRef(null);const canvasRef=useRef(null);const ctxRef=useRef(null); + const [isDrawing,setIsDrawing]=useState(false); + const [tool,setTool]=useState("pen");const [penColor,setPenColor]=useState(dark?"#e6edf3":"#1a1a2e");const [penSize,setPenSize]=useState(3); + const [history,setHistory]=useState([]);const [hIdx,setHIdx]=useState(-1); + const [showColors,setShowColors]=useState(false);const [showSizes,setShowSizes]=useState(false); + const parsed=useMemo(()=>{try{const p=JSON.parse(note.content);return{html:p.html||"",tasks:p.tasks||[]};}catch{return{html:note.content||"",tasks:[]};}},[note.content,note.id]); + 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",faint:dark?"#484f58":"#afb8c1"}),[dark]); + useEffect(()=>{if(editorRef.current)editorRef.current.innerHTML=parsed.html;},[note.id]); + useEffect(()=>{ + if(mode!=="draw")return;const c=canvasRef.current;if(!c)return; + const p=c.parentElement;c.width=p.clientWidth;c.height=p.clientHeight; + const ctx=c.getContext("2d");ctx.lineCap="round";ctx.lineJoin="round";ctxRef.current=ctx; + if(note.drawingData){const img=new Image();img.onload=()=>{ctx.drawImage(img,0,0);snap();};img.src=note.drawingData;}else snap(); + },[mode]); + const saveContent=(html,tasks)=>{onUpdate({content:JSON.stringify({html,tasks})});}; + const onHtmlChange=()=>{if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks);}; + const onTasksChange=(tasks)=>{saveContent(parsed.html,tasks);}; + const addTaskList=()=>{if(parsed.tasks.length===0)onTasksChange([{id:uid(),text:"",done:false}]);}; + const snap=()=>{if(!canvasRef.current)return;const d=canvasRef.current.toDataURL();setHistory(p=>[...p.slice(0,(hIdx>=0?hIdx+1:p.length)),d]);setHIdx(p=>p+1);}; + const restoreIdx=(idx)=>{setHIdx(idx);const img=new Image();img.onload=()=>{ctxRef.current.clearRect(0,0,canvasRef.current.width,canvasRef.current.height);ctxRef.current.drawImage(img,0,0);};img.src=history[idx];}; + const undo=()=>{if(hIdx>0)restoreIdx(hIdx-1);};const redo=()=>{if(hIdx{ctxRef.current?.clearRect(0,0,canvasRef.current.width,canvasRef.current.height);snap();onUpdate({drawingData:canvasRef.current.toDataURL()});}; + const getPos=(e)=>{const c=canvasRef.current,r=c.getBoundingClientRect();const t=e.touches?e.touches[0]:e;return{x:(t.clientX-r.left)*(c.width/r.width),y:(t.clientY-r.top)*(c.height/r.height)};}; + const startDraw=(e)=>{e.preventDefault();const p=getPos(e);setIsDrawing(true);const ctx=ctxRef.current;ctx.beginPath();ctx.moveTo(p.x,p.y);if(tool==="eraser"){ctx.globalCompositeOperation="destination-out";ctx.lineWidth=penSize*5;}else if(tool==="highlighter"){ctx.globalCompositeOperation="source-over";ctx.strokeStyle=penColor+"44";ctx.lineWidth=penSize*6;}else{ctx.globalCompositeOperation="source-over";ctx.strokeStyle=penColor;ctx.lineWidth=penSize;}}; + const doDraw=(e)=>{if(!isDrawing)return;e.preventDefault();const p=getPos(e);ctxRef.current.lineTo(p.x,p.y);ctxRef.current.stroke();}; + const stopDraw=(e)=>{if(!isDrawing)return;e?.preventDefault();setIsDrawing(false);ctxRef.current.closePath();ctxRef.current.globalCompositeOperation="source-over";snap();onUpdate({drawingData:canvasRef.current.toDataURL()});}; + const exec=(cmd,val=null)=>{document.execCommand(cmd,false,val);editorRef.current?.focus();onHtmlChange();}; + const tbtn=(active)=>({display:"flex",alignItems:"center",justifyContent:"center",width:30,height:30,borderRadius:6,border:"none",cursor:"pointer",background:active?T.active+"22":"transparent",color:active?T.active:T.text,transition:"all .12s",flexShrink:0}); + const modeBtn=(m,icon,label)=>; + + return(
+
+ {modeBtn("text",I.text,"Write")}{modeBtn("draw",I.draw,"Draw")} +
+ {mode==="text"?(<> + + + +
+ + + +
+ + ):(<> + + + +
+ +
+
+ + {showColors&&
+ {COLORS.map(c=>
} +
+
+ + {showSizes&&
+ {PEN_SIZES.map(s=>)} +
} +
+
+ )} +
+
+
+
+ {mode==="text"&&} +
+ {mode==="text"&¬e.drawingData&&} + {mode==="draw"&&} +
+
); +} + +/* ═══════ NOTEBOOK COVER ═══════ */ +function NotebookCover({notebook,noteCount,onClick,onContextMenu,isActive,isDragOver,draggable,onDragStart,onDragEnd,onDragOver,onDrop}){ + const cover=COVERS[notebook.cover%COVERS.length];const [hovered,setHovered]=useState(false); + return(); +} + + +/* ════════════════════════════ MAIN APP (offline-first) ════════════════════════════ */ +export default function NoteApp(){ const [dark,setDark]=useState(true); - const [sidebarOpen,setSidebarOpen]=useState(true); + const [user,setUser]=useState(null);const [loading,setLoading]=useState(true); + const [sidebarOpen,setSidebarOpen]=useState(window.innerWidth>768); const [activeFolder,setActiveFolder]=useState("dashboard"); const [activeNote,setActiveNote]=useState(null); const [searchQuery,setSearchQuery]=useState(""); @@ -402,106 +410,187 @@ export default function NoteApp() { const [ctxMenu,setCtxMenu]=useState(null); const [confirmDialog,setConfirmDialog]=useState(null); const [editingNb,setEditingNb]=useState(null); - const [dragNbId,setDragNbId]=useState(null); - const [dragOverNbId,setDragOverNbId]=useState(null); - const fileRef=useRef(null); - - const [notebooks,setNotebooks]=useState([ - {id:"nb1",name:"Personal",cover:0},{id:"nb2",name:"Work Projects",cover:2}, - {id:"nb3",name:"Ideas & Sketches",cover:4},{id:"nb4",name:"Study Notes",cover:7}, - ]); - const [notes,setNotes]=useState([ - {id:"n1",title:"Welcome to InkNote",folderId:"nb1",content:"

Welcome to InkNote ✨

Each note supports both writing and drawing — switch modes with the tabs. Drawings stay visible while you write!

  • Rich text with formatting, lists, checkboxes
  • Freehand drawing layered on top
",drawingData:null,starred:true,createdAt:"2025-06-10T10:00:00Z",updatedAt:now()}, - {id:"n2",title:"Sprint Planning",folderId:"nb2",content:"

Q3 Roadmap

  1. Sync engine
  2. Mobile gestures
  3. PDF export

Review: Friday 3pm

",drawingData:null,starred:false,createdAt:"2025-06-12T14:00:00Z",updatedAt:now()}, - {id:"n3",title:"UI Wireframes",folderId:"nb3",content:"

Dashboard layout concepts...

",drawingData:null,starred:false,createdAt:"2025-06-11T08:30:00Z",updatedAt:now()}, - {id:"n4",title:"Data Structures",folderId:"nb4",content:"

Binary Trees

Each node has at most two children. Operations: insert, delete, search, traversal.

",drawingData:null,starred:true,createdAt:"2025-06-09T11:00:00Z",updatedAt:now()}, - ]); - const [pdfs,setPdfs]=useState([]); + const [showAccount,setShowAccount]=useState(false); + const [dragNbId,setDragNbId]=useState(null);const [dragOverNbId,setDragOverNbId]=useState(null); + const [notebooks,setNotebooks]=useState([]);const [notes,setNotes]=useState([]); + const [noteContents,setNoteContents]=useState({}); + const [isMobile,setIsMobile]=useState(window.innerWidth<768); + const [mobileView,setMobileView]=useState("list"); // list | editor const T=useMemo(()=>dark?{ - bg:"#0d1117",sidebar:"#010409",sidebarActive:"#161b22",card:"#161b22",toolbar:"#161b22", - border:"#30363d",borderLight:"#21262d",text:"#e6edf3",textSec:"#c9d1d9",muted:"#8b949e",faint:"#484f58", - accent:"#388bfd",accentSoft:"#388bfd22",accentText:"#58a6ff",danger:"#f85149",success:"#3fb950",warning:"#d29922",inputBg:"#0d1117",dashBg:"#0d1117", + bg:"#0d1117",sidebar:"#010409",sidebarActive:"#161b22",card:"#161b22",toolbar:"#161b22",border:"#30363d",borderLight:"#21262d",text:"#e6edf3",textSec:"#c9d1d9",muted:"#8b949e",faint:"#484f58",accent:"#388bfd",accentSoft:"#388bfd22",accentText:"#58a6ff",danger:"#f85149",success:"#3fb950",warning:"#d29922",inputBg:"#0d1117",dashBg:"#0d1117", }:{ - bg:"#f6f8fa",sidebar:"#ffffff",sidebarActive:"#eef2ff",card:"#ffffff",toolbar:"#ffffff", - border:"#d0d7de",borderLight:"#e5e7eb",text:"#1f2328",textSec:"#424a53",muted:"#656d76",faint:"#afb8c1", - accent:"#0969da",accentSoft:"#0969da18",accentText:"#0550ae",danger:"#cf222e",success:"#1a7f37",warning:"#9a6700",inputBg:"#f6f8fa",dashBg:"#f0f2f5", + bg:"#f6f8fa",sidebar:"#ffffff",sidebarActive:"#eef2ff",card:"#ffffff",toolbar:"#ffffff",border:"#d0d7de",borderLight:"#e5e7eb",text:"#1f2328",textSec:"#424a53",muted:"#656d76",faint:"#afb8c1",accent:"#0969da",accentSoft:"#0969da18",accentText:"#0550ae",danger:"#cf222e",success:"#1a7f37",warning:"#9a6700",inputBg:"#f6f8fa",dashBg:"#f0f2f5", },[dark]); + // ── Responsive ── + useEffect(()=>{const h=()=>{const m=window.innerWidth<768;setIsMobile(m);if(!m)setSidebarOpen(true);};window.addEventListener("resize",h);return()=>window.removeEventListener("resize",h);},[]); + + // ── Sync status listener ── + useEffect(()=>sync.onSyncStatus(setSyncStatus),[]); + + // ── Data change listener — reload from IndexedDB ── + useEffect(()=>sync.onDataChange(async()=>{ + setNotebooks(await sync.getNotebooks()); + setNotes(await sync.getNotes()); + }),[]); + + // ── Startup: restore session → load local data → sync ── + useEffect(()=>{(async()=>{ + try{ + const u=await sync.restoreSession(); + if(u){setUser(u);await reloadLocal(); + // Background sync if online + try{await sync.fullSync();await reloadLocal();}catch(e){if(e.message==="AUTH"){setUser(null);}} + } + }catch{} + setLoading(false); + })();},[]); + + // Reload from IndexedDB + const reloadLocal=async()=>{ + setNotebooks(await sync.getNotebooks()); + setNotes(await sync.getNotes()); + }; + + const onLogin=async(u)=>{setUser(u); + try{await sync.fullSync();}catch{} + await reloadLocal(); + }; + const onLogout=async()=>{await sync.logout();setUser(null);setNotes([]);setNotebooks([]);setNoteContents({});setActiveNote(null);setActiveFolder("dashboard");}; + + const activeNotes=useMemo(()=>notes.filter(n=>!n.trashed),[notes]); + const trashedNotes=useMemo(()=>notes.filter(n=>n.trashed),[notes]); const filteredNotes=useMemo(()=>{ - let list=notes; - if(activeFolder==="starred")list=list.filter(n=>n.starred); - else if(activeFolder==="pdfs")return pdfs; - else if(activeFolder==="trash")return[]; - else if(activeFolder!=="all"&&activeFolder!=="dashboard")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));} + let list; + if(activeFolder==="trash")list=trashedNotes; + else if(activeFolder==="starred")list=activeNotes.filter(n=>n.starred); + else if(activeFolder==="all")list=activeNotes; + else if(activeFolder!=="dashboard")list=activeNotes.filter(n=>n.folderId===activeFolder); + else list=activeNotes; + if(searchQuery){const q=searchQuery.toLowerCase();list=list.filter(n=>n.title.toLowerCase().includes(q));} return list.sort((a,b)=>new Date(b.updatedAt)-new Date(a.updatedAt)); - },[notes,pdfs,activeFolder,searchQuery]); + },[notes,activeFolder,searchQuery,activeNotes,trashedNotes]); - const currentNote=activeNote?notes.find(n=>n.id===activeNote)||pdfs.find(p=>p.id===activeNote):null; + const currentNoteMeta=activeNote?notes.find(n=>n.id===activeNote):null; + const currentNoteContent=activeNote?noteContents[activeNote]:null; + const contentLoaded=!!currentNoteContent; + const currentNote=currentNoteMeta&¤tNoteContent?{...currentNoteMeta,...currentNoteContent}:null; - const createNote=()=>{ - const fid=["all","starred","pdfs","trash","dashboard"].includes(activeFolder)?(notebooks[0]?.id||"nb1"):activeFolder; - const n={id:uid(),title:"Untitled Note",folderId:fid,content:"",drawingData:null,starred:false,createdAt:now(),updatedAt:now()}; - setNotes(p=>[n,...p]);setActiveNote(n.id);if(activeFolder==="dashboard")setActiveFolder(fid);sim(); + // Load note content on demand (from IndexedDB → instant, or server if cache miss) + useEffect(()=>{if(activeNote&&!noteContents[activeNote]){ + sync.loadNoteContent(activeNote).then(c=>setNoteContents(p=>({...p,[activeNote]:c}))); + }},[activeNote]); + + // ── CRUD — all go through sync engine ── + const createNote=async()=>{ + const fid=["all","starred","trash","dashboard"].includes(activeFolder)?(notebooks[0]?.id):activeFolder;if(!fid)return; + const note=await sync.createNote(fid); + setNotes(p=>[note,...p]);setNoteContents(p=>({...p,[note.id]:{content:"",drawingData:null}})); + setActiveNote(note.id);if(activeFolder==="dashboard")setActiveFolder(fid); + if(isMobile)setMobileView("editor"); }; - const updateNote=(id,u)=>{setNotes(p=>p.map(n=>n.id===id?{...n,...u,updatedAt:now()}:n));sim();}; - const requestDeleteNote=(id)=>{ - const note=notes.find(n=>n.id===id); - setConfirmDialog({title:"Delete Note",message:`Are you sure you want to delete "${note?.title||"this note"}"? This cannot be undone.`, - onConfirm:()=>{setNotes(p=>p.filter(n=>n.id!==id));if(activeNote===id)setActiveNote(null);sim();setConfirmDialog(null);}}); + + const updateNote=async(id,u)=>{ + if(u.title!==undefined){setNotes(p=>p.map(n=>n.id===id?{...n,title:u.title,updatedAt:now()}:n));sync.updateNoteTitle(id,u.title);} + if(u.content!==undefined){setNoteContents(p=>({...p,[id]:{...p[id],content:u.content}}));sync.updateNoteContent(id,u.content);} + if(u.drawingData!==undefined){setNoteContents(p=>({...p,[id]:{...p[id],drawingData:u.drawingData}}));sync.updateNoteDrawing(id,u.drawingData);} }; - const toggleStar=(id)=>setNotes(p=>p.map(n=>n.id===id?{...n,starred:!n.starred}:n)); - const importPDF=(e)=>{const f=e.target.files[0];if(!f)return;const p={id:uid(),type:"pdf",title:f.name,starred:false,createdAt:now(),updatedAt:now()};setPdfs(prev=>[p,...prev]);setActiveNote(p.id);setActiveFolder("pdfs");sim();}; - const createNb=({name,cover})=>{const nb={id:uid(),name,cover};setNotebooks(p=>[...p,nb]);setShowNewNb(false);setActiveFolder(nb.id);}; - const updateNb=(id,updates)=>{setNotebooks(p=>p.map(nb=>nb.id===id?{...nb,...updates}:nb));setEditingNb(null);sim();}; - const requestDeleteNb=(id)=>{ - const nb=notebooks.find(n=>n.id===id);const nc=notes.filter(n=>n.folderId===id).length; - setConfirmDialog({title:"Delete Notebook",message:`Delete "${nb?.name}"${nc>0?` and its ${nc} note${nc>1?"s":""}`:""} permanently?`, - onConfirm:()=>{setNotes(p=>p.filter(n=>n.folderId!==id));setNotebooks(p=>p.filter(n=>n.id!==id));if(activeFolder===id)setActiveFolder("dashboard");setConfirmDialog(null);sim();}}); + + const toggleStar=async(id)=>{const nv=await sync.toggleNoteStar(id);setNotes(p=>p.map(n=>n.id===id?{...n,starred:nv}:n));}; + + const trashNoteAction=(id)=>{const note=notes.find(n=>n.id===id); + setConfirmDialog({title:"Move to Trash",message:`Move "${note?.title}" to trash?`,confirmLabel:"Move to Trash", + onConfirm:async()=>{await sync.trashNote(id);setNotes(p=>p.map(n=>n.id===id?{...n,trashed:true}:n)); + if(activeNote===id){setActiveNote(null);if(isMobile)setMobileView("list");}setConfirmDialog(null);}}); }; - const sim=()=>{setSyncStatus("syncing");setTimeout(()=>setSyncStatus("synced"),1200);}; + + const restoreNote=async(id)=>{await sync.restoreNoteLocal(id);setNotes(p=>p.map(n=>n.id===id?{...n,trashed:false}:n));if(activeNote===id){setActiveNote(null);if(isMobile)setMobileView("list");}}; + + const permanentDeleteNote=(id)=>{const note=notes.find(n=>n.id===id); + setConfirmDialog({title:"Delete Permanently",message:`Permanently delete "${note?.title}"? This cannot be undone.`, + onConfirm:async()=>{await sync.permanentDeleteNoteLocal(id);setNotes(p=>p.filter(n=>n.id!==id)); + if(activeNote===id){setActiveNote(null);if(isMobile)setMobileView("list");}setConfirmDialog(null);}}); + }; + + const emptyTrash=()=>{ + setConfirmDialog({title:"Empty Trash",message:`Permanently delete all ${trashedNotes.length} trashed notes?`, + onConfirm:async()=>{await sync.emptyTrashLocal();setNotes(p=>p.filter(n=>!n.trashed));setActiveNote(null);setConfirmDialog(null);}}); + }; + + const moveNoteAction=async(id,newFolderId)=>{await sync.moveNote(id,newFolderId);setNotes(p=>p.map(n=>n.id===id?{...n,folderId:newFolderId}:n));}; + + const createNb=async({name,cover})=>{const nb=await sync.createNotebook({name,cover});setNotebooks(p=>[...p,nb]);setShowNewNb(false);setActiveFolder(nb.id);}; + const updateNb=async(id,updates)=>{await sync.updateNotebook(id,updates);setNotebooks(p=>p.map(nb=>nb.id===id?{...nb,...updates}:nb));setEditingNb(null);}; + + const requestDeleteNb=(id)=>{const nb=notebooks.find(n=>n.id===id);const nc=activeNotes.filter(n=>n.folderId===id).length; + setConfirmDialog({title:"Delete Notebook",message:`Delete "${nb?.name}"${nc>0?` and trash its ${nc} notes`:""} permanently?`, + onConfirm:async()=>{await sync.deleteNotebookLocal(id); + setNotes(p=>p.map(n=>n.folderId===id?{...n,trashed:true}:n));setNotebooks(p=>p.filter(n=>n.id!==id)); + if(activeFolder===id)setActiveFolder("dashboard");setConfirmDialog(null);}}); + }; - // Drag & Drop const handleNbDragStart=(e,id)=>{setDragNbId(id);e.dataTransfer.effectAllowed="move";}; const handleNbDragOver=(e,id)=>{e.preventDefault();if(dragNbId&&dragNbId!==id)setDragOverNbId(id);}; - const handleNbDrop=(e,targetId)=>{e.preventDefault();if(!dragNbId||dragNbId===targetId)return; - setNotebooks(prev=>{const a=[...prev];const fi=a.findIndex(n=>n.id===dragNbId);const ti=a.findIndex(n=>n.id===targetId);if(fi<0||ti<0)return prev;const[item]=a.splice(fi,1);a.splice(ti,0,item);return a;}); + const handleNbDrop=async(e,targetId)=>{e.preventDefault();if(!dragNbId||dragNbId===targetId)return; + setNotebooks(prev=>{const a=[...prev];const fi=a.findIndex(n=>n.id===dragNbId);const ti=a.findIndex(n=>n.id===targetId);if(fi<0||ti<0)return prev;const[item]=a.splice(fi,1);a.splice(ti,0,item); + const ids=a.map(n=>n.id);sync.reorderNotebooks(ids);return a.map((n,i)=>({...n,sortOrder:i}));}); setDragNbId(null);setDragOverNbId(null);}; const handleNbDragEnd=()=>{setDragNbId(null);setDragOverNbId(null);}; + const handleNbContextMenu=(e,nb)=>{e.preventDefault();e.stopPropagation(); - setCtxMenu({x:Math.min(e.clientX,window.innerWidth-200),y:Math.min(e.clientY,window.innerHeight-180), - items:[{label:"Rename",icon:I.edit,action:()=>setEditingNb(nb)},{label:"Change Cover",icon:I.palette,action:()=>setEditingNb(nb)},{divider:true},{label:"Delete Notebook",icon:I.trash,danger:true,action:()=>requestDeleteNb(nb.id)}]}); + setCtxMenu({x:e.clientX,y:e.clientY,items:[{label:"Edit",icon:I.edit,action:()=>setEditingNb(nb)},{divider:true},{label:"Delete Notebook",icon:I.trash,danger:true,action:()=>requestDeleteNb(nb.id)}]});}; + + const handleNoteContextMenu=(e,note)=>{e.preventDefault();e.stopPropagation(); + if(note.trashed){setCtxMenu({x:e.clientX,y:e.clientY,items:[{label:"Restore",icon:I.restore,action:()=>restoreNote(note.id)},{divider:true},{label:"Delete Permanently",icon:I.trash,danger:true,action:()=>permanentDeleteNote(note.id)}]});} + else{const subs=notebooks.filter(nb=>nb.id!==note.folderId).map(nb=>({label:nb.name,colorDot:COVERS[nb.cover%COVERS.length].bg,action:()=>moveNoteAction(note.id,nb.id)})); + setCtxMenu({x:e.clientX,y:e.clientY,items:[{label:note.starred?"Unstar":"Star",icon:note.starred?I.starFill:I.star,action:()=>toggleStar(note.id)},...(subs.length>0?[{label:"Move to...",icon:I.move,submenu:subs}]:[]),{divider:true},{label:"Move to Trash",icon:I.trash,danger:true,action:()=>trashNoteAction(note.id)}]});} }; useEffect(()=>{const l=document.createElement("link");l.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";l.rel="stylesheet";document.head.appendChild(l);},[]); + // ── Periodic sync (every 30s if online) ── + useEffect(()=>{const i=setInterval(()=>{if(sync.isOnline())sync.flushQueue().catch(()=>{});},30000);return()=>clearInterval(i);},[]); + + if(loading)return
Loading...
; + if(!user)return; + const isTrashView=activeFolder==="trash"; + + /* ── Back button for mobile ── */ + const backBtn=(label,onClick)=>; + + /* ── Sidebar ── */ const renderSidebar=()=>( -
+
+ {isMobile&&sidebarOpen&&
setSidebarOpen(false)} style={{position:"absolute",inset:0,background:"rgba(0,0,0,0.4)",zIndex:-1}}/>} +
✏️
InkNote
- +
+ + +
{I.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"}}/> + 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"}}/>
- +
{DEFAULT_FOLDERS.map(f=>( - ))}
@@ -511,63 +600,63 @@ export default function NoteApp() {
{notebooks.map(nb=>( -
handleNbDragStart(e,nb.id)} onDragOver={e=>handleNbDragOver(e,nb.id)} onDrop={e=>handleNbDrop(e,nb.id)} onDragEnd={handleNbDragEnd} onContextMenu={e=>handleNbContextMenu(e,nb)} +
handleNbDragStart(e,nb.id)} onDragOver={e=>handleNbDragOver(e,nb.id)} onDrop={e=>handleNbDrop(e,nb.id)} onDragEnd={handleNbDragEnd} onContextMenu={e=>handleNbContextMenu(e,nb)} style={{display:"flex",alignItems:"center",borderRadius:10,transition:"all .15s",background:dragOverNbId===nb.id?T.accentSoft:activeFolder===nb.id?T.sidebarActive:"transparent",borderTop:dragOverNbId===nb.id?`2px solid ${T.accent}`:"2px solid transparent"}}> - {I.grip} -
))}
- ● - {syncStatus==="synced"?"All changes synced":syncStatus==="syncing"?"Syncing...":"Offline"} + ● + {syncStatus==="synced"?"All synced":syncStatus==="syncing"?"Syncing...":syncStatus==="offline"?"Offline mode":syncStatus==="pending"?"Changes pending":"Sync error"} + {!sync.isOnline()&&offline}
+
); + /* ── Dashboard ── */ const renderDashboard=()=>(
-
+
-

My Notebooks

+

My Notebooks

-
-
+
{notebooks.map(nb=>( - n.folderId===nb.id).length} + n.folderId===nb.id).length} onClick={()=>{setActiveFolder(nb.id);setActiveNote(null);}} onContextMenu={e=>handleNbContextMenu(e,nb)} - isActive={activeFolder===nb.id} isDragOver={dragOverNbId===nb.id} draggable + isActive={activeFolder===nb.id} isDragOver={dragOverNbId===nb.id} draggable={!isMobile} onDragStart={e=>handleNbDragStart(e,nb.id)} onDragEnd={handleNbDragEnd} onDragOver={e=>handleNbDragOver(e,nb.id)} onDrop={e=>handleNbDrop(e,nb.id)}/> ))}
-
+

Recent Notes

-
- {notes.slice(0,6).map(note=>{const nb=notebooks.find(b=>b.id===note.folderId);return( -

{fname}

- - - + {!isTrashView&&} + {isTrashView&&trashedNotes.length>0&&}
-
+
{filteredNotes.length===0?( -
📝
No notes yet
+
+
{isTrashView?"🗑️":"📝"}
{isTrashView?"Trash is empty":"No notes yet"}
+ {!isTrashView&&} +
):filteredNotes.map(note=>( - ))}
- );}; + ); + }; + /* ── Editor ── */ const renderEditor=()=>{ - if(!currentNote)return( + // On mobile, hide editor when list is shown + if(isMobile&&mobileView!=="editor")return null; + if(!currentNote&¤tNoteMeta)return( +
+ {isMobile&&
{backBtn("Back",()=>{setActiveNote(null);setMobileView("list");})}
} +
Loading note...
+
+ ); + if(!currentNote)return isMobile?null:(
✏️
Select a note to edit
- + {!isTrashView&&} +
+ ); + if(isTrashView&¤tNoteMeta)return( +
+
+ {isMobile&&backBtn("Back",()=>{setActiveNote(null);setMobileView("list");})} + {currentNoteMeta.title} + + +
+
Trashed notes are read-only. Restore to edit.
); return(
+ {isMobile&&backBtn("Back",()=>{setActiveNote(null);setMobileView("list");})} 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"}}/> - - + style={{flex:1,border:"none",background:"transparent",outline:"none",fontSize:isMobile?15:17,fontWeight:600,color:T.text,fontFamily:"'Newsreader',Georgia,serif",minWidth:0}}/> + +
updateNote(currentNote.id,u)} dark={dark}/>
- );}; + ); + }; return(
@@ -644,15 +757,21 @@ export default function NoteApp() { *{box-sizing:border-box;margin:0;padding:0} ::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:transparent} ::-webkit-scrollbar-thumb{background:${T.borderLight};border-radius:3px} - button:hover{opacity:0.88}input::placeholder{color:${T.faint}} + button:hover{opacity:0.88} + input::placeholder{color:${T.faint}} [contenteditable]:empty:before{content:'Start writing...';color:${T.faint};pointer-events:none} [draggable]{user-select:none} + @media(max-width:767px){ + ::-webkit-scrollbar{display:none} + body{-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none} + } `} {renderSidebar()} {activeFolder==="dashboard"?renderDashboard():(<>{renderNoteList()}{renderEditor()})} {showNewNb&&setShowNewNb(false)} onCreate={createNb} dark={dark}/>} {editingNb&&setEditingNb(null)} onSave={u=>updateNb(editingNb.id,u)} dark={dark}/>} - {confirmDialog&&setConfirmDialog(null)} dark={dark}/>} + {showAccount&&setShowAccount(false)} onLogout={()=>{setShowAccount(false);onLogout();}} onUserUpdate={u=>setUser(u)} dark={dark}/>} + {confirmDialog&&setConfirmDialog(null)} dark={dark}/>} {ctxMenu&&setCtxMenu(null)} dark={dark}/>}
); diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 5f791b8..02e7fdc 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -2,8 +2,46 @@ import React from 'react' import ReactDOM from 'react-dom/client' import NoteApp from './NoteApp.jsx' +// Hide splash screen after app renders +const hideSplash = () => { + const splash = document.getElementById('splash'); + if (splash) { + splash.classList.add('hide'); + setTimeout(() => splash.remove(), 300); + } +}; + ReactDOM.createRoot(document.getElementById('root')).render( -) \ No newline at end of file +); + +// Hide splash after a short delay (let React mount) +setTimeout(hideSplash, 200); + +// ── Register Service Worker ── +if ('serviceWorker' in navigator) { + window.addEventListener('load', async () => { + try { + const reg = await navigator.serviceWorker.register('./sw.js', { scope: '/' }); + console.log('[SW] Registered:', reg.scope); + + // Check for updates periodically + setInterval(() => reg.update(), 60 * 60 * 1000); // hourly + + // Prompt refresh on new version + reg.addEventListener('updatefound', () => { + const newWorker = reg.installing; + newWorker?.addEventListener('statechange', () => { + if (newWorker.state === 'activated' && navigator.serviceWorker.controller) { + // New version available — could show a toast here + console.log('[SW] New version available'); + } + }); + }); + } catch (err) { + console.warn('[SW] Registration failed:', err); + } + }); +} \ No newline at end of file diff --git a/frontend/src/offlineStore.js b/frontend/src/offlineStore.js new file mode 100644 index 0000000..ed31022 --- /dev/null +++ b/frontend/src/offlineStore.js @@ -0,0 +1,204 @@ +/** + * InkNote Offline Store — IndexedDB local-first storage + * + * All reads/writes go here first (instant, works offline). + * Sync engine flushes changes to server when online. + */ + +const DB_NAME = 'inknote'; +const DB_VERSION = 2; + +let db = null; + +function open() { + return new Promise((resolve, reject) => { + if (db) return resolve(db); + const req = indexedDB.open(DB_NAME, DB_VERSION); + + req.onupgradeneeded = (e) => { + const d = e.target.result; + + // Notebooks store + if (!d.objectStoreNames.contains('notebooks')) { + const nb = d.createObjectStore('notebooks', { keyPath: 'id' }); + nb.createIndex('sortOrder', 'sortOrder'); + } + + // Notes metadata store + if (!d.objectStoreNames.contains('notes')) { + const n = d.createObjectStore('notes', { keyPath: 'id' }); + n.createIndex('folderId', 'folderId'); + n.createIndex('trashed', 'trashed'); + n.createIndex('updatedAt', 'updatedAt'); + } + + // Note content store (separate for lazy loading) + if (!d.objectStoreNames.contains('noteContents')) { + d.createObjectStore('noteContents', { keyPath: 'id' }); + } + + // Sync queue — pending changes to push to server + if (!d.objectStoreNames.contains('syncQueue')) { + const sq = d.createObjectStore('syncQueue', { keyPath: 'queueId', autoIncrement: true }); + sq.createIndex('entityId', 'entityId'); + sq.createIndex('timestamp', 'timestamp'); + } + + // Key-value meta store (token, user, last sync time, etc) + if (!d.objectStoreNames.contains('meta')) { + d.createObjectStore('meta', { keyPath: 'key' }); + } + }; + + req.onsuccess = (e) => { db = e.target.result; resolve(db); }; + req.onerror = (e) => reject(e.target.error); + }); +} + +// ─── Generic helpers ─── +async function tx(stores, mode, fn) { + const d = await open(); + return new Promise((resolve, reject) => { + const t = d.transaction(stores, mode); + const result = fn(t); + t.oncomplete = () => resolve(result._value); + t.onerror = (e) => reject(e.target.error); + }); +} + +function reqToPromise(req) { + return new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = (e) => rej(e.target.error); }); +} + +// ─── Meta (key-value) ─── +export async function getMeta(key) { + const d = await open(); + const r = await reqToPromise(d.transaction('meta', 'readonly').objectStore('meta').get(key)); + return r?.value ?? null; +} + +export async function setMeta(key, value) { + const d = await open(); + await reqToPromise(d.transaction('meta', 'readwrite').objectStore('meta').put({ key, value })); +} + +export async function deleteMeta(key) { + const d = await open(); + await reqToPromise(d.transaction('meta', 'readwrite').objectStore('meta').delete(key)); +} + +// ─── Notebooks ─── +export async function getAllNotebooks() { + const d = await open(); + const all = await reqToPromise(d.transaction('notebooks', 'readonly').objectStore('notebooks').getAll()); + return all.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); +} + +export async function putNotebook(nb) { + const d = await open(); + await reqToPromise(d.transaction('notebooks', 'readwrite').objectStore('notebooks').put(nb)); +} + +export async function putNotebooks(list) { + const d = await open(); + const t = d.transaction('notebooks', 'readwrite'); + const s = t.objectStore('notebooks'); + for (const nb of list) s.put(nb); + return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; }); +} + +export async function deleteNotebook(id) { + const d = await open(); + await reqToPromise(d.transaction('notebooks', 'readwrite').objectStore('notebooks').delete(id)); +} + +export async function clearNotebooks() { + const d = await open(); + await reqToPromise(d.transaction('notebooks', 'readwrite').objectStore('notebooks').clear()); +} + +// ─── Notes (metadata) ─── +export async function getAllNotes() { + const d = await open(); + return reqToPromise(d.transaction('notes', 'readonly').objectStore('notes').getAll()); +} + +export async function getNote(id) { + const d = await open(); + return reqToPromise(d.transaction('notes', 'readonly').objectStore('notes').get(id)); +} + +export async function putNote(note) { + const d = await open(); + await reqToPromise(d.transaction('notes', 'readwrite').objectStore('notes').put(note)); +} + +export async function putNotes(list) { + const d = await open(); + const t = d.transaction('notes', 'readwrite'); + const s = t.objectStore('notes'); + for (const n of list) s.put(n); + return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; }); +} + +export async function deleteNote(id) { + const d = await open(); + const t = d.transaction(['notes', 'noteContents'], 'readwrite'); + t.objectStore('notes').delete(id); + t.objectStore('noteContents').delete(id); + return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; }); +} + +export async function clearNotes() { + const d = await open(); + const t = d.transaction(['notes'], 'readwrite'); + t.objectStore('notes').clear(); + return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; }); +} + +// ─── Note content (lazy loaded) ─── +export async function getNoteContent(id) { + const d = await open(); + return reqToPromise(d.transaction('noteContents', 'readonly').objectStore('noteContents').get(id)); +} + +export async function putNoteContent(id, content, drawingData) { + const d = await open(); + await reqToPromise(d.transaction('noteContents', 'readwrite').objectStore('noteContents').put({ id, content, drawingData })); +} + +// ─── Sync Queue ─── +export async function enqueue(action) { + const d = await open(); + const entry = { ...action, timestamp: Date.now() }; + await reqToPromise(d.transaction('syncQueue', 'readwrite').objectStore('syncQueue').add(entry)); +} + +export async function peekQueue(limit = 50) { + const d = await open(); + return reqToPromise(d.transaction('syncQueue', 'readonly').objectStore('syncQueue').getAll(null, limit)); +} + +export async function dequeue(queueId) { + const d = await open(); + await reqToPromise(d.transaction('syncQueue', 'readwrite').objectStore('syncQueue').delete(queueId)); +} + +export async function clearQueue() { + const d = await open(); + await reqToPromise(d.transaction('syncQueue', 'readwrite').objectStore('syncQueue').clear()); +} + +export async function getQueueSize() { + const d = await open(); + return reqToPromise(d.transaction('syncQueue', 'readonly').objectStore('syncQueue').count()); +} + +// ─── Full wipe (for logout) ─── +export async function clearAll() { + const d = await open(); + const stores = ['notebooks', 'notes', 'noteContents', 'syncQueue', 'meta']; + const t = d.transaction(stores, 'readwrite'); + stores.forEach((s) => t.objectStore(s).clear()); + return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; }); +} \ No newline at end of file diff --git a/frontend/src/syncEngine.js b/frontend/src/syncEngine.js new file mode 100644 index 0000000..dcafc02 --- /dev/null +++ b/frontend/src/syncEngine.js @@ -0,0 +1,333 @@ +/** + * InkNote Sync Engine v2 — online-first, offline-fallback + * + * When ONLINE: Server call → cache to IndexedDB (server IDs everywhere) + * When OFFLINE: IndexedDB + queue → flush on reconnect + */ + +import * as db from './offlineStore.js'; + +const DEVICE_ID = (() => { + try { + let d = localStorage.getItem('inknote_device'); + if (!d) { d = 'dev_' + Math.random().toString(36).slice(2, 10); localStorage.setItem('inknote_device', d); } + return d; + } catch { return 'dev_' + Math.random().toString(36).slice(2, 10); } +})(); + +/* ─── API helper ─── */ +const api = { + base: '/api', + token: null, + async req(method, path, body) { + const opts = { method, headers: { 'Content-Type': 'application/json', 'X-Device-Id': DEVICE_ID } }; + if (this.token) opts.headers['Authorization'] = 'Bearer ' + this.token; + if (body) opts.body = JSON.stringify(body); + const r = await fetch(this.base + path, opts); + if (r.status === 401) { this.token = null; try { localStorage.removeItem('inknote_token'); } catch {} throw new Error('AUTH'); } + const data = await r.json(); + if (!r.ok) throw new Error(data.error || 'Request failed'); + return data; + }, + get(p) { return this.req('GET', p); }, + post(p, b) { return this.req('POST', p, b); }, + put(p, b) { return this.req('PUT', p, b); }, + del(p) { return this.req('DELETE', p); }, +}; + +/* ─── Online status ─── */ +let _online = navigator.onLine; +window.addEventListener('online', () => { _online = true; notifyStatus('syncing'); flushQueue().then(() => fullSync()).catch(() => {}); }); +window.addEventListener('offline', () => { _online = false; notifyStatus('offline'); }); +export function isOnline() { return _online; } + +/* ─── Status ─── */ +let _syncStatus = 'synced'; +let _statusCbs = []; +let _dataCbs = []; +function notifyStatus(s) { _syncStatus = s; _statusCbs.forEach(fn => fn(s)); } +export function onSyncStatus(fn) { _statusCbs.push(fn); fn(_syncStatus); return () => { _statusCbs = _statusCbs.filter(f => f !== fn); }; } +function notifyData(t, d) { _dataCbs.forEach(fn => fn(t, d)); } +export function onDataChange(fn) { _dataCbs.push(fn); return () => { _dataCbs = _dataCbs.filter(f => f !== fn); }; } + +/* ─── Auth ─── */ +export async function login(email, password) { + const data = await api.post('/auth/login', { email, password }); + api.token = data.token; + await db.setMeta('token', data.token); + await db.setMeta('user', data.user); + try { localStorage.setItem('inknote_token', data.token); } catch {} + return data.user; +} + +export async function register(email, password, displayName) { + const data = await api.post('/auth/register', { email, password, displayName }); + api.token = data.token; + await db.setMeta('token', data.token); + await db.setMeta('user', data.user); + try { localStorage.setItem('inknote_token', data.token); } catch {} + return data.user; +} + +export async function restoreSession() { + let token = await db.getMeta('token'); + if (!token) { try { token = localStorage.getItem('inknote_token'); } catch {} } + if (!token) return null; + api.token = token; + if (_online) { + try { const u = await api.get('/auth/me'); await db.setMeta('user', u); return u; } + catch (e) { if (e.message === 'AUTH') { await logout(); return null; } } + } + return await db.getMeta('user') || {}; +} + +export async function logout() { + api.token = null; + try { localStorage.removeItem('inknote_token'); } catch {} + await db.clearAll(); +} + +/* ─── Account ─── */ +export async function updateProfile(displayName) { + const u = await api.put('/auth/profile', { displayName }); + await db.setMeta('user', u); + return u; +} +export async function updateEmail(newEmail, password) { + const u = await api.put('/auth/email', { newEmail, password }); + await db.setMeta('user', u); + return u; +} +export async function updatePassword(currentPassword, newPassword) { + await api.put('/auth/password', { currentPassword, newPassword }); +} + +/* ─── Full sync ─── */ +export async function fullSync() { + if (!_online || !api.token) return; + notifyStatus('syncing'); + try { + await flushQueue(); + const serverNbs = await api.get('/notebooks'); + const nbs = serverNbs.map(nb => ({ id: nb.id, name: nb.name, cover: nb.cover_index || 0, sortOrder: nb.sort_order || 0 })); + await db.clearNotebooks(); + if (nbs.length > 0) await db.putNotebooks(nbs); + + const serverNotes = await api.get('/notes'); + const active = serverNotes.map(n => ({ id: n.id, title: n.title, folderId: n.notebook_id, starred: !!n.starred, trashed: false, createdAt: n.created_at, updatedAt: n.updated_at })); + let trashed = []; + try { + const st = await api.get('/notes/trash'); + trashed = st.map(n => ({ id: n.id, title: n.title, folderId: n.notebook_id, starred: !!n.starred, trashed: true, createdAt: n.created_at, updatedAt: n.updated_at || n.deleted_at })); + } catch {} + + await db.clearNotes(); + const all = [...active, ...trashed]; + if (all.length > 0) await db.putNotes(all); + notifyStatus('synced'); + notifyData('fullSync', null); + } catch (e) { + if (e.message === 'AUTH') throw e; + notifyStatus('error'); + } +} + +/* ─── Queue ─── */ +let _flushing = false; +async function flushQueue() { + if (_flushing || !_online || !api.token) return; + _flushing = true; + try { + const pending = await db.peekQueue(100); + for (const entry of pending) { + try { await processEntry(entry); } catch (e) { + if (e.message === 'AUTH') { _flushing = false; throw e; } + if (!_online) break; + console.warn('[sync] skip failed entry:', entry.action, e.message); + } + await db.dequeue(entry.queueId); + } + } catch (e) { if (e.message === 'AUTH') throw e; } + _flushing = false; +} + +async function processEntry(entry) { + const d = entry.data; + switch (entry.action) { + case 'createNotebook': { + const r = await api.post('/notebooks', { name: d.name, coverIndex: d.cover }); + if (r.id !== d.localId) { + await db.deleteNotebook(d.localId); + await db.putNotebook({ id: r.id, name: d.name, cover: d.cover, sortOrder: d.sortOrder || 0 }); + const notes = await db.getAllNotes(); + for (const n of notes) { if (n.folderId === d.localId) { n.folderId = r.id; await db.putNote(n); } } + } + break; + } + case 'updateNotebook': await api.put(`/notebooks/${d.id}`, { name: d.name, coverIndex: d.cover, sortOrder: d.sortOrder }); break; + case 'deleteNotebook': await api.del(`/notebooks/${d.id}`); break; + case 'createNote': { + const content = await db.getNoteContent(d.localId); + const r = await api.post('/notes', { title: d.title, notebookId: d.folderId, content: content?.content || '' }); + if (r.id !== d.localId) { + const old = await db.getNote(d.localId); + const oldC = await db.getNoteContent(d.localId); + await db.deleteNote(d.localId); + if (old) { delete old._pendingCreate; old.id = r.id; await db.putNote(old); } + if (oldC) await db.putNoteContent(r.id, oldC.content, oldC.drawingData); + } + break; + } + case 'updateNote': await api.put(`/notes/${d.id}`, d.updates); break; + case 'updateDrawing': await api.put(`/notes/${d.id}/drawing`, { drawingData: d.drawingData }); break; + case 'trashNote': await api.del(`/notes/${d.id}`); break; + case 'restoreNote': await api.put(`/notes/${d.id}/restore`, {}); break; + case 'permanentDelete': await api.del(`/notes/${d.id}/permanent`); break; + case 'emptyTrash': await api.del('/notes/trash/empty'); break; + } +} + +/* ─── Debounce ─── */ +const _timers = {}; +function debounce(key, fn, ms = 800) { if (_timers[key]) clearTimeout(_timers[key]); _timers[key] = setTimeout(fn, ms); } + +/* ════════ PUBLIC DATA API ════════ */ + +export async function getNotebooks() { return db.getAllNotebooks(); } + +export async function createNotebook({ name, cover }) { + if (_online && api.token) { + try { + const r = await api.post('/notebooks', { name, coverIndex: cover }); + const nb = { id: r.id, name, cover, sortOrder: (await db.getAllNotebooks()).length }; + await db.putNotebook(nb); notifyData('notebook', nb); return nb; + } catch (e) { if (e.message === 'AUTH') throw e; } + } + const lid = 'nb_' + Math.random().toString(36).slice(2, 10); + const nb = { id: lid, name, cover, sortOrder: (await db.getAllNotebooks()).length }; + await db.putNotebook(nb); + await db.enqueue({ action: 'createNotebook', data: { localId: lid, name, cover, sortOrder: nb.sortOrder } }); + notifyData('notebook', nb); notifyStatus('pending'); return nb; +} + +export async function updateNotebook(id, updates) { + const nbs = await db.getAllNotebooks(); const nb = nbs.find(n => n.id === id); if (!nb) return; + const upd = { ...nb, ...updates }; await db.putNotebook(upd); notifyData('notebook', upd); + if (_online && api.token) { try { await api.put(`/notebooks/${id}`, { name: updates.name, coverIndex: updates.cover, sortOrder: updates.sortOrder }); return; } catch (e) { if (e.message === 'AUTH') throw e; } } + await db.enqueue({ action: 'updateNotebook', data: { id, name: updates.name, cover: updates.cover, sortOrder: updates.sortOrder } }); notifyStatus('pending'); +} + +export async function deleteNotebookLocal(id) { + const notes = await db.getAllNotes(); + for (const n of notes) { if (n.folderId === id && !n.trashed) { n.trashed = true; await db.putNote(n); } } + await db.deleteNotebook(id); notifyData('notebook', { id, deleted: true }); + if (_online && api.token) { try { await api.del(`/notebooks/${id}`); return; } catch (e) { if (e.message === 'AUTH') throw e; } } + await db.enqueue({ action: 'deleteNotebook', data: { id } }); notifyStatus('pending'); +} + +export async function reorderNotebooks(orderedIds) { + const nbs = await db.getAllNotebooks(); + for (let i = 0; i < orderedIds.length; i++) { const nb = nbs.find(n => n.id === orderedIds[i]); if (nb) { nb.sortOrder = i; await db.putNotebook(nb); } } + notifyData('notebooks', null); + if (_online && api.token) { for (let i = 0; i < orderedIds.length; i++) { api.put(`/notebooks/${orderedIds[i]}`, { sortOrder: i }).catch(() => {}); } } +} + +export async function getNotes() { return db.getAllNotes(); } + +export async function loadNoteContent(id) { + const local = await db.getNoteContent(id); + if (local) return { content: local.content || '', drawingData: local.drawingData || null }; + if (_online && api.token) { + try { const d = await api.get(`/notes/${id}`); const c = { content: d.content || '', drawingData: null }; await db.putNoteContent(id, c.content, c.drawingData); return c; } catch {} + } + return { content: '', drawingData: null }; +} + +export async function createNote(folderId) { + const ts = new Date().toISOString(); + if (_online && api.token) { + try { + const r = await api.post('/notes', { title: 'Untitled Note', notebookId: folderId, content: '' }); + const note = { id: r.id, title: 'Untitled Note', folderId, starred: false, trashed: false, createdAt: ts, updatedAt: ts }; + await db.putNote(note); await db.putNoteContent(r.id, '', null); notifyData('note', note); return note; + } catch (e) { if (e.message === 'AUTH') throw e; } + } + const lid = 'local_' + Math.random().toString(36).slice(2, 10); + const note = { id: lid, title: 'Untitled Note', folderId, starred: false, trashed: false, createdAt: ts, updatedAt: ts, _pendingCreate: true }; + await db.putNote(note); await db.putNoteContent(lid, '', null); + await db.enqueue({ action: 'createNote', data: { localId: lid, title: 'Untitled Note', folderId } }); + notifyData('note', note); notifyStatus('pending'); return note; +} + +export async function updateNoteTitle(id, title) { + const note = await db.getNote(id); if (!note) return; + note.title = title; note.updatedAt = new Date().toISOString(); await db.putNote(note); notifyData('note', note); + debounce(`title_${id}`, async () => { + if (_online && api.token && !note._pendingCreate) { try { await api.put(`/notes/${id}`, { title }); notifyStatus('synced'); return; } catch {} } + await db.enqueue({ action: 'updateNote', data: { id, updates: { title } } }); notifyStatus('pending'); + }); +} + +export async function updateNoteContent(id, content) { + const ex = await db.getNoteContent(id); await db.putNoteContent(id, content, ex?.drawingData || null); + const note = await db.getNote(id); if (note) { note.updatedAt = new Date().toISOString(); await db.putNote(note); } + notifyData('noteContent', { id }); + debounce(`content_${id}`, async () => { + if (_online && api.token && !note?._pendingCreate) { try { await api.put(`/notes/${id}`, { content }); notifyStatus('synced'); return; } catch {} } + await db.enqueue({ action: 'updateNote', data: { id, updates: { content } } }); notifyStatus('pending'); + }); +} + +export async function updateNoteDrawing(id, drawingData) { + const ex = await db.getNoteContent(id); await db.putNoteContent(id, ex?.content || '', drawingData); + notifyData('noteContent', { id }); + debounce(`drawing_${id}`, async () => { + const note = await db.getNote(id); + if (_online && api.token && !note?._pendingCreate) { try { await api.put(`/notes/${id}/drawing`, { drawingData }); notifyStatus('synced'); return; } catch {} } + await db.enqueue({ action: 'updateDrawing', data: { id, drawingData } }); notifyStatus('pending'); + }); +} + +export async function toggleNoteStar(id) { + const note = await db.getNote(id); if (!note) return false; + note.starred = !note.starred; await db.putNote(note); notifyData('note', note); + if (_online && api.token && !note._pendingCreate) { api.put(`/notes/${id}`, { starred: note.starred }).catch(() => {}); } + else { await db.enqueue({ action: 'updateNote', data: { id, updates: { starred: note.starred } } }); notifyStatus('pending'); } + return note.starred; +} + +export async function moveNote(id, fid) { + const note = await db.getNote(id); if (!note) return; + note.folderId = fid; note.updatedAt = new Date().toISOString(); await db.putNote(note); notifyData('note', note); + if (_online && api.token && !note._pendingCreate) { api.put(`/notes/${id}`, { notebookId: fid }).catch(() => {}); } + else { await db.enqueue({ action: 'updateNote', data: { id, updates: { notebookId: fid } } }); notifyStatus('pending'); } +} + +export async function trashNote(id) { + const note = await db.getNote(id); if (!note) return; + note.trashed = true; note.updatedAt = new Date().toISOString(); await db.putNote(note); notifyData('note', note); + if (_online && api.token && !note._pendingCreate) { try { await api.del(`/notes/${id}`); } catch {} } + else { await db.enqueue({ action: 'trashNote', data: { id } }); notifyStatus('pending'); } +} + +export async function restoreNoteLocal(id) { + const note = await db.getNote(id); if (!note) return; + note.trashed = false; note.updatedAt = new Date().toISOString(); await db.putNote(note); notifyData('note', note); + if (_online && api.token) { try { await api.put(`/notes/${id}/restore`, {}); } catch {} } + else { await db.enqueue({ action: 'restoreNote', data: { id } }); notifyStatus('pending'); } +} + +export async function permanentDeleteNoteLocal(id) { + await db.deleteNote(id); notifyData('note', { id, deleted: true }); + if (_online && api.token) { try { await api.del(`/notes/${id}/permanent`); } catch {} } + else { await db.enqueue({ action: 'permanentDelete', data: { id } }); notifyStatus('pending'); } +} + +export async function emptyTrashLocal() { + const all = await db.getAllNotes(); for (const n of all) { if (n.trashed) await db.deleteNote(n.id); } + notifyData('notes', null); + if (_online && api.token) { try { await api.del('/notes/trash/empty'); } catch {} } + else { await db.enqueue({ action: 'emptyTrash', data: {} }); notifyStatus('pending'); } +} + +export { DEVICE_ID }; \ No newline at end of file