+
+
+
+ 12?12:14,fontWeight:600,fontFamily:"'Newsreader',Georgia,serif",textShadow:"0 1px 4px rgba(0,0,0,0.4)",display:"-webkit-box",WebkitLineClamp:2,WebkitBoxOrient:"vertical",overflow:"hidden",wordBreak:"break-word"}}>{notebook.name}
+
+
+
+
+
+
+
{notebook.name}
+
{noteCount} {noteCount===1?"note":"notes"}
+
+ );
+}
+
+
+/* ════════════════════════════ 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
- Sync engine
- Mobile gestures
- 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}}/>}
+
-
+
+
+
+
{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() {
setShowNewNb(true)} style={{display:"flex",alignItems:"center",justifyContent:"center",width:22,height:22,borderRadius:6,border:"none",background:T.accentSoft,cursor:"pointer",color:T.accent}}>{I.plus}
{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}
-
{setActiveFolder(nb.id);setActiveNote(null);}} style={{display:"flex",alignItems:"center",gap:10,flex:1,padding:"7px 10px 7px 4px",borderRadius:10,border:"none",cursor:"pointer",fontSize:13,fontWeight:500,textAlign:"left",fontFamily:"'DM Sans',sans-serif",background:"transparent",color:activeFolder===nb.id?T.accentText:T.textSec}}>
+ {!isMobile&&{I.grip}}
+ {setActiveFolder(nb.id);setActiveNote(null);if(isMobile){setSidebarOpen(false);setMobileView("list");}}} style={{display:"flex",alignItems:"center",gap:10,flex:1,padding:"7px 10px 7px "+(isMobile?"10px":"4px"),borderRadius:10,border:"none",cursor:"pointer",fontSize:13,fontWeight:500,textAlign:"left",background:"transparent",color:activeFolder===nb.id?T.accentText:T.textSec}}>
{nb.name}
- {notes.filter(n=>n.folderId===nb.id).length}
+ {activeNotes.filter(n=>n.folderId===nb.id).length}
))}
- ●
- {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=()=>(
-
+
setSidebarOpen(!sidebarOpen)} style={{display:"flex",alignItems:"center",justifyContent:"center",width:36,height:36,borderRadius:10,border:"none",background:"transparent",cursor:"pointer",color:T.muted}}>{I.menu}
-
My Notebooks
+ My Notebooks
-
setShowNewNb(true)} style={{display:"flex",alignItems:"center",gap:8,padding:"10px 20px",borderRadius:12,border:"none",background:T.accent,color:"#fff",cursor:"pointer",fontSize:13,fontWeight:600,fontFamily:"'DM Sans',sans-serif",boxShadow:`0 2px 10px ${T.accent}44`}}>{I.plus} New Notebook
-
+
{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)}/>
))}
setShowNewNb(true)} style={{display:"flex",flexDirection:"column",alignItems:"center",gap:10,background:"none",border:"none",cursor:"pointer"}}>
-
-
+
Recent Notes
-
- {notes.slice(0,6).map(note=>{const nb=notebooks.find(b=>b.id===note.folderId);return(
-
{setActiveFolder(note.folderId);setActiveNote(note.id);}}
- style={{display:"flex",flexDirection:"column",padding:"14px 16px",borderRadius:12,border:`1px solid ${T.border}`,background:T.card,cursor:"pointer",textAlign:"left",fontFamily:"'DM Sans',sans-serif"}}>
+
+ {activeNotes.slice(0,6).map(note=>{const nb=notebooks.find(b=>b.id===note.folderId);return(
+
{setActiveFolder(note.folderId);setActiveNote(note.id);if(isMobile)setMobileView("editor");}}
+ style={{display:"flex",flexDirection:"column",padding:"14px 16px",borderRadius:12,border:`1px solid ${T.border}`,background:T.card,cursor:"pointer",textAlign:"left"}}>
{nb&&
}
{note.title}
{note.starred&&⭐}
- {note.drawingData&&🎨}
-
{(note.content||"").replace(/<[^>]*>/g,"").slice(0,90)||"Empty note"}
-
+
{fmtDate(note.updatedAt)}
{nb&&{nb.name}}
@@ -578,64 +667,88 @@ export default function NoteApp() {
);
+ /* ── Note List ── */
const renderNoteList=()=>{
const fname=(()=>{const sf=DEFAULT_FOLDERS.find(f=>f.id===activeFolder);if(sf)return sf.name;const nb=notebooks.find(n=>n.id===activeFolder);return nb?nb.name:"Notes";})();
+ // On mobile, hide list when editor is open
+ if(isMobile&&mobileView==="editor"&&activeNote)return null;
return(
-
+
setSidebarOpen(!sidebarOpen)} style={{display:"flex",alignItems:"center",justifyContent:"center",width:30,height:30,borderRadius:8,border:"none",background:"transparent",cursor:"pointer",color:T.muted}}>{I.menu}
{fname}
- {I.plus}
- fileRef.current?.click()} style={{display:"flex",alignItems:"center",justifyContent:"center",width:32,height:32,borderRadius:10,border:`1px solid ${T.border}`,background:"transparent",cursor:"pointer",color:T.text,fontSize:15}}>📄
-
+ {!isTrashView&&{I.plus}}
+ {isTrashView&&trashedNotes.length>0&&Empty}
-
+
{filteredNotes.length===0?(
-
+
+
{isTrashView?"🗑️":"📝"}
{isTrashView?"Trash is empty":"No notes yet"}
+ {!isTrashView&&
{I.plus} New Note}
+
):filteredNotes.map(note=>(
-
setActiveNote(note.id)}
- style={{display:"flex",flexDirection:"column",width:"100%",padding:"12px 14px",borderRadius:10,border:`1px solid ${activeNote===note.id?T.accent+"55":"transparent"}`,background:activeNote===note.id?T.accentSoft:"transparent",cursor:"pointer",marginBottom:2,textAlign:"left",fontFamily:"'DM Sans',sans-serif"}}>
+ {setActiveNote(note.id);if(isMobile)setMobileView("editor");}} onContextMenu={e=>handleNoteContextMenu(e,note)}
+ style={{display:"flex",flexDirection:"column",width:"100%",padding:"12px 14px",borderRadius:10,border:`1px solid ${activeNote===note.id?T.accent+"55":"transparent"}`,background:activeNote===note.id?T.accentSoft:"transparent",cursor:"pointer",marginBottom:2,textAlign:"left"}}>
- 📝
+ {note.trashed?"🗑️":"📝"}
{note.title}
- {note.starred&&⭐}
- {note.drawingData&&🎨}
+ {note.starred&&!note.trashed&&⭐}
- {note.content&&{note.content.replace(/<[^>]*>/g,"").slice(0,70)}
}
- {fmtDate(note.updatedAt)}
+ {fmtDate(note.updatedAt)}
))}
- );};
+ );
+ };
+ /* ── 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
-
{I.plus} New Note
+ {!isTrashView&&
{I.plus} New Note}
+
+ );
+ if(isTrashView&¤tNoteMeta)return(
+
+
+ {isMobile&&backBtn("Back",()=>{setActiveNote(null);setMobileView("list");})}
+ {currentNoteMeta.title}
+ restoreNote(currentNoteMeta.id)} style={{padding:"6px 14px",borderRadius:8,border:"none",background:T.accent,color:"#fff",cursor:"pointer",fontSize:12,fontWeight:600}}>Restore
+ permanentDeleteNote(currentNoteMeta.id)} style={{display:"flex",alignItems:"center",justifyContent:"center",width:30,height:30,borderRadius:8,border:"none",background:"transparent",cursor:"pointer",color:T.danger}}>{I.trash}
+
+
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"}}/>
- toggleStar(currentNote.id)} style={{display:"flex",alignItems:"center",justifyContent:"center",width:30,height:30,borderRadius:8,border:"none",background:"transparent",cursor:"pointer",color:currentNote.starred?"#f0b429":T.faint}}>
- {currentNote.starred?I.starFill:I.star}
-
- requestDeleteNote(currentNote.id)} style={{display:"flex",alignItems:"center",justifyContent:"center",width:30,height:30,borderRadius:8,border:"none",background:"transparent",cursor:"pointer",color:T.danger}}>{I.trash}
+ style={{flex:1,border:"none",background:"transparent",outline:"none",fontSize:isMobile?15:17,fontWeight:600,color:T.text,fontFamily:"'Newsreader',Georgia,serif",minWidth:0}}/>
+ toggleStar(currentNote.id)} style={{display:"flex",alignItems:"center",justifyContent:"center",width:30,height:30,borderRadius:8,border:"none",background:"transparent",cursor:"pointer",color:currentNote.starred?"#f0b429":T.faint}}>{currentNote.starred?I.starFill:I.star}
+ trashNoteAction(currentNote.id)} style={{display:"flex",alignItems:"center",justifyContent:"center",width:30,height:30,borderRadius:8,border:"none",background:"transparent",cursor:"pointer",color:T.danger}}>{I.trash}
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