1095 lines
89 KiB
React
1095 lines
89 KiB
React
import { useState, useRef, useEffect, 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 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 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"},
|
||
];
|
||
const DEFAULT_FOLDERS = [{id:"all",name:"All Notes",icon:"📋"},{id:"starred",name:"Starred",icon:"⭐"},{id:"trash",name:"Trash",icon:"🗑️"}];
|
||
const I = {
|
||
plus:<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>,
|
||
search:<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>,
|
||
bold:<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>,
|
||
italic:<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>,
|
||
underline:<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>,
|
||
list:<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="4" cy="6" r="1" fill="currentColor"/><circle cx="4" cy="12" r="1" fill="currentColor"/><circle cx="4" cy="18" r="1" fill="currentColor"/></svg>,
|
||
listOrd:<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="2" y="8" fontSize="8" fill="currentColor" stroke="none" fontFamily="sans-serif">1</text><text x="2" y="14" fontSize="8" fill="currentColor" stroke="none" fontFamily="sans-serif">2</text><text x="2" y="20" fontSize="8" fill="currentColor" stroke="none" fontFamily="sans-serif">3</text></svg>,
|
||
check:<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M9 12l2 2 4-4"/></svg>,
|
||
trash:<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>,
|
||
star:<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>,
|
||
starFill:<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>,
|
||
pen:<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg>,
|
||
eraser:<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M20 20H7L3 16l9-9 8 8-4 4"/><path d="M6 11l8 8"/></svg>,
|
||
highlight:<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M15.5 2.5L22 9l-6 6-7-7 6.5-5.5z"/><path d="M9 8l-5 5 4 4 5-5"/><path d="M4 13l-2 7 7-2"/></svg>,
|
||
undo:<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>,
|
||
redo:<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>,
|
||
sun:<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>,
|
||
moon:<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>,
|
||
menu:<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>,
|
||
text:<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg>,
|
||
draw:<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>,
|
||
edit:<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>,
|
||
move:<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>,
|
||
restore:<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>,
|
||
grip:<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="5" r="2"/><circle cx="15" cy="5" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="15" cy="12" r="2"/><circle cx="9" cy="19" r="2"/><circle cx="15" cy="19" r="2"/></svg>,
|
||
logout:<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>,
|
||
user:<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>,
|
||
x:<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>,
|
||
save:<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>,
|
||
};
|
||
/* ═══════ 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(<div style={{position:"fixed",inset:0,background:"rgba(0,0,0,0.5)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:2000,backdropFilter:"blur(4px)"}} onClick={onCancel}>
|
||
<div style={{background:T.bg,borderRadius:16,padding:26,width:380,maxWidth:"90vw",boxShadow:"0 20px 40px rgba(0,0,0,0.3)",border:`1px solid ${T.border}`}} onClick={e=>e.stopPropagation()}>
|
||
<div style={{fontSize:17,fontWeight:600,color:T.text,marginBottom:8,fontFamily:"'Newsreader',Georgia,serif"}}>{title}</div>
|
||
<div style={{fontSize:13,color:T.muted,lineHeight:1.6,marginBottom:22,fontFamily:"'DM Sans',sans-serif"}}>{message}</div>
|
||
<div style={{display:"flex",gap:10,justifyContent:"flex-end"}}>
|
||
<button onClick={onCancel} style={{padding:"8px 20px",borderRadius:10,border:`1px solid ${T.border}`,background:"transparent",color:T.text,cursor:"pointer",fontSize:13,fontWeight:500}}>Cancel</button>
|
||
<button onClick={onConfirm} style={{padding:"8px 20px",borderRadius:10,border:"none",background:T.danger,color:"#fff",cursor:"pointer",fontSize:13,fontWeight:500}}>{confirmLabel}</button>
|
||
</div>
|
||
</div>
|
||
</div>);
|
||
}
|
||
|
||
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(<div style={{position:"fixed",left:Math.min(x,window.innerWidth-220),top:Math.min(y,window.innerHeight-200),zIndex:3000,background:T.bg,border:`1px solid ${T.border}`,borderRadius:12,boxShadow:"0 8px 24px rgba(0,0,0,0.25)",padding:4,minWidth:190,fontFamily:"'DM Sans',sans-serif"}} onClick={e=>e.stopPropagation()}>
|
||
{items.map((item,i)=>item.divider?<div key={i} style={{height:1,background:T.border,margin:"4px 6px"}}/>:item.submenu?<SubMenu key={i} item={item} T={T} onClose={onClose}/>:(
|
||
<button key={i} onClick={()=>{item.action();onClose();}} style={{display:"flex",alignItems:"center",gap:10,width:"100%",padding:"8px 12px",borderRadius:8,border:"none",cursor:"pointer",fontSize:13,fontWeight:500,background:"transparent",color:item.danger?T.danger:T.text,textAlign:"left"}}
|
||
onMouseEnter={e=>e.currentTarget.style.background=T.hover} onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
|
||
{item.icon&&<span style={{display:"flex",opacity:0.7}}>{item.icon}</span>}{item.label}
|
||
</button>
|
||
))}
|
||
</div>);
|
||
}
|
||
|
||
function SubMenu({item,T,onClose}){
|
||
const [open,setOpen]=useState(false);
|
||
return(<div style={{position:"relative"}} onMouseEnter={()=>setOpen(true)} onMouseLeave={()=>setOpen(false)}>
|
||
<div style={{display:"flex",alignItems:"center",gap:10,width:"100%",padding:"8px 12px",borderRadius:8,fontSize:13,fontWeight:500,color:T.text,cursor:"pointer",background:open?T.hover:"transparent"}}>
|
||
{item.icon&&<span style={{display:"flex",opacity:0.7}}>{item.icon}</span>}{item.label}<span style={{marginLeft:"auto",fontSize:10,opacity:0.5}}>▸</span>
|
||
</div>
|
||
{open&&<div style={{position:"absolute",left:"100%",top:0,background:T.bg,border:`1px solid ${T.border}`,borderRadius:10,boxShadow:"0 8px 20px rgba(0,0,0,0.2)",padding:4,minWidth:160,zIndex:10}}>
|
||
{item.submenu.map((sub,j)=><button key={j} onClick={()=>{sub.action();onClose();}} style={{display:"flex",alignItems:"center",gap:8,width:"100%",padding:"7px 12px",borderRadius:6,border:"none",cursor:"pointer",fontSize:12,fontWeight:500,background:"transparent",color:T.text,textAlign:"left"}}
|
||
onMouseEnter={e=>e.currentTarget.style.background=T.hover} onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
|
||
{sub.colorDot&&<div style={{width:12,height:8,borderRadius:2,background:sub.colorDot,flexShrink:0}}/>}{sub.label}
|
||
</button>)}
|
||
</div>}
|
||
</div>);
|
||
}
|
||
|
||
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(<div style={{position:"fixed",inset:0,background:"rgba(0,0,0,0.55)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:1000,backdropFilter:"blur(6px)"}} onClick={onClose}>
|
||
<div style={{background:T.bg,borderRadius:20,padding:30,width:460,maxWidth:"92vw",boxShadow:"0 24px 48px rgba(0,0,0,0.3)",border:`1px solid ${T.border}`}} onClick={e=>e.stopPropagation()}>
|
||
<h3 style={{margin:"0 0 22px",color:T.text,fontSize:18,fontWeight:600,fontFamily:"'Newsreader',Georgia,serif"}}>Edit Notebook</h3>
|
||
<input type="text" value={name} onChange={e=>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});}}/>
|
||
<div style={{marginBottom:22}}>
|
||
<div style={{fontSize:12,color:T.muted,marginBottom:8}}>Cover style</div>
|
||
<div style={{display:"grid",gridTemplateColumns:"repeat(5,1fr)",gap:8}}>
|
||
{COVERS.map((c,i)=><button key={i} onClick={()=>setSel(i)} style={{width:"100%",aspectRatio:"3/4",borderRadius:8,background:c.bg,border:sel===i?`3px solid ${T.active}`:"2px solid transparent",cursor:"pointer",position:"relative",overflow:"hidden"}}><div style={{position:"absolute",left:0,top:0,bottom:0,width:4,background:c.spine}}/></button>)}
|
||
</div>
|
||
</div>
|
||
<div style={{display:"flex",gap:10,justifyContent:"flex-end"}}>
|
||
<button onClick={onClose} style={{padding:"8px 20px",borderRadius:10,border:`1px solid ${T.border}`,background:"transparent",color:T.text,cursor:"pointer",fontSize:13,fontWeight:500}}>Cancel</button>
|
||
<button onClick={()=>{if(name.trim())onSave({name:name.trim(),cover:sel});}} style={{padding:"8px 20px",borderRadius:10,border:"none",background:T.active,color:"#fff",cursor:"pointer",fontSize:13,fontWeight:500}}>Save</button>
|
||
</div>
|
||
</div>
|
||
</div>);
|
||
}
|
||
|
||
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(<div style={{position:"fixed",inset:0,background:"rgba(0,0,0,0.55)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:1000,backdropFilter:"blur(6px)"}} onClick={onClose}>
|
||
<div style={{background:T.bg,borderRadius:20,padding:30,width:460,maxWidth:"92vw",boxShadow:"0 24px 48px rgba(0,0,0,0.3)",border:`1px solid ${T.border}`}} onClick={e=>e.stopPropagation()}>
|
||
<h3 style={{margin:"0 0 22px",color:T.text,fontSize:18,fontWeight:600,fontFamily:"'Newsreader',Georgia,serif"}}>New Notebook</h3>
|
||
<input type="text" value={name} onChange={e=>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});}}/>
|
||
<div style={{marginBottom:22}}>
|
||
<div style={{fontSize:12,color:T.muted,marginBottom:8}}>Choose cover style</div>
|
||
<div style={{display:"grid",gridTemplateColumns:"repeat(5,1fr)",gap:8}}>
|
||
{COVERS.map((c,i)=><button key={i} onClick={()=>setSel(i)} style={{width:"100%",aspectRatio:"3/4",borderRadius:8,background:c.bg,border:sel===i?`3px solid ${T.active}`:"2px solid transparent",cursor:"pointer",position:"relative",overflow:"hidden"}}><div style={{position:"absolute",left:0,top:0,bottom:0,width:4,background:c.spine}}/></button>)}
|
||
</div>
|
||
</div>
|
||
<div style={{display:"flex",gap:10,justifyContent:"flex-end"}}>
|
||
<button onClick={onClose} style={{padding:"8px 20px",borderRadius:10,border:`1px solid ${T.border}`,background:"transparent",color:T.text,cursor:"pointer",fontSize:13,fontWeight:500}}>Cancel</button>
|
||
<button onClick={()=>{if(name.trim())onCreate({name:name.trim(),cover:sel});}} style={{padding:"8px 20px",borderRadius:10,border:"none",background:T.active,color:"#fff",cursor:"pointer",fontSize:13,fontWeight:500}}>Create</button>
|
||
</div>
|
||
</div>
|
||
</div>);
|
||
}
|
||
|
||
|
||
/* ═══════ 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(<div style={{position:"fixed",inset:0,background:"rgba(0,0,0,0.55)",display:"flex",alignItems:"center",justifyContent:"center",zIndex:2000,backdropFilter:"blur(6px)"}} onClick={onClose}>
|
||
<div style={{background:T.bg,borderRadius:20,padding:0,width:440,maxWidth:"92vw",boxShadow:"0 24px 48px rgba(0,0,0,0.3)",border:`1px solid ${T.border}`,overflow:"hidden"}} onClick={e=>e.stopPropagation()}>
|
||
<div style={{display:"flex",alignItems:"center",justifyContent:"space-between",padding:"18px 22px",borderBottom:`1px solid ${T.border}`}}>
|
||
<h3 style={{margin:0,fontSize:17,fontWeight:600,color:T.text,fontFamily:"'Newsreader',Georgia,serif"}}>Account</h3>
|
||
<button onClick={onClose} style={{display:"flex",alignItems:"center",justifyContent:"center",width:28,height:28,borderRadius:8,border:"none",background:"transparent",cursor:"pointer",color:T.muted}}>{I.x}</button>
|
||
</div>
|
||
<div style={{display:"flex",gap:4,padding:"12px 22px 0",borderBottom:`1px solid ${T.border}`}}>
|
||
<button onClick={()=>{setTab("profile");clear();}} style={tbtn(tab==="profile")}>Profile</button>
|
||
<button onClick={()=>{setTab("email");clear();}} style={tbtn(tab==="email")}>Email</button>
|
||
<button onClick={()=>{setTab("password");clear();}} style={tbtn(tab==="password")}>Password</button>
|
||
</div>
|
||
<div style={{padding:"18px 22px 22px"}}>
|
||
{msg&&<div style={{color:T.success,fontSize:12,marginBottom:10,padding:"8px 12px",borderRadius:8,background:T.success+"15"}}>{msg}</div>}
|
||
{err&&<div style={{color:T.danger,fontSize:12,marginBottom:10,padding:"8px 12px",borderRadius:8,background:T.danger+"12"}}>{err}</div>}
|
||
|
||
{tab==="profile"&&<>
|
||
<label style={{fontSize:12,color:T.muted,marginBottom:4,display:"block"}}>Display Name</label>
|
||
<input type="text" value={displayName} onChange={e=>setDisplayName(e.target.value)} style={inp} onKeyDown={e=>{if(e.key==="Enter")saveProfile();}}/>
|
||
<button onClick={saveProfile} disabled={saving} style={{width:"100%",padding:"10px",borderRadius:10,border:"none",background:T.accent,color:"#fff",cursor:"pointer",fontSize:13,fontWeight:600,opacity:saving?0.6:1}}>{saving?"Saving...":"Save Changes"}</button>
|
||
</>}
|
||
|
||
{tab==="email"&&<>
|
||
<label style={{fontSize:12,color:T.muted,marginBottom:4,display:"block"}}>New Email</label>
|
||
<input type="email" value={newEmail} onChange={e=>setNewEmail(e.target.value)} style={inp}/>
|
||
<label style={{fontSize:12,color:T.muted,marginBottom:4,display:"block"}}>Confirm Password</label>
|
||
<input type="password" value={emailPass} onChange={e=>setEmailPass(e.target.value)} style={inp} onKeyDown={e=>{if(e.key==="Enter")saveEmail();}}/>
|
||
<button onClick={saveEmail} disabled={saving} style={{width:"100%",padding:"10px",borderRadius:10,border:"none",background:T.accent,color:"#fff",cursor:"pointer",fontSize:13,fontWeight:600,opacity:saving?0.6:1}}>{saving?"Saving...":"Update Email"}</button>
|
||
</>}
|
||
|
||
{tab==="password"&&<>
|
||
<label style={{fontSize:12,color:T.muted,marginBottom:4,display:"block"}}>Current Password</label>
|
||
<input type="password" value={curPass} onChange={e=>setCurPass(e.target.value)} style={inp}/>
|
||
<label style={{fontSize:12,color:T.muted,marginBottom:4,display:"block"}}>New Password</label>
|
||
<input type="password" value={newPass} onChange={e=>setNewPass(e.target.value)} style={inp}/>
|
||
<label style={{fontSize:12,color:T.muted,marginBottom:4,display:"block"}}>Confirm New Password</label>
|
||
<input type="password" value={newPass2} onChange={e=>setNewPass2(e.target.value)} style={inp} onKeyDown={e=>{if(e.key==="Enter")savePassword();}}/>
|
||
<button onClick={savePassword} disabled={saving} style={{width:"100%",padding:"10px",borderRadius:10,border:"none",background:T.accent,color:"#fff",cursor:"pointer",fontSize:13,fontWeight:600,opacity:saving?0.6:1}}>{saving?"Saving...":"Change Password"}</button>
|
||
</>}
|
||
</div>
|
||
<div style={{padding:"0 22px 18px"}}>
|
||
<button onClick={onLogout} style={{display:"flex",alignItems:"center",gap:8,width:"100%",padding:"10px",borderRadius:10,border:`1px solid ${T.danger}33`,background:T.danger+"08",cursor:"pointer",color:T.danger,fontSize:13,fontWeight:600,justifyContent:"center"}}>{I.logout} Sign Out</button>
|
||
</div>
|
||
</div>
|
||
</div>);
|
||
}
|
||
|
||
/* ═══════ 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(<div style={{display:"flex",alignItems:"center",justifyContent:"center",minHeight:"100vh",background:T.bg,fontFamily:"'DM Sans',sans-serif"}}>
|
||
<div style={{background:T.card,borderRadius:24,padding:40,width:400,maxWidth:"90vw",boxShadow:"0 20px 60px rgba(0,0,0,0.15)",border:`1px solid ${T.border}`}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:12,marginBottom:28}}>
|
||
<div style={{width:42,height:42,borderRadius:14,background:`linear-gradient(135deg,${T.accent},#a855f7)`,display:"flex",alignItems:"center",justifyContent:"center",fontSize:22}}>✏️</div>
|
||
<div><div style={{fontSize:22,fontWeight:700,color:T.text,fontFamily:"'Newsreader',Georgia,serif"}}>InkNote</div>
|
||
<div style={{fontSize:12,color:T.muted}}>{isLogin?"Sign in to sync your notes":"Create your account"}</div></div>
|
||
</div>
|
||
{!isLogin&&<input type="text" value={name} onChange={e=>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"}}/>}
|
||
<input type="email" value={email} onChange={e=>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"}}/>
|
||
<input type="password" value={pass} onChange={e=>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&&<div style={{color:T.danger,fontSize:12,marginBottom:12,padding:"8px 12px",borderRadius:8,background:T.danger+"12"}}>{err}</div>}
|
||
<button onClick={submit} disabled={loading} style={{width:"100%",padding:"12px",borderRadius:12,border:"none",background:T.accent,color:"#fff",cursor:"pointer",fontSize:14,fontWeight:600,marginBottom:16,opacity:loading?0.6:1}}>
|
||
{loading?"...":(isLogin?"Sign In":"Create Account")}
|
||
</button>
|
||
<div style={{textAlign:"center",fontSize:13,color:T.muted}}>
|
||
{isLogin?"Don't have an account? ":"Already have an account? "}
|
||
<button onClick={()=>{setIsLogin(!isLogin);setErr("");}} style={{background:"none",border:"none",color:T.accent,cursor:"pointer",fontSize:13,fontWeight:600,textDecoration:"underline"}}>{isLogin?"Sign Up":"Sign In"}</button>
|
||
</div>
|
||
</div>
|
||
</div>);
|
||
}
|
||
/* ═══════ 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(<div style={{padding:"0 24px 10px"}}>
|
||
<div style={{fontSize:11,fontWeight:600,color:T.muted,textTransform:"uppercase",letterSpacing:"0.06em",marginBottom:6}}>Tasks ({tasks.filter(t=>t.done).length}/{tasks.length})</div>
|
||
{tasks.map((task,i)=>(
|
||
<div key={task.id} style={{display:"flex",alignItems:"center",gap:8,padding:"4px 0",borderBottom:`1px solid ${dark?"#1c2128":"#f0f0f0"}`}}>
|
||
<input type="checkbox" checked={task.done} onChange={()=>toggle(i)} style={{width:16,height:16,accentColor:T.active,cursor:"pointer",flexShrink:0}}/>
|
||
<input ref={el=>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"}}/>
|
||
<button onClick={()=>remove(i)} style={{display:"flex",alignItems:"center",justifyContent:"center",width:20,height:20,borderRadius:4,border:"none",background:"transparent",cursor:"pointer",color:T.faint,opacity:0.5,fontSize:12}}>×</button>
|
||
</div>
|
||
))}
|
||
<button onClick={()=>addAfter(tasks.length-1)} style={{display:"flex",alignItems:"center",gap:6,padding:"6px 0",border:"none",background:"transparent",cursor:"pointer",color:T.active,fontSize:12,fontWeight:500,marginTop:4}}>
|
||
{I.plus} <span>Add task</span>
|
||
</button>
|
||
</div>);
|
||
}
|
||
|
||
/* ═══════ 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 [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(()=>{
|
||
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;i<data.length;i+=4){
|
||
const a=data[i+3];if(a<245)continue;solid++;
|
||
if(data[i]<16&&data[i+1]<16&&data[i+2]<16)black++;
|
||
}
|
||
const blackRatio=solid?black/solid:0;
|
||
if(blackRatio>0.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;
|
||
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;i<sample.length;i+=4){
|
||
const a=sample[i+3];if(a<245)continue;solid++;
|
||
if(sample[i]<16&&sample[i+1]<16&&sample[i+2]<16)black++;
|
||
}
|
||
const blackRatio=solid?black/solid:0;
|
||
if(blackRatio>0.9){
|
||
const im=tctx.getImageData(0,0,CW,CH);const d=im.data;
|
||
for(let i=0;i<d.length;i+=4){
|
||
if(d[i]<18&&d[i+1]<18&&d[i+2]<18&&d[i+3]>245)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 exec=(cmd,val=null)=>{document.execCommand(cmd,false,val);editorRef.current?.focus();onHtmlChange();};
|
||
|
||
/* ── 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,
|
||
`<img src="${ev.target.result}" data-rid="1" style="width:50%;max-width:50%;height:auto;border-radius:8px;margin:8px 0;display:block;cursor:pointer" />`);
|
||
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<rect.right-20)return;
|
||
e.preventDefault();e.stopPropagation();
|
||
setSelectedImg(img);
|
||
const startX=px,startW=img.offsetWidth,cW=el.offsetWidth-48;
|
||
const move=(ev)=>{
|
||
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)=><button onClick={()=>switchMode(m)} style={{display:"flex",alignItems:"center",gap:6,padding:"6px 12px",borderRadius:8,fontSize:12,fontWeight:600,border:`1.5px solid ${mode===m?T.active:T.border}`,background:mode===m?T.active+"15":"transparent",color:mode===m?T.active:T.muted,cursor:"pointer",flexShrink:0}}>{ic}<span>{lb}</span></button>;
|
||
|
||
return(<div style={{display:"flex",flexDirection:"column",height:"100%",background:isMobileViewport?T.bg:(dark?"linear-gradient(180deg,#0f141b 0%,#0d1117 60%)":"linear-gradient(180deg,#f8fafd 0%,#eef3f8 70%)")}}>
|
||
{/* Main toolbar */}
|
||
<div style={{display:"flex",alignItems:"center",gap:6,padding:"7px 14px",borderBottom:`1px solid ${T.border}`,background:T.toolbar,flexWrap:"wrap",minHeight:46}}>
|
||
{mb("text",I.text,"Write")}{mb("draw",I.draw,"Draw")}
|
||
<div style={{width:1,height:22,background:T.border,margin:"0 2px",flexShrink:0}}/>
|
||
{mode==="text"?(<>
|
||
<button style={tb()} onClick={()=>exec("bold")}>{I.bold}</button>
|
||
<button style={tb()} onClick={()=>exec("italic")}>{I.italic}</button>
|
||
<button style={tb()} onClick={()=>exec("underline")}>{I.underline}</button>
|
||
<div style={{width:1,height:22,background:T.border,margin:"0 2px"}}/>
|
||
<button style={tb()} onClick={()=>exec("insertUnorderedList")}>{I.list}</button>
|
||
<button style={tb()} onClick={()=>exec("insertOrderedList")}>{I.listOrd}</button>
|
||
<button style={tb(parsed.tasks.length>0)} onClick={addTaskList} title="Add task list">{I.check}</button>
|
||
<div style={{width:1,height:22,background:T.border,margin:"0 2px"}}/>
|
||
<select onChange={e=>exec("formatBlock",e.target.value)} defaultValue="" style={{border:`1px solid ${T.border}`,borderRadius:6,padding:"3px 6px",background:T.toolbar,color:T.text,fontSize:11}}>
|
||
<option value="" disabled>Heading</option><option value="h1">H1</option><option value="h2">H2</option><option value="h3">H3</option><option value="p">Body</option>
|
||
</select>
|
||
</>):(<>
|
||
<button style={tb(tool==="pen")} onClick={()=>setTool("pen")}>{I.pen}</button>
|
||
<button style={tb(tool==="highlighter")} onClick={()=>setTool("highlighter")}>{I.highlight}</button>
|
||
<button style={tb(tool==="eraser")} onClick={()=>setTool("eraser")}>{I.eraser}</button>
|
||
<div style={{width:1,height:22,background:T.border,margin:"0 2px"}}/>
|
||
<button style={tb()} onClick={undo}>{I.undo}</button><button style={tb()} onClick={redo}>{I.redo}</button>
|
||
<div style={{width:1,height:22,background:T.border,margin:"0 2px"}}/>
|
||
<div style={{position:"relative"}}>
|
||
<button style={tb(showColors)} onClick={()=>{setShowColors(!showColors);setShowSizes(false);}}><div style={{width:18,height:18,borderRadius:"50%",background:penColor,border:`2px solid ${T.border}`}}/></button>
|
||
{showColors&&<div style={{position:"absolute",top:36,left:0,background:T.toolbar,border:`1px solid ${T.border}`,borderRadius:12,padding:8,display:"grid",gridTemplateColumns:"repeat(5,1fr)",gap:4,zIndex:100,boxShadow:"0 8px 20px rgba(0,0,0,.2)"}}>
|
||
{COLORS.map(c=><button key={c} onClick={()=>{setPenColor(c);setShowColors(false);}} style={{width:26,height:26,borderRadius:"50%",background:c,border:penColor===c?`3px solid ${T.active}`:`1px solid ${T.border}`,cursor:"pointer"}}/>)}
|
||
</div>}
|
||
</div>
|
||
<div style={{position:"relative"}}>
|
||
<button style={tb(showSizes)} onClick={()=>{setShowSizes(!showSizes);setShowColors(false);}}><div style={{width:Math.min(penSize*2,16),height:Math.min(penSize*2,16),borderRadius:"50%",background:T.text}}/></button>
|
||
{showSizes&&<div style={{position:"absolute",top:36,left:0,background:T.toolbar,border:`1px solid ${T.border}`,borderRadius:12,padding:8,display:"flex",flexDirection:"column",gap:4,zIndex:100,boxShadow:"0 8px 20px rgba(0,0,0,.2)"}}>
|
||
{PEN_SIZES.map(s=><button key={s} onClick={()=>{setPenSize(s);setShowSizes(false);}} style={{display:"flex",alignItems:"center",gap:8,padding:"3px 8px",borderRadius:6,border:"none",cursor:"pointer",background:penSize===s?T.active+"22":"transparent"}}>
|
||
<div style={{width:Math.min(s*2.5,20),height:Math.min(s*2.5,20),borderRadius:"50%",background:T.text}}/><span style={{fontSize:11,color:T.muted}}>{s}px</span></button>)}
|
||
</div>}
|
||
</div>
|
||
<div style={{flex:1}}/><button style={{...tb(),color:"#e5534b"}} onClick={clearCanvas}>{I.trash}</button>
|
||
</>)}
|
||
</div>
|
||
|
||
{/* Image toolbar — appears when an image is selected */}
|
||
{selectedImg&&mode==="text"&&<div data-imgtb="1" style={{display:"flex",alignItems:"center",justifyContent:"center",gap:6,padding:"6px 10px",background:T.toolbar,borderBottom:`1px solid ${T.border}`}}>
|
||
<span style={{fontSize:11,color:T.muted}}>Size:</span>
|
||
{[25,33,50,75,100].map(p=><button key={p} onClick={()=>setImgSize(p)} style={{padding:"3px 8px",borderRadius:6,border:`1px solid ${T.border}`,background:T.bg,color:T.text,fontSize:11,cursor:"pointer",fontWeight:500}}>{p}%</button>)}
|
||
<div style={{width:1,height:16,background:T.border}}/>
|
||
<button onClick={deleteImg} style={{padding:"3px 8px",borderRadius:6,border:"none",background:"#e5534b22",color:"#e5534b",fontSize:11,cursor:"pointer",fontWeight:600}}>Delete</button>
|
||
<span style={{fontSize:10,color:T.faint,marginLeft:2}}>· drag right edge to resize</span>
|
||
</div>}
|
||
|
||
{/* Content area */}
|
||
<div ref={stageRef} style={{flex:1,position:"relative",overflow:"hidden"}}>
|
||
<div style={{position:"absolute",inset:0,overflowY:"auto",WebkitOverflowScrolling:"touch",zIndex:1,padding:isMobileViewport?"10px 0 18px":"18px 0 28px"}}>
|
||
<div style={{position:"relative",width:stageSize.w,minHeight:stageSize.h,margin:isMobileViewport?"0 auto":"0 0 0 28px",background:T.bg,border:isMobileViewport?"none":`1px solid ${T.border}`,borderRadius:isMobileViewport?0:14,boxShadow:isMobileViewport?"none":"0 16px 36px rgba(0,0,0,0.14)"}}>
|
||
<div ref={editorRef} contentEditable={mode==="text"} suppressContentEditableWarning onInput={onHtmlChange}
|
||
style={{padding:"20px 24px",outline:"none",fontSize:15,lineHeight:1.7,color:T.text,minHeight:stageSize.h,width:"100%",fontFamily:"'Newsreader','Georgia',serif",pointerEvents:mode==="text"?"auto":"none"}}/>
|
||
{note.drawingData&&!hideDrawing&&<img src={note.drawingData} alt="" onError={()=>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"&&<canvas ref={canvasRef} onMouseDown={startDraw} onMouseMove={doDraw} onMouseUp={stopDraw} onMouseLeave={stopDraw} onTouchStart={startDraw} onTouchMove={doDraw} onTouchEnd={stopDraw}
|
||
style={{position:"absolute",left:0,top:0,width:"100%",height:stageSize.h,touchAction:"none",cursor:"crosshair",background:"transparent",zIndex:3}}/>}
|
||
{mode==="text"&&<TaskList tasks={parsed.tasks} onChange={onTasksChange} dark={dark}/>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>);
|
||
}
|
||
/* ═══════ 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(<button onClick={onClick} onContextMenu={onContextMenu} draggable={draggable} onDragStart={onDragStart} onDragEnd={onDragEnd} onDragOver={onDragOver} onDrop={onDrop} onMouseEnter={()=>setHovered(true)} onMouseLeave={()=>setHovered(false)}
|
||
style={{display:"flex",flexDirection:"column",alignItems:"center",gap:10,background:"none",border:"none",cursor:"pointer",transition:"transform .22s cubic-bezier(.4,0,.2,1),opacity .2s",transform:hovered?"translateY(-6px) scale(1.03)":isDragOver?"scale(1.06)":"none",opacity:isDragOver?0.6:1}}>
|
||
<div style={{position:"relative",width:156,height:210,perspective:800}}>
|
||
<div style={{position:"absolute",bottom:-5,left:10,right:10,height:14,background:"rgba(0,0,0,0.25)",borderRadius:"50%",filter:"blur(8px)",opacity:hovered?0.5:0.3}}/>
|
||
<div style={{position:"absolute",left:0,top:2,bottom:2,width:18,background:cover.spine,borderRadius:"4px 0 0 4px",boxShadow:"inset -2px 0 4px rgba(0,0,0,0.3)",zIndex:2}}>
|
||
{[12,18].map(t=><div key={t} style={{position:"absolute",top:t,left:3,right:3,height:1,background:"rgba(255,255,255,0.1)"}}/>)}
|
||
{[12,18].map(t=><div key={"b"+t} style={{position:"absolute",bottom:t,left:3,right:3,height:1,background:"rgba(255,255,255,0.1)"}}/>)}
|
||
</div>
|
||
<div style={{position:"absolute",right:2,top:8,bottom:8,width:5,background:"linear-gradient(90deg,#e8e0d4,#f5f0e8,#e8e0d4)",borderRadius:"0 2px 2px 0",zIndex:1}}/>
|
||
<div style={{position:"absolute",left:14,top:0,right:0,bottom:0,background:cover.bg,borderRadius:"2px 8px 8px 2px",boxShadow:isActive?`0 4px 20px rgba(0,0,0,0.3),0 0 0 3px ${cover.accent}88`:"0 4px 16px rgba(0,0,0,0.2)",overflow:"hidden",zIndex:3}}>
|
||
<div style={{position:"absolute",inset:0,background:"repeating-linear-gradient(45deg,transparent,transparent 30px,rgba(255,255,255,0.015) 30px,rgba(255,255,255,0.015) 60px)"}}/>
|
||
<div style={{position:"absolute",top:0,left:0,right:0,height:3,background:cover.accent,opacity:0.7}}/>
|
||
<div style={{position:"absolute",top:28,left:"50%",transform:"translateX(-50%)",width:50,height:50,borderRadius:"50%",border:`1.5px solid ${cover.accent}44`,display:"flex",alignItems:"center",justifyContent:"center"}}><div style={{width:30,height:30,borderRadius:"50%",border:`1px solid ${cover.accent}33`}}/></div>
|
||
<div style={{position:"absolute",top:90,left:8,right:8,display:"flex",flexDirection:"column",alignItems:"center",gap:4}}>
|
||
<div style={{width:"100%",padding:"6px 4px",borderTop:`1px solid ${cover.accent}33`,borderBottom:`1px solid ${cover.accent}33`,textAlign:"center"}}>
|
||
<span style={{color:"#fff",fontSize:notebook.name.length>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}</span>
|
||
</div>
|
||
<div style={{display:"flex",gap:3}}>{[0,1,2].map(i=><div key={i} style={{width:2.5,height:2.5,borderRadius:"50%",background:cover.accent+"55"}}/>)}</div>
|
||
</div>
|
||
<div style={{position:"absolute",bottom:0,left:0,right:0,height:3,background:cover.accent,opacity:0.7}}/>
|
||
</div>
|
||
</div>
|
||
<div style={{textAlign:"center",maxWidth:156}}>
|
||
<div style={{fontSize:13,fontWeight:600,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{notebook.name}</div>
|
||
<div style={{fontSize:11,opacity:0.5,marginTop:1}}>{noteCount} {noteCount===1?"note":"notes"}</div>
|
||
</div>
|
||
</button>);
|
||
}
|
||
|
||
|
||
/* ════════════════════════════ MAIN APP (offline-first) ════════════════════════════ */
|
||
export default function NoteApp(){
|
||
const [dark,setDark]=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("");
|
||
const [showNewNb,setShowNewNb]=useState(false);
|
||
const [syncStatus,setSyncStatus]=useState("synced");
|
||
const [ctxMenu,setCtxMenu]=useState(null);
|
||
const [confirmDialog,setConfirmDialog]=useState(null);
|
||
const [editingNb,setEditingNb]=useState(null);
|
||
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 [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",
|
||
}:{
|
||
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);},[]);
|
||
|
||
// ── 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),[]);
|
||
|
||
// ── Data change listener — reload from IndexedDB ──
|
||
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 ──
|
||
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;
|
||
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,activeFolder,searchQuery,activeNotes,trashedNotes]);
|
||
|
||
const currentNoteMeta=activeNote?notes.find(n=>n.id===activeNote):null;
|
||
const currentNoteContent=activeNote?noteContents[activeNote]:null;
|
||
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 — 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()=>{
|
||
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=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=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 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);}});
|
||
};
|
||
|
||
const handleNbDragStart=(e,id)=>{setDragNbId(id);e.dataTransfer.effectAllowed="move";};
|
||
const handleNbDragOver=(e,id)=>{e.preventDefault();if(dragNbId&&dragNbId!==id)setDragOverNbId(id);};
|
||
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: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.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<div style={{display:"flex",alignItems:"center",justifyContent:"center",height:"100vh",background:dark?"#0d1117":"#f6f8fa",color:dark?"#e6edf3":"#1f2328",fontFamily:"'DM Sans',sans-serif",fontSize:16}}>Loading...</div>;
|
||
if(!user)return<AuthScreen onLogin={onLogin} dark={dark}/>;
|
||
const isTrashView=activeFolder==="trash";
|
||
|
||
/* ── Back button for mobile ── */
|
||
const backBtn=(label,onClick)=><button onClick={onClick} style={{display:"flex",alignItems:"center",gap:6,padding:"6px 10px",borderRadius:8,border:"none",background:"transparent",cursor:"pointer",color:T.accent,fontSize:13,fontWeight:600}}>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><polyline points="15 18 9 12 15 6"/></svg>{label}
|
||
</button>;
|
||
|
||
/* ── Sidebar ── */
|
||
const renderSidebar=()=>(
|
||
<div style={{position:isMobile?"fixed":"relative",zIndex:isMobile?900:1,width:isMobile?(sidebarOpen?"100vw":0):(sidebarOpen?260:0),minWidth:isMobile?0:(sidebarOpen?260:0),height:isMobile?"100vh":"auto",background:T.sidebar,borderRight:isMobile?"none":`1px solid ${T.border}`,display:"flex",flexDirection:"column",overflow:"hidden",transition:"all .25s cubic-bezier(.4,0,.2,1)",fontFamily:"'DM Sans',sans-serif"}}>
|
||
{isMobile&&sidebarOpen&&<div onClick={()=>setSidebarOpen(false)} style={{position:"absolute",inset:0,background:"rgba(0,0,0,0.4)",zIndex:-1}}/>}
|
||
<div style={{width:isMobile?"85vw":260,maxWidth:320,height:"100%",background:T.sidebar,display:"flex",flexDirection:"column",position:"relative",zIndex:1}}>
|
||
<div style={{padding:"20px 18px 12px",display:"flex",alignItems:"center",justifyContent:"space-between"}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:10}}>
|
||
<div style={{width:32,height:32,borderRadius:10,background:`linear-gradient(135deg,${T.accent},#a855f7)`,display:"flex",alignItems:"center",justifyContent:"center",fontSize:16}}>✏️</div>
|
||
<span style={{fontWeight:700,fontSize:17,color:T.text,letterSpacing:"-0.02em"}}>InkNote</span>
|
||
</div>
|
||
<div style={{display:"flex",gap:4}}>
|
||
<button onClick={()=>setDark(!dark)} style={{display:"flex",alignItems:"center",justifyContent:"center",width:32,height:32,borderRadius:8,border:"none",background:"transparent",cursor:"pointer",color:T.muted}}>{dark?I.sun:I.moon}</button>
|
||
<button onClick={()=>setShowAccount(true)} title="Account" style={{display:"flex",alignItems:"center",justifyContent:"center",width:32,height:32,borderRadius:8,border:"none",background:"transparent",cursor:"pointer",color:T.muted}}>{I.user}</button>
|
||
</div>
|
||
</div>
|
||
<div style={{padding:"0 14px 12px"}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:8,padding:"8px 12px",borderRadius:10,background:T.inputBg,border:`1px solid ${T.borderLight}`}}>
|
||
<span style={{color:T.faint}}>{I.search}</span>
|
||
<input type="text" value={searchQuery} onChange={e=>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"}}/>
|
||
</div>
|
||
</div>
|
||
<div style={{padding:"0 10px"}}>
|
||
<button onClick={()=>{setActiveFolder("dashboard");setActiveNote(null);if(isMobile){setSidebarOpen(false);setMobileView("list");}}} style={{display:"flex",alignItems:"center",gap:10,width:"100%",padding:"9px 12px",borderRadius:10,border:"none",cursor:"pointer",fontSize:13,fontWeight:600,textAlign:"left",background:activeFolder==="dashboard"?T.sidebarActive:"transparent",color:activeFolder==="dashboard"?T.accentText:T.textSec}}><span style={{fontSize:15}}>📚</span><span>Dashboard</span></button>
|
||
</div>
|
||
<div style={{padding:"6px 10px 0"}}>
|
||
{DEFAULT_FOLDERS.map(f=>(
|
||
<button key={f.id} onClick={()=>{setActiveFolder(f.id);setActiveNote(null);if(isMobile){setSidebarOpen(false);setMobileView("list");}}} style={{display:"flex",alignItems:"center",gap:10,width:"100%",padding:"8px 12px",borderRadius:10,border:"none",cursor:"pointer",fontSize:13,fontWeight:500,textAlign:"left",background:activeFolder===f.id?T.sidebarActive:"transparent",color:activeFolder===f.id?T.accentText:T.textSec}}>
|
||
<span style={{fontSize:14}}>{f.icon}</span><span>{f.name}</span>
|
||
{f.id==="all"&&<span style={{marginLeft:"auto",fontSize:11,color:T.faint,background:T.accentSoft,padding:"2px 7px",borderRadius:10}}>{activeNotes.length}</span>}
|
||
{f.id==="trash"&&trashedNotes.length>0&&<span style={{marginLeft:"auto",fontSize:11,color:T.danger,background:T.danger+"15",padding:"2px 7px",borderRadius:10}}>{trashedNotes.length}</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div style={{padding:"14px 10px 4px"}}>
|
||
<div style={{display:"flex",alignItems:"center",justifyContent:"space-between",padding:"0 12px",marginBottom:6}}>
|
||
<span style={{fontSize:11,fontWeight:600,color:T.faint,textTransform:"uppercase",letterSpacing:"0.08em"}}>Notebooks</span>
|
||
<button onClick={()=>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}</button>
|
||
</div>
|
||
{notebooks.map(nb=>(
|
||
<div key={nb.id} draggable={!isMobile} onDragStart={e=>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"}}>
|
||
{!isMobile&&<span style={{padding:"0 2px 0 6px",color:T.faint,cursor:"grab",display:"flex",alignItems:"center",opacity:0.5}}>{I.grip}</span>}
|
||
<button onClick={()=>{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}}>
|
||
<div style={{width:20,height:14,borderRadius:3,background:COVERS[nb.cover%COVERS.length].bg,flexShrink:0,boxShadow:"0 1px 3px rgba(0,0,0,0.2)"}}/>
|
||
<span>{nb.name}</span>
|
||
<span style={{marginLeft:"auto",fontSize:11,color:T.faint}}>{activeNotes.filter(n=>n.folderId===nb.id).length}</span>
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div style={{marginTop:"auto",padding:"12px 14px",borderTop:`1px solid ${T.borderLight}`}}>
|
||
{installPrompt&&<button onClick={handleInstall} style={{display:"flex",alignItems:"center",gap:8,width:"100%",padding:"8px 12px",borderRadius:10,border:`1px solid ${T.accent}44`,background:T.accentSoft,cursor:"pointer",color:T.accent,fontSize:12,fontWeight:600,marginBottom:8}}>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Install App</button>}
|
||
<div style={{display:"flex",alignItems:"center",gap:6,color:T.muted,fontSize:12}}>
|
||
<span style={{color:syncStatus==="synced"?T.success:syncStatus==="syncing"?T.warning:syncStatus==="offline"?"#8b949e":syncStatus==="pending"?T.warning:T.danger}}>●</span>
|
||
<span>{syncStatus==="synced"?"All synced":syncStatus==="syncing"?"Syncing...":syncStatus==="offline"?"Offline mode":syncStatus==="pending"?"Changes pending":"Sync error"}</span>
|
||
{!sync.isOnline()&&<span style={{fontSize:10,padding:"2px 6px",borderRadius:6,background:T.faint+"22",color:T.faint,marginLeft:"auto"}}>offline</span>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
/* ── Dashboard ── */
|
||
const renderDashboard=()=>(
|
||
<div style={{flex:1,display:"flex",flexDirection:"column",background:T.dashBg,fontFamily:"'DM Sans',sans-serif",overflow:"auto"}}>
|
||
<div style={{padding:isMobile?"20px 20px 16px":"28px 36px 20px",display:"flex",alignItems:"center",justifyContent:"space-between"}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:10}}>
|
||
<button onClick={()=>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}</button>
|
||
<h1 style={{margin:0,fontSize:isMobile?20:24,fontWeight:700,color:T.text,fontFamily:"'Newsreader',Georgia,serif"}}>My Notebooks</h1>
|
||
</div>
|
||
</div>
|
||
<div style={{padding:isMobile?"0 16px 24px":"0 36px 32px",display:"grid",gridTemplateColumns:isMobile?"repeat(auto-fill,minmax(140px,1fr))":"repeat(auto-fill,minmax(180px,1fr))",gap:isMobile?"20px 16px":"32px 28px",justifyItems:"center"}}>
|
||
{notebooks.map(nb=>(
|
||
<NotebookCover key={nb.id} notebook={nb} noteCount={activeNotes.filter(n=>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={!isMobile}
|
||
onDragStart={e=>handleNbDragStart(e,nb.id)} onDragEnd={handleNbDragEnd} onDragOver={e=>handleNbDragOver(e,nb.id)} onDrop={e=>handleNbDrop(e,nb.id)}/>
|
||
))}
|
||
<button onClick={()=>setShowNewNb(true)} style={{display:"flex",flexDirection:"column",alignItems:"center",gap:10,background:"none",border:"none",cursor:"pointer"}}>
|
||
<div style={{width:isMobile?130:156,height:isMobile?175:210,borderRadius:12,border:`2px dashed ${T.border}`,display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",gap:8,color:T.faint}}>
|
||
<div style={{width:40,height:40,borderRadius:"50%",background:T.accentSoft,display:"flex",alignItems:"center",justifyContent:"center",color:T.accent}}>{I.plus}</div>
|
||
<span style={{fontSize:12,fontWeight:500}}>Add Notebook</span>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<div style={{padding:isMobile?"0 16px 24px":"0 36px 36px"}}>
|
||
<h2 style={{fontSize:16,fontWeight:600,color:T.text,marginBottom:14,fontFamily:"'Newsreader',Georgia,serif"}}>Recent Notes</h2>
|
||
<div style={{display:"grid",gridTemplateColumns:isMobile?"1fr":"repeat(auto-fill,minmax(260px,1fr))",gap:12}}>
|
||
{activeNotes.slice(0,6).map(note=>{const nb=notebooks.find(b=>b.id===note.folderId);return(
|
||
<button key={note.id} onClick={()=>{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"}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:6,width:"100%"}}>
|
||
{nb&&<div style={{width:14,height:10,borderRadius:2,background:COVERS[nb.cover%COVERS.length].bg,flexShrink:0}}/>}
|
||
<span style={{fontSize:13,fontWeight:600,color:T.text,flex:1,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{note.title}</span>
|
||
{note.starred&&<span style={{color:"#f0b429",fontSize:11}}>⭐</span>}
|
||
</div>
|
||
<div style={{display:"flex",alignItems:"center",gap:6,marginTop:6}}>
|
||
<span style={{fontSize:11,color:T.faint}}>{fmtDate(note.updatedAt)}</span>
|
||
{nb&&<span style={{fontSize:10,color:T.faint,background:T.accentSoft,padding:"1px 6px",borderRadius:6}}>{nb.name}</span>}
|
||
</div>
|
||
</button>
|
||
);})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
/* ── 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(
|
||
<div style={{width:isMobile?"100%":300,minWidth:isMobile?0:300,flex:isMobile?1:undefined,borderRight:isMobile?"none":`1px solid ${T.border}`,display:"flex",flexDirection:"column",background:T.bg,fontFamily:"'DM Sans',sans-serif"}}>
|
||
<div style={{padding:"14px 16px 10px",display:"flex",alignItems:"center",justifyContent:"space-between",borderBottom:`1px solid ${T.borderLight}`}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:8}}>
|
||
<button onClick={()=>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}</button>
|
||
<h2 style={{margin:0,fontSize:16,fontWeight:600,color:T.text,fontFamily:"'Newsreader',Georgia,serif"}}>{fname}</h2>
|
||
</div>
|
||
<div style={{display:"flex",gap:4}}>
|
||
{!isTrashView&&<button onClick={createNote} title="New Note" style={{display:"flex",alignItems:"center",justifyContent:"center",width:32,height:32,borderRadius:10,border:"none",background:T.accent,cursor:"pointer",color:"#fff",boxShadow:`0 2px 8px ${T.accent}44`}}>{I.plus}</button>}
|
||
{isTrashView&&trashedNotes.length>0&&<button onClick={emptyTrash} title="Empty Trash" style={{display:"flex",alignItems:"center",justifyContent:"center",padding:"6px 12px",borderRadius:10,border:"none",background:T.danger,cursor:"pointer",color:"#fff",fontSize:12,fontWeight:600}}>Empty</button>}
|
||
</div>
|
||
</div>
|
||
<div style={{flex:1,overflowY:"auto",padding:6,WebkitOverflowScrolling:"touch"}}>
|
||
{filteredNotes.length===0?(
|
||
<div style={{display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",height:"100%",color:T.faint,gap:10,padding:30}}>
|
||
<div style={{fontSize:36}}>{isTrashView?"🗑️":"📝"}</div><div style={{fontSize:13,textAlign:"center"}}>{isTrashView?"Trash is empty":"No notes yet"}</div>
|
||
{!isTrashView&&<button onClick={createNote} style={{display:"flex",alignItems:"center",gap:6,padding:"10px 20px",borderRadius:12,border:"none",background:T.accent,color:"#fff",cursor:"pointer",fontSize:13,fontWeight:600,marginTop:8}}>{I.plus} New Note</button>}
|
||
</div>
|
||
):filteredNotes.map(note=>(
|
||
<button key={note.id} onClick={()=>{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"}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:7,marginBottom:3,width:"100%"}}>
|
||
<span style={{fontSize:13}}>{note.trashed?"🗑️":"📝"}</span>
|
||
<span style={{fontSize:13,fontWeight:600,color:T.text,flex:1,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{note.title}</span>
|
||
{note.starred&&!note.trashed&&<span style={{color:"#f0b429",fontSize:11}}>⭐</span>}
|
||
</div>
|
||
<div style={{fontSize:10,color:T.faint,marginTop:3}}>{fmtDate(note.updatedAt)}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/* ── Editor ── */
|
||
const renderEditor=()=>{
|
||
// On mobile, hide editor when list is shown
|
||
if(isMobile&&mobileView!=="editor")return null;
|
||
if(!currentNoteSafe)return isMobile?null:(
|
||
<div style={{flex:1,display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",background:T.bg,color:T.faint,fontFamily:"'DM Sans',sans-serif",gap:14}}>
|
||
<div style={{width:72,height:72,borderRadius:20,background:T.accentSoft,display:"flex",alignItems:"center",justifyContent:"center",fontSize:32}}>✏️</div>
|
||
<div style={{fontSize:15,fontWeight:500,color:T.muted}}>Select a note to edit</div>
|
||
{!isTrashView&&<button onClick={createNote} style={{display:"flex",alignItems:"center",gap:8,padding:"10px 22px",borderRadius:12,border:"none",background:T.accent,color:"#fff",cursor:"pointer",fontSize:13,fontWeight:600,marginTop:6}}>{I.plus} New Note</button>}
|
||
</div>
|
||
);
|
||
if(isTrashView&¤tNoteMeta)return(
|
||
<div style={{flex:1,display:"flex",flexDirection:"column",background:T.bg,fontFamily:"'DM Sans',sans-serif"}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:8,padding:"12px 16px",borderBottom:`1px solid ${T.borderLight}`}}>
|
||
{isMobile&&backBtn("Back",()=>{setActiveNote(null);setMobileView("list");})}
|
||
<span style={{fontSize:17,fontWeight:600,color:T.text,fontFamily:"'Newsreader',Georgia,serif",flex:1,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{currentNoteMeta.title}</span>
|
||
<button onClick={()=>restoreNote(currentNoteMeta.id)} style={{padding:"6px 14px",borderRadius:8,border:"none",background:T.accent,color:"#fff",cursor:"pointer",fontSize:12,fontWeight:600}}>Restore</button>
|
||
<button onClick={()=>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}</button>
|
||
</div>
|
||
<div style={{flex:1,padding:"20px 24px",color:T.muted,fontStyle:"italic",fontSize:14}}>Trashed notes are read-only. Restore to edit.</div>
|
||
</div>
|
||
);
|
||
return(
|
||
<div style={{flex:1,display:"flex",flexDirection:"column",background:T.bg,fontFamily:"'DM Sans',sans-serif"}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:8,padding:"8px 16px",borderBottom:`1px solid ${T.borderLight}`}}>
|
||
{isMobile&&backBtn("Back",()=>{setActiveNote(null);setMobileView("list");})}
|
||
<input type="text" value={currentNoteSafe.title} onChange={e=>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}}/>
|
||
<button onClick={()=>toggleStar(currentNoteSafe.id)} style={{display:"flex",alignItems:"center",justifyContent:"center",width:30,height:30,borderRadius:8,border:"none",background:"transparent",cursor:"pointer",color:currentNoteSafe.starred?"#f0b429":T.faint}}>{currentNoteSafe.starred?I.starFill:I.star}</button>
|
||
<button onClick={()=>trashNoteAction(currentNoteSafe.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}</button>
|
||
</div>
|
||
<div style={{flex:1,overflow:"hidden"}}>
|
||
<NoteEditor key={currentNoteSafe.id} note={currentNoteSafe} onUpdate={u=>updateNote(currentNoteSafe.id,u)} dark={dark}/>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return(
|
||
<div style={{display:"flex",height:"100vh",width:"100vw",overflow:"hidden",background:T.bg,color:T.text,fontFamily:"'DM Sans',sans-serif"}}>
|
||
<style>{`
|
||
@import url('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');
|
||
*{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}}
|
||
[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}
|
||
}
|
||
`}</style>
|
||
{renderSidebar()}
|
||
{activeFolder==="dashboard"?renderDashboard():(<>{renderNoteList()}{renderEditor()}</>)}
|
||
{showNewNb&&<CreateNotebookModal onClose={()=>setShowNewNb(false)} onCreate={createNb} dark={dark}/>}
|
||
{editingNb&&<EditNotebookModal notebook={editingNb} onClose={()=>setEditingNb(null)} onSave={u=>updateNb(editingNb.id,u)} dark={dark}/>}
|
||
{showAccount&&<AccountMenu user={user} onClose={()=>setShowAccount(false)} onLogout={()=>{setShowAccount(false);onLogout();}} onUserUpdate={u=>setUser(u)} dark={dark}/>}
|
||
{confirmDialog&&<ConfirmDialog title={confirmDialog.title} message={confirmDialog.message} confirmLabel={confirmDialog.confirmLabel} onConfirm={confirmDialog.onConfirm} onCancel={()=>setConfirmDialog(null)} dark={dark}/>}
|
||
{ctxMenu&&<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={()=>setCtxMenu(null)} dark={dark}/>}
|
||
</div>
|
||
);
|
||
}
|