diff --git a/backend/server.js b/backend/server.js index 960998f..8b8fce7 100644 --- a/backend/server.js +++ b/backend/server.js @@ -136,6 +136,18 @@ function decrypt(data) { return decrypted; } +function detectImageMime(buffer, filePath = "") { + if (buffer && buffer.length >= 12) { + if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return "image/png"; + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg"; + if (buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") return "image/webp"; + } + const ext = path.extname(filePath).toLowerCase(); + if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg"; + if (ext === ".webp") return "image/webp"; + return "image/png"; +} + /* ─────────── Vector clock conflict resolution ─────────── */ function mergeVectorClocks(clockA, clockB) { const merged = { ...clockA }; @@ -313,6 +325,20 @@ app.get("/api/notes/:id", authMiddleware, (req, res) => { } } delete note.content_encrypted; + + // Read drawing data from file if exists + if (note.drawing_data_path && fs.existsSync(note.drawing_data_path)) { + try { + const raw = fs.readFileSync(note.drawing_data_path); + const mime = detectImageMime(raw, note.drawing_data_path); + note.drawingData = `data:${mime};base64,` + raw.toString("base64"); + } catch { + note.drawingData = null; + } + } else { + note.drawingData = null; + } + res.json(note); }); @@ -404,12 +430,28 @@ app.put("/api/notes/:id/drawing", 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" }); - const filePath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.png`); - const base64Data = drawingData.replace(/^data:image\/\w+;base64,/, ""); + if (!drawingData || typeof drawingData !== "string") return res.status(400).json({ error: "drawingData required" }); + const m = drawingData.match(/^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/); + if (!m) return res.status(400).json({ error: "Invalid drawing format" }); + const mime = m[1]; + const ext = mime === "image/png" ? "png" : mime === "image/webp" ? "webp" : "jpg"; + const base64Data = m[2]; + const filePath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.${ext}`); + + // Remove older file variants from previous format writes. + for (const oldExt of ["png", "jpg", "jpeg", "webp"]) { + const oldPath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.${oldExt}`); + if (oldPath !== filePath && fs.existsSync(oldPath)) { + try { fs.unlinkSync(oldPath); } catch {} + } + } 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); + db.prepare("UPDATE notes SET drawing_data_path = ?, updated_at = datetime('now'), version = version + 1, device_id = ? WHERE id = ?") + .run(filePath, req.deviceId, req.params.id); + db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, payload, device_id) VALUES (?, 'note', ?, 'update', ?, ?)") + .run(req.userId, req.params.id, JSON.stringify({ drawingUpdated: true }), req.deviceId); + broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, drawingUpdated: true } }, req.deviceId); res.json({ success: true }); }); @@ -537,4 +579,4 @@ server.listen(PORT, () => { console.log(` 📡 WebSocket available at ws://localhost:${PORT}/ws`); console.log(` 🔒 JWT Secret: ${JWT_SECRET.slice(0, 12)}...`); console.log(` 💾 Database: ${path.join(DATA_DIR, "inknote.db")}\n`); -}); \ No newline at end of file +}); diff --git a/frontend/nginx.conf b/frontend/nginx.conf index d9410ed..da34936 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,26 +4,26 @@ server { 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 + # PWA service worker — never cache location = /sw.js { add_header Cache-Control "no-cache, no-store, must-revalidate"; add_header Pragma "no-cache"; + add_header Expires "0"; add_header Service-Worker-Allowed "/"; - add_header Content-Type "application/javascript"; + types { application/javascript js; } try_files $uri =404; } # PWA manifest location = /manifest.json { - add_header Cache-Control "public, max-age=3600"; - add_header Content-Type "application/manifest+json"; + add_header Cache-Control "no-cache"; + types { application/manifest+json json; } try_files $uri =404; } - # Static assets — long cache with content hash from Vite + # Vite hashed assets — immutable cache location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; @@ -35,8 +35,9 @@ server { try_files $uri =404; } - # SPA fallback — all other routes serve index.html + # SPA fallback location / { + add_header Cache-Control "no-cache"; try_files $uri $uri/ /index.html; } diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index 174da8a..aca96dd 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -2,16 +2,39 @@ "name": "InkNote", "short_name": "InkNote", "description": "Write & draw — notes that sync everywhere", - "start_url": ".", - "scope": ".", + "start_url": "./", + "scope": "./", + "id": "/", "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" } + { + "src": "./icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "./icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "./icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "./icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } ], "prefer_related_applications": false } \ No newline at end of file diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 2d861b3..efeb1bc 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,66 +1,62 @@ -const CACHE_NAME = 'inknote-v2'; +const CACHE_NAME = 'inknote-v6'; -// 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); } - } - }) - ); +// Install — don't precache, just activate immediately +self.addEventListener('install', () => { self.skipWaiting(); }); -// Activate — clean old caches +// Activate — clean old caches, take control self.addEventListener('activate', (e) => { e.waitUntil( - caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))) + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) + ).then(() => self.clients.claim()) ); - self.clients.claim(); }); -// Fetch handler +// Fetch — runtime caching strategy self.addEventListener('fetch', (e) => { const url = new URL(e.request.url); - // Skip API/WebSocket requests entirely — handled by app + IndexedDB + // Never intercept API or WebSocket if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) return; - // Skip non-GET requests + // Only handle GET requests if (e.request.method !== 'GET') return; - // Navigation (HTML pages) — network-first, fallback to cache + // Navigation (HTML) — network-first with cache fallback 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('/')) + fetch(e.request).then(r => { + const clone = r.clone(); + caches.open(CACHE_NAME).then(c => c.put(e.request, clone)); + return r; + }).catch(() => caches.match(e.request).then(r => r || caches.match('/'))) ); return; } - // JS/CSS/images — stale-while-revalidate + // Static assets — 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; - }) + caches.open(CACHE_NAME).then(cache => + cache.match(e.request).then(cached => { + const fetchP = fetch(e.request).then(r => { + if (r.ok) cache.put(e.request, r.clone()); + return r; + }).catch(() => cached); + return cached || fetchP; + }) + ) ); }); -// Background sync +// Background sync trigger self.addEventListener('sync', (e) => { if (e.tag === 'sync-changes') { - e.waitUntil(self.clients.matchAll().then(clients => { - clients.forEach(c => c.postMessage({ type: 'FLUSH_QUEUE' })); - })); + 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 4767f78..afaceb9 100644 --- a/frontend/src/NoteApp.jsx +++ b/frontend/src/NoteApp.jsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { useState, useRef, useEffect, useMemo } from "react"; import * as sync from "./syncEngine.js"; const uid = () => Math.random().toString(36).slice(2, 10); @@ -286,84 +286,339 @@ function NoteEditor({note,onUpdate,dark}){ 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 [undoStack,setUndoStack]=useState([]);const [redoStack,setRedoStack]=useState([]); const [showColors,setShowColors]=useState(false);const [showSizes,setShowSizes]=useState(false); + const [selectedImg,setSelectedImg]=useState(null); + const [hideDrawing,setHideDrawing]=useState(false); + const [drawingBlend,setDrawingBlend]=useState({mode:"normal",opacity:1}); + const [isMobileViewport,setIsMobileViewport]=useState(window.innerWidth<768); + const stageRef=useRef(null); + const saveTimerRef=useRef(null); + const dirtyRef=useRef(false); + const CW=1200,CH=1600; // Canonical drawing resolution shared by all devices. + const SAVE_DEBOUNCE_MS=600; + const [stageSize,setStageSize]=useState({w:CW,h:CH}); + 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 el=editorRef.current; + if(!el)return; + const prevNoteId=el.dataset.noteId; + const isNewNote=prevNoteId!==note.id; + if(isNewNote){ + el.innerHTML=parsed.html; + el.dataset.noteId=note.id; + return; + } + // Late content arrival for the same note (mobile/network): fill only if editor is still empty. + const hasLocalText=(el.textContent||"").trim().length>0; + if(!hasLocalText && parsed.html){ + el.innerHTML=parsed.html; + } + },[note.id,parsed.html]); + + useEffect(()=>{ + setHideDrawing(false); + const isLegacyRaster=typeof note.drawingData==="string"&&/^data:image\/(jpeg|jpg|webp)/i.test(note.drawingData); + if(!note.drawingData){setDrawingBlend({mode:"normal",opacity:1});return;} + let cancelled=false; + const img=new Image(); + img.onload=()=>{ + if(cancelled)return; + try{ + const c=document.createElement("canvas");c.width=32;c.height=32; + const cx=c.getContext("2d",{willReadFrequently:true});if(!cx)throw new Error("ctx"); + cx.drawImage(img,0,0,32,32); + const data=cx.getImageData(0,0,32,32).data; + let solid=0,black=0; + for(let i=0;i0.9)setDrawingBlend({mode:"screen",opacity:0.62}); + else if(isLegacyRaster)setDrawingBlend({mode:"screen",opacity:0.42}); + else setDrawingBlend({mode:"normal",opacity:1}); + }catch{ + setDrawingBlend(isLegacyRaster?{mode:"screen",opacity:0.42}:{mode:"normal",opacity:1}); + } + }; + img.onerror=()=>{if(!cancelled)setHideDrawing(true);}; + img.src=note.drawingData; + return()=>{cancelled=true;}; + },[note.id,note.drawingData]); + + // Keep one fixed-aspect drawing stage regardless of device dimensions. + useEffect(()=>{ + const fitStage=()=>{ + const host=stageRef.current; + if(!host)return; + const W=Math.max(1,host.clientWidth); + const mobile=window.innerWidth<768; + setIsMobileViewport(mobile); + const sidePad=mobile?10:28; + const desktopMax=760; + const minW=mobile?220:320; + const maxAllowed=Math.max(220,W-sidePad*2); + const targetW=mobile?maxAllowed:Math.min(desktopMax,maxAllowed); + const w=Math.floor(Math.max(minW,targetW)); + const h=Math.floor((w*CH)/CW); + setStageSize({w,h}); + }; + fitStage(); + if(typeof ResizeObserver!=="undefined"){ + const ro=new ResizeObserver(fitStage); + if(stageRef.current)ro.observe(stageRef.current); + window.addEventListener("resize",fitStage); + return()=>{ro.disconnect();window.removeEventListener("resize",fitStage);}; + } + window.addEventListener("resize",fitStage); + return()=>window.removeEventListener("resize",fitStage); + },[]); + + const saveDrawingNow=()=>{ + if(!canvasRef.current||!dirtyRef.current)return; + onUpdate({drawingData:canvasRef.current.toDataURL("image/png")}); + dirtyRef.current=false; + }; + const scheduleDrawingSave=()=>{ + if(saveTimerRef.current)clearTimeout(saveTimerRef.current); + saveTimerRef.current=setTimeout(()=>{saveTimerRef.current=null;saveDrawingNow();},SAVE_DEBOUNCE_MS); + }; + + /* ── DRAWING: init canvas once on mode switch ── */ + useEffect(()=>{ + if(mode!=="draw")return; + const c=canvasRef.current;if(!c)return; + c.width=CW;c.height=CH; 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(); + ctx.clearRect(0,0,CW,CH); + dirtyRef.current=false; + if(note.drawingData){ + const img=new Image();img.onload=()=>{ + const t=document.createElement("canvas");t.width=CW;t.height=CH; + const tctx=t.getContext("2d",{willReadFrequently:true});if(!tctx){ctx.drawImage(img,0,0,CW,CH);return;} + tctx.drawImage(img,0,0,CW,CH); + try{ + const sc=document.createElement("canvas");sc.width=40;sc.height=40; + const sctx=sc.getContext("2d",{willReadFrequently:true});if(!sctx)throw new Error("ctx"); + sctx.drawImage(t,0,0,40,40); + const sample=sctx.getImageData(0,0,40,40).data; + let solid=0,black=0; + for(let i=0;i0.9){ + const im=tctx.getImageData(0,0,CW,CH);const d=im.data; + for(let i=0;i245)d[i+3]=0; + } + tctx.putImageData(im,0,0); + } + }catch{} + ctx.drawImage(t,0,0,CW,CH); + };img.src=note.drawingData; + } },[mode]); + + /* Save drawing on mode switch and debounced stroke-end updates. */ + const switchMode=(m)=>{ + if(mode==="draw"&&m==="text"&&dirtyRef.current&&canvasRef.current){ + if(saveTimerRef.current){clearTimeout(saveTimerRef.current);saveTimerRef.current=null;} + saveDrawingNow(); + } + setMode(m);setShowColors(false);setShowSizes(false);setSelectedImg(null); + }; + + const pushUndo=()=>{if(!canvasRef.current)return;const d=canvasRef.current.toDataURL("image/png");setUndoStack(p=>[...p.slice(-15),d]);setRedoStack([]);}; + const undo=()=>{if(!undoStack.length)return;const s=undoStack[undoStack.length-1];setUndoStack(p=>p.slice(0,-1)); + setRedoStack(p=>[...p,canvasRef.current.toDataURL("image/png")]); + const i=new Image();i.onload=()=>{ctxRef.current.clearRect(0,0,CW,CH);ctxRef.current.drawImage(i,0,0,CW,CH);dirtyRef.current=true;scheduleDrawingSave();};i.src=s;}; + const redo=()=>{if(!redoStack.length)return;const s=redoStack[redoStack.length-1];setRedoStack(p=>p.slice(0,-1)); + setUndoStack(p=>[...p,canvasRef.current.toDataURL("image/png")]); + const i=new Image();i.onload=()=>{ctxRef.current.clearRect(0,0,CW,CH);ctxRef.current.drawImage(i,0,0,CW,CH);dirtyRef.current=true;scheduleDrawingSave();};i.src=s;}; + const clearCanvas=()=>{pushUndo();ctxRef.current?.clearRect(0,0,CW,CH);dirtyRef.current=true;scheduleDrawingSave();}; + + const getPos=(e)=>{const c=canvasRef.current,r=c.getBoundingClientRect();const t=e.touches?e.touches[0]:e;return{x:(t.clientX-r.left)*(CW/r.width),y:(t.clientY-r.top)*(CH/r.height)};}; + const startDraw=(e)=>{e.preventDefault();pushUndo();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";dirtyRef.current=true;scheduleDrawingSave();}; + + useEffect(()=>()=>{if(saveTimerRef.current)clearTimeout(saveTimerRef.current);saveDrawingNow();},[]); + + /* ── TEXT ── */ 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(
+ /* ── IMAGE PASTE ── */ + useEffect(()=>{ + const el=editorRef.current;if(!el||mode!=="text")return; + const h=(e)=>{ + const items=e.clipboardData?.items;if(!items)return; + for(const item of items){ + if(item.type.startsWith("image/")){ + e.preventDefault(); + const blob=item.getAsFile();if(!blob)return; + const reader=new FileReader(); + reader.onload=(ev)=>{ + document.execCommand("insertHTML",false, + ``); + if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks); + }; + reader.readAsDataURL(blob);return; + } + } + }; + el.addEventListener("paste",h);return()=>el.removeEventListener("paste",h); + },[mode,parsed.tasks]); + + /* ── IMAGE SELECT + DRAG RESIZE ── */ + useEffect(()=>{ + const el=editorRef.current;if(!el||mode!=="text")return; + + const onClick=(e)=>{ + const img=e.target.closest("img[data-rid]"); + setSelectedImg(img||null); + }; + + const onPointerDown=(e)=>{ + const img=e.target.closest("img[data-rid]");if(!img)return; + const rect=img.getBoundingClientRect(); + const px=e.clientX||(e.touches?.[0]?.clientX); + // Only start resize if clicking within 20px of right edge + if(px{ + ev.preventDefault(); + const mx=ev.clientX||(ev.touches?.[0]?.clientX); + const nw=Math.max(40,Math.min(cW,startW+(mx-startX))); + const pct=Math.round(nw/cW*100); + img.style.width=pct+"%";img.style.maxWidth=pct+"%"; + }; + const up=()=>{ + document.removeEventListener("mousemove",move);document.removeEventListener("mouseup",up); + document.removeEventListener("touchmove",move);document.removeEventListener("touchend",up); + if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks); + }; + document.addEventListener("mousemove",move);document.addEventListener("mouseup",up); + document.addEventListener("touchmove",move,{passive:false});document.addEventListener("touchend",up); + }; + + // Cursor hint near right edge + const onMove=(e)=>{ + const img=e.target.closest("img[data-rid]");if(!img){return;} + const r=img.getBoundingClientRect(); + img.style.cursor=e.clientX>r.right-20?"ew-resize":"pointer"; + }; + + el.addEventListener("click",onClick); + el.addEventListener("mousedown",onPointerDown); + el.addEventListener("touchstart",onPointerDown,{passive:false}); + el.addEventListener("mousemove",onMove); + return()=>{el.removeEventListener("click",onClick);el.removeEventListener("mousedown",onPointerDown); + el.removeEventListener("touchstart",onPointerDown);el.removeEventListener("mousemove",onMove);}; + },[mode,parsed.tasks]); + + // Blue outline on selected image + useEffect(()=>{ + const el=editorRef.current;if(!el)return; + el.querySelectorAll("img[data-rid]").forEach(img=>{ + if(img===selectedImg){img.style.outline=`3px solid ${T.active}`;img.style.outlineOffset="2px";} + else{img.style.outline="none";img.style.outlineOffset="0";} + }); + },[selectedImg,T.active]); + + // Deselect on outside click + useEffect(()=>{if(!selectedImg)return; + const h=(e)=>{if(!editorRef.current?.contains(e.target)&&!e.target.closest("[data-imgtb]"))setSelectedImg(null);}; + document.addEventListener("pointerdown",h);return()=>document.removeEventListener("pointerdown",h); + },[selectedImg]); + + const setImgSize=(p)=>{if(!selectedImg)return;selectedImg.style.width=p+"%";selectedImg.style.maxWidth=p+"%";if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks);}; + const deleteImg=()=>{if(!selectedImg)return;selectedImg.remove();setSelectedImg(null);if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks);}; + + /* ── RENDER ── */ + const tb=(a)=>({display:"flex",alignItems:"center",justifyContent:"center",width:30,height:30,borderRadius:6,border:"none",cursor:"pointer",background:a?T.active+"22":"transparent",color:a?T.active:T.text,transition:"all .12s",flexShrink:0}); + const mb=(m,ic,lb)=>; + + return(
+ {/* Main toolbar */}
- {modeBtn("text",I.text,"Write")}{modeBtn("draw",I.draw,"Draw")} + {mb("text",I.text,"Write")}{mb("draw",I.draw,"Draw")}
{mode==="text"?(<> - - - + + +
- - - + + +
):(<> - - - + + +
- +
- + {showColors&&
{COLORS.map(c=>
}
- + {showSizes&&
{PEN_SIZES.map(s=>)}
}
-
+
)}
-
-
-
- {mode==="text"&&} + + {/* Image toolbar — appears when an image is selected */} + {selectedImg&&mode==="text"&&
+ Size: + {[25,33,50,75,100].map(p=>)} +
+ + · drag right edge to resize +
} + + {/* Content area */} +
+
+
+
+ {note.drawingData&&!hideDrawing&&setHideDrawing(true)} style={{position:"absolute",left:0,top:0,width:"100%",height:stageSize.h,pointerEvents:"none",objectFit:"fill",zIndex:2,opacity:drawingBlend.opacity,mixBlendMode:drawingBlend.mode}}/>} + {mode==="draw"&&} + {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); @@ -416,6 +671,7 @@ export default function NoteApp(){ const [noteContents,setNoteContents]=useState({}); const [isMobile,setIsMobile]=useState(window.innerWidth<768); const [mobileView,setMobileView]=useState("list"); // list | editor + const [installPrompt,setInstallPrompt]=useState(null); 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", @@ -426,6 +682,14 @@ export default function NoteApp(){ // ── Responsive ── useEffect(()=>{const h=()=>{const m=window.innerWidth<768;setIsMobile(m);if(!m)setSidebarOpen(true);};window.addEventListener("resize",h);return()=>window.removeEventListener("resize",h);},[]); + // ── PWA install prompt ── + useEffect(()=>{ + const h=(e)=>{e.preventDefault();setInstallPrompt(e);}; + window.addEventListener("beforeinstallprompt",h); + return()=>window.removeEventListener("beforeinstallprompt",h); + },[]); + const handleInstall=async()=>{if(!installPrompt)return;installPrompt.prompt();const r=await installPrompt.userChoice;if(r.outcome==="accepted")setInstallPrompt(null);}; + // ── Sync status listener ── useEffect(()=>sync.onSyncStatus(setSyncStatus),[]); @@ -433,6 +697,7 @@ export default function NoteApp(){ useEffect(()=>sync.onDataChange(async()=>{ setNotebooks(await sync.getNotebooks()); setNotes(await sync.getNotes()); + // Keep loaded note content cached to avoid editor flicker/stalls on periodic sync }),[]); // ── Startup: restore session → load local data → sync ── @@ -474,13 +739,48 @@ export default function NoteApp(){ const currentNoteMeta=activeNote?notes.find(n=>n.id===activeNote):null; const currentNoteContent=activeNote?noteContents[activeNote]:null; - const contentLoaded=!!currentNoteContent; + const activeNoteHasContent=!!currentNoteContent; const currentNote=currentNoteMeta&¤tNoteContent?{...currentNoteMeta,...currentNoteContent}:null; + const currentNoteSafe=currentNote||( + currentNoteMeta + ? {...currentNoteMeta,content:currentNoteContent?.content||"",drawingData:currentNoteContent?.drawingData||null} + : null + ); - // 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]); + // Load note content on demand — fetches server-first on cache miss (page load = all cache empty) + useEffect(()=>{ + if(!activeNote || activeNoteHasContent) return; + let cancelled=false; + sync.loadNoteContent(activeNote) + .then(c=>{ if(!cancelled) setNoteContents(p=>({...p,[activeNote]:c})); }) + .catch(()=>{ if(!cancelled) setNoteContents(p=>({...p,[activeNote]:{content:"",drawingData:null}})); }); + return ()=>{cancelled=true;}; + },[activeNote,activeNoteHasContent]); + + // Hard fallback: never stay in loading state forever. + useEffect(()=>{ + if(!activeNote || activeNoteHasContent) return; + const t=setTimeout(()=>{ + setNoteContents(p=>p[activeNote]?p:{...p,[activeNote]:{content:"",drawingData:null}}); + },5000); + return ()=>clearTimeout(t); + },[activeNote,activeNoteHasContent]); + + // Keep currently open note content fresh across devices. + useEffect(()=>{ + if(!user||!activeNote)return; + const i=setInterval(()=>{ + if(!sync.isOnline())return; + sync.loadNoteContent(activeNote,{forceRemote:true}).then(c=>{ + setNoteContents(p=>{ + const prev=p[activeNote]; + if(prev&&prev.content===c.content&&prev.drawingData===c.drawingData)return p; + return {...p,[activeNote]:c}; + }); + }).catch(()=>{}); + },6000); + return()=>clearInterval(i); + },[user,activeNote]); // ── CRUD — all go through sync engine ── const createNote=async()=>{ @@ -550,7 +850,27 @@ export default function NoteApp(){ 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);},[]); + useEffect(()=>{const i=setInterval(()=>{if(sync.isOnline())sync.fullSync().then(()=>reloadLocal()).catch(()=>{});},30000);return()=>clearInterval(i);},[]); + + // ── History API — browser/phone back button ── + const navStateRef=useRef({folder:"dashboard",note:null,view:"list"}); + useEffect(()=>{ + const cur=navStateRef.current; + if(cur.folder!==activeFolder||cur.note!==activeNote||cur.view!==mobileView){ + const state={folder:activeFolder,note:activeNote,view:mobileView}; + try{window.history.pushState(state,"");}catch{} + navStateRef.current=state; + } + },[activeFolder,activeNote,mobileView]); + + useEffect(()=>{ + const handler=(e)=>{ + const s=e.state; + if(s){setActiveFolder(s.folder||"dashboard");setActiveNote(s.note||null);if(isMobile)setMobileView(s.view||"list");} + else{if(activeNote){setActiveNote(null);if(isMobile)setMobileView("list");}else if(activeFolder!=="dashboard"){setActiveFolder("dashboard");setActiveNote(null);}} + }; + window.addEventListener("popstate",handler);return()=>window.removeEventListener("popstate",handler); + },[activeNote,activeFolder,isMobile]); if(loading)return
Loading...
; if(!user)return; @@ -612,6 +932,8 @@ export default function NoteApp(){ ))}
+ {installPrompt&&}
● {syncStatus==="synced"?"All synced":syncStatus==="syncing"?"Syncing...":syncStatus==="offline"?"Offline mode":syncStatus==="pending"?"Changes pending":"Sync error"} @@ -710,13 +1032,7 @@ export default function NoteApp(){ const renderEditor=()=>{ // 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:( + if(!currentNoteSafe)return isMobile?null:(
✏️
Select a note to edit
@@ -738,13 +1054,13 @@ export default function NoteApp(){
{isMobile&&backBtn("Back",()=>{setActiveNote(null);setMobileView("list");})} - updateNote(currentNote.id,{title:e.target.value})} + updateNote(currentNoteSafe.id,{title:e.target.value})} 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}/> + updateNote(currentNoteSafe.id,u)} dark={dark}/>
); @@ -775,4 +1091,4 @@ export default function NoteApp(){ {ctxMenu&&setCtxMenu(null)} dark={dark}/>}
); -} \ No newline at end of file +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 02e7fdc..2305da9 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -2,13 +2,10 @@ import React from 'react' import ReactDOM from 'react-dom/client' import NoteApp from './NoteApp.jsx' -// Hide splash screen after app renders +// Hide splash screen const hideSplash = () => { const splash = document.getElementById('splash'); - if (splash) { - splash.classList.add('hide'); - setTimeout(() => splash.remove(), 300); - } + if (splash) { splash.classList.add('hide'); setTimeout(() => splash.remove(), 300); } }; ReactDOM.createRoot(document.getElementById('root')).render( @@ -17,29 +14,18 @@ ReactDOM.createRoot(document.getElementById('root')).render( ); -// Hide splash after a short delay (let React mount) setTimeout(hideSplash, 200); -// ── Register Service Worker ── +// 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'); - } - }); - }); + // Use the page's base URL as scope so it works behind any reverse proxy path + const swUrl = new URL('./sw.js', window.location.href).href; + const scope = new URL('./', window.location.href).href; + const reg = await navigator.serviceWorker.register(swUrl, { scope }); + console.log('[SW] Registered, scope:', reg.scope); + setInterval(() => reg.update(), 60 * 60 * 1000); } catch (err) { console.warn('[SW] Registration failed:', err); } diff --git a/frontend/src/syncEngine.js b/frontend/src/syncEngine.js index dcafc02..efccae7 100644 --- a/frontend/src/syncEngine.js +++ b/frontend/src/syncEngine.js @@ -6,6 +6,7 @@ */ import * as db from './offlineStore.js'; +const REQUEST_TIMEOUT_MS = 12000; const DEVICE_ID = (() => { try { @@ -20,14 +21,27 @@ const api = { base: '/api', token: null, async req(method, path, body) { - const opts = { method, headers: { 'Content-Type': 'application/json', 'X-Device-Id': DEVICE_ID } }; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const opts = { method, headers: { 'Content-Type': 'application/json', 'X-Device-Id': DEVICE_ID }, signal: controller.signal }; 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; + try { + let r; + try { + r = await fetch(this.base + path, opts); + } catch (err) { + if (err?.name === 'AbortError') throw new Error('TIMEOUT'); + throw new Error('NETWORK'); + } + if (r.status === 401) { this.token = null; try { localStorage.removeItem('inknote_token'); } catch {} throw new Error('AUTH'); } + let data = {}; + try { data = await r.json(); } catch {} + if (!r.ok) throw new Error(data.error || 'Request failed'); + return data; + } finally { + clearTimeout(timeoutId); + } }, get(p) { return this.req('GET', p); }, post(p, b) { return this.req('POST', p, b); }, @@ -234,12 +248,34 @@ export async function reorderNotebooks(orderedIds) { export async function getNotes() { return db.getAllNotes(); } -export async function loadNoteContent(id) { +export async function loadNoteContent(id, opts = {}) { + const forceRemote = !!opts.forceRemote; + + // Local-first for instant editor open and resilience against slow/hung requests. + if (!forceRemote) { + const local = await db.getNoteContent(id); + if (local) { + if (_online && api.token) { + api.get(`/notes/${id}`).then(async (d) => { + const c = { content: d.content || '', drawingData: d.drawingData || null }; + await db.putNoteContent(id, c.content, c.drawingData); + }).catch(() => {}); + } + return { content: local.content || '', drawingData: local.drawingData || null }; + } + } + + // No local cache (or force remote): fetch server, then fallback to cache. + if (_online && api.token) { + try { + const d = await api.get(`/notes/${id}`); + const c = { content: d.content || '', drawingData: d.drawingData || null }; + await db.putNoteContent(id, c.content, c.drawingData); + return c; + } catch {} + } 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 }; } @@ -330,4 +366,4 @@ export async function emptyTrashLocal() { else { await db.enqueue({ action: 'emptyTrash', data: {} }); notifyStatus('pending'); } } -export { DEVICE_ID }; \ No newline at end of file +export { DEVICE_ID };