added docker files and compose
This commit is contained in:
1 parent
4709096ab3
commit
443184a6fa
5 files changed
+694
-1150
No files matched your search
@@ -0,0 +1,659 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
|
||||
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: "pdfs", name: "PDFs", 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>,
|
||||
palette:<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><circle cx="13.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="10.5" r="1.5"/><circle cx="8.5" cy="7.5" r="1.5"/><circle cx="6.5" cy="12" r="1.5"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></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>,
|
||||
};
|
||||
|
||||
|
||||
/* ═══════ CONFIRM DIALOG ═══════ */
|
||||
function ConfirmDialog({ title, message, confirmLabel="Delete", onConfirm, onCancel, dark }) {
|
||||
const T = useMemo(()=>({ bg:dark?"#161b22":"#fff", border:dark?"#30363d":"#d0d7de", text:dark?"#e6edf3":"#1f2328", muted:dark?"#8b949e":"#656d76", danger:dark?"#f85149":"#cf222e" }),[dark]);
|
||||
return (
|
||||
<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,fontFamily:"'DM Sans',sans-serif"}}>Cancel</button>
|
||||
<button onClick={onConfirm} style={{padding:"8px 20px",borderRadius:10,border:"none",background:T.danger,color:"#fff",cursor:"pointer",fontSize:13,fontWeight:500,fontFamily:"'DM Sans',sans-serif"}}>{confirmLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════ CONTEXT MENU ═══════ */
|
||||
function ContextMenu({ x, y, items, onClose, dark }) {
|
||||
const T = useMemo(()=>({ bg:dark?"#1c2128":"#fff", border:dark?"#30363d":"#d0d7de", text:dark?"#e6edf3":"#1f2328", hover:dark?"#262c36":"#f3f4f6", danger:dark?"#f85149":"#cf222e" }),[dark]);
|
||||
useEffect(()=>{
|
||||
const h=()=>onClose();
|
||||
window.addEventListener("click",h); window.addEventListener("contextmenu",h);
|
||||
return ()=>{window.removeEventListener("click",h);window.removeEventListener("contextmenu",h);};
|
||||
},[onClose]);
|
||||
return (
|
||||
<div style={{position:"fixed",left:x,top:y,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:180,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"}}/>
|
||||
):(
|
||||
<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",transition:"background .1s"}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════ EDIT NOTEBOOK MODAL ═══════ */
|
||||
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",fontFamily:"'DM Sans',sans-serif"}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
UNIFIED NOTE EDITOR — single toolbar, canvas area is
|
||||
always the same size so drawings never shift
|
||||
═══════════════════════════════════════════════════════ */
|
||||
function NoteEditor({ note, onUpdate, dark }) {
|
||||
const [mode, setMode] = useState("text");
|
||||
const editorRef = useRef(null);
|
||||
const canvasRef = useRef(null);
|
||||
const ctxRef = useRef(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
|
||||
// Draw tool state (lifted out of canvas)
|
||||
const [tool, setTool] = useState("pen");
|
||||
const [penColor, setPenColor] = useState(dark ? "#e6edf3" : "#1a1a2e");
|
||||
const [penSize, setPenSize] = useState(3);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [hIdx, setHIdx] = useState(-1);
|
||||
const [showColors, setShowColors] = useState(false);
|
||||
const [showSizes, setShowSizes] = useState(false);
|
||||
|
||||
const T = useMemo(() => ({
|
||||
bg: dark?"#0d1117":"#ffffff", toolbar: dark?"#161b22":"#f6f8fa",
|
||||
border: dark?"#30363d":"#d0d7de", text: dark?"#e6edf3":"#1f2328",
|
||||
muted: dark?"#8b949e":"#656d76", active: dark?"#388bfd":"#0969da",
|
||||
faint: dark?"#484f58":"#afb8c1",
|
||||
}), [dark]);
|
||||
|
||||
// Init text editor
|
||||
useEffect(() => {
|
||||
if (editorRef.current && editorRef.current.innerHTML !== (note.content||""))
|
||||
editorRef.current.innerHTML = note.content || "";
|
||||
}, [note.id]);
|
||||
|
||||
// Init canvas when switching to draw mode
|
||||
useEffect(() => {
|
||||
if (mode !== "draw") return;
|
||||
const c = canvasRef.current; if (!c) return;
|
||||
const p = c.parentElement;
|
||||
c.width = p.clientWidth; c.height = p.clientHeight;
|
||||
const ctx = c.getContext("2d");
|
||||
ctx.lineCap = "round"; ctx.lineJoin = "round";
|
||||
ctxRef.current = ctx;
|
||||
if (note.drawingData) {
|
||||
const img = new Image();
|
||||
img.onload = () => { ctx.drawImage(img, 0, 0); snap(); };
|
||||
img.src = note.drawingData;
|
||||
} else snap();
|
||||
}, [mode]);
|
||||
|
||||
// Canvas helpers
|
||||
const snap = () => {
|
||||
if (!canvasRef.current) return;
|
||||
const d = canvasRef.current.toDataURL();
|
||||
setHistory(p => { const n = [...p.slice(0, (hIdx >= 0 ? hIdx + 1 : p.length))]; n.push(d); return n; });
|
||||
setHIdx(p => p + 1);
|
||||
};
|
||||
const restoreIdx = (idx) => {
|
||||
setHIdx(idx);
|
||||
const img = new Image();
|
||||
img.onload = () => { ctxRef.current.clearRect(0,0,canvasRef.current.width,canvasRef.current.height); ctxRef.current.drawImage(img,0,0); };
|
||||
img.src = history[idx];
|
||||
};
|
||||
const undo = () => { if (hIdx > 0) restoreIdx(hIdx - 1); };
|
||||
const redo = () => { if (hIdx < history.length - 1) restoreIdx(hIdx + 1); };
|
||||
const clearCanvas = () => { ctxRef.current?.clearRect(0,0,canvasRef.current.width,canvasRef.current.height); snap(); onUpdate({drawingData: canvasRef.current.toDataURL()}); };
|
||||
|
||||
const getPos = (e) => {
|
||||
const c = canvasRef.current, r = c.getBoundingClientRect();
|
||||
const sx = c.width/r.width, sy = c.height/r.height;
|
||||
const t = e.touches ? e.touches[0] : e;
|
||||
return { x: (t.clientX-r.left)*sx, y: (t.clientY-r.top)*sy };
|
||||
};
|
||||
const startDraw = (e) => {
|
||||
e.preventDefault(); const p = getPos(e); setIsDrawing(true);
|
||||
const ctx = ctxRef.current; ctx.beginPath(); ctx.moveTo(p.x,p.y);
|
||||
if (tool==="eraser"){ctx.globalCompositeOperation="destination-out";ctx.lineWidth=penSize*5;}
|
||||
else if(tool==="highlighter"){ctx.globalCompositeOperation="source-over";ctx.strokeStyle=penColor+"44";ctx.lineWidth=penSize*6;}
|
||||
else{ctx.globalCompositeOperation="source-over";ctx.strokeStyle=penColor;ctx.lineWidth=penSize;}
|
||||
};
|
||||
const doDraw = (e) => {if(!isDrawing)return;e.preventDefault();const p=getPos(e);ctxRef.current.lineTo(p.x,p.y);ctxRef.current.stroke();};
|
||||
const stopDraw = (e) => {
|
||||
if(!isDrawing)return;e?.preventDefault();setIsDrawing(false);
|
||||
ctxRef.current.closePath();ctxRef.current.globalCompositeOperation="source-over";
|
||||
snap(); onUpdate({drawingData:canvasRef.current.toDataURL()});
|
||||
};
|
||||
|
||||
const exec = (cmd,val=null)=>{document.execCommand(cmd,false,val);editorRef.current?.focus();onUpdate({content:editorRef.current.innerHTML});};
|
||||
|
||||
const tbtn = (active)=>({
|
||||
display:"flex",alignItems:"center",justifyContent:"center",width:30,height:30,borderRadius:6,
|
||||
border:"none",cursor:"pointer",background:active?T.active+"22":"transparent",color:active?T.active:T.text,transition:"all .12s",flexShrink:0,
|
||||
});
|
||||
|
||||
const modeBtn = (m,icon,label) => (
|
||||
<button onClick={()=>{setMode(m);setShowColors(false);setShowSizes(false);}} 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",transition:"all .15s",fontFamily:"'DM Sans',sans-serif",flexShrink:0,
|
||||
}}>{icon}<span>{label}</span></button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{display:"flex",flexDirection:"column",height:"100%",background:T.bg}}>
|
||||
{/* ── SINGLE UNIFIED TOOLBAR ── */}
|
||||
<div style={{display:"flex",alignItems:"center",gap:6,padding:"7px 14px",borderBottom:`1px solid ${T.border}`,background:T.toolbar,flexWrap:"wrap",minHeight:46}}>
|
||||
{modeBtn("text",I.text,"Write")}
|
||||
{modeBtn("draw",I.draw,"Draw")}
|
||||
<div style={{width:1,height:22,background:T.border,margin:"0 2px",flexShrink:0}}/>
|
||||
|
||||
{mode==="text" ? (<>
|
||||
<button style={tbtn(false)} onClick={()=>exec("bold")}>{I.bold}</button>
|
||||
<button style={tbtn(false)} onClick={()=>exec("italic")}>{I.italic}</button>
|
||||
<button style={tbtn(false)} onClick={()=>exec("underline")}>{I.underline}</button>
|
||||
<div style={{width:1,height:22,background:T.border,margin:"0 2px"}}/>
|
||||
<button style={tbtn(false)} onClick={()=>exec("insertUnorderedList")}>{I.list}</button>
|
||||
<button style={tbtn(false)} onClick={()=>exec("insertOrderedList")}>{I.listOrd}</button>
|
||||
<button style={tbtn(false)} onClick={()=>{exec("insertHTML",`<div style="display:flex;align-items:center;gap:8px;margin:4px 0"><input type="checkbox" style="width:16px;height:16px;accent-color:${T.active}"/> <span>Task</span></div>`);}}>{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,fontFamily:"'DM Sans',sans-serif"}}>
|
||||
<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={tbtn(tool==="pen")} onClick={()=>setTool("pen")}>{I.pen}</button>
|
||||
<button style={tbtn(tool==="highlighter")} onClick={()=>setTool("highlighter")}>{I.highlight}</button>
|
||||
<button style={tbtn(tool==="eraser")} onClick={()=>setTool("eraser")}>{I.eraser}</button>
|
||||
<div style={{width:1,height:22,background:T.border,margin:"0 2px"}}/>
|
||||
<button style={tbtn(false)} onClick={undo} disabled={hIdx<=0}>{I.undo}</button>
|
||||
<button style={tbtn(false)} onClick={redo} disabled={hIdx>=history.length-1}>{I.redo}</button>
|
||||
<div style={{width:1,height:22,background:T.border,margin:"0 2px"}}/>
|
||||
{/* Color picker */}
|
||||
<div style={{position:"relative"}}>
|
||||
<button style={tbtn(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>
|
||||
{/* Size picker */}
|
||||
<div style={{position:"relative"}}>
|
||||
<button style={tbtn(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={{...tbtn(false),color:"#e5534b"}} onClick={clearCanvas} title="Clear drawing">{I.trash}</button>
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{/* ── CONTENT AREA — always same size regardless of mode ── */}
|
||||
<div style={{flex:1,position:"relative",overflow:"hidden"}}>
|
||||
{/* Text layer — always rendered */}
|
||||
<div ref={editorRef} contentEditable={mode==="text"} suppressContentEditableWarning
|
||||
onInput={()=>onUpdate({content:editorRef.current.innerHTML})}
|
||||
style={{
|
||||
position:"absolute",inset:0,padding:"20px 24px",outline:"none",fontSize:15,lineHeight:1.7,
|
||||
color:T.text,overflowY:"auto",fontFamily:"'Newsreader','Georgia',serif",
|
||||
zIndex:1, pointerEvents:mode==="text"?"auto":"none",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Drawing image overlay — visible in WRITE mode when drawings exist */}
|
||||
{mode==="text" && note.drawingData && (
|
||||
<img src={note.drawingData} alt="" style={{
|
||||
position:"absolute",inset:0,width:"100%",height:"100%",
|
||||
pointerEvents:"none",zIndex:2,objectFit:"fill",
|
||||
}}/>
|
||||
)}
|
||||
|
||||
{/* Live canvas — visible in DRAW mode */}
|
||||
{mode==="draw" && (
|
||||
<canvas ref={canvasRef}
|
||||
onMouseDown={startDraw} onMouseMove={doDraw} onMouseUp={stopDraw} onMouseLeave={stopDraw}
|
||||
onTouchStart={startDraw} onTouchMove={doDraw} onTouchEnd={stopDraw}
|
||||
style={{position:"absolute",inset:0,width:"100%",height:"100%",touchAction:"none",cursor:"crosshair",zIndex:3,background:"transparent"}}
|
||||
/>
|
||||
)}
|
||||
</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)":"translateY(0) scale(1)",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}}>
|
||||
{Array.from({length:6}).map((_,i)=><div key={i} style={{position:"absolute",top:`${12+i*14}%`,left:0,right:0,height:1,background:"rgba(0,0,0,0.06)"}}/>)}
|
||||
</div>
|
||||
<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)",letterSpacing:"0.04em",lineHeight:1.2,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:16,left:12,right:12,height:1,background:cover.accent+"33"}}/>
|
||||
<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",fontFamily:"'DM Sans',sans-serif"}}>{notebook.name}</div>
|
||||
<div style={{fontSize:11,opacity:0.5,marginTop:1,fontFamily:"'DM Sans',sans-serif"}}>{noteCount} {noteCount===1?"note":"notes"}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════ CREATE NOTEBOOK MODAL ═══════ */
|
||||
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",fontFamily:"'DM Sans',sans-serif"}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ═══════════════════════ MAIN APP ═══════════════════════ */
|
||||
export default function NoteApp() {
|
||||
const [dark,setDark]=useState(true);
|
||||
const [sidebarOpen,setSidebarOpen]=useState(true);
|
||||
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 [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:"<h2>Welcome to InkNote ✨</h2><p>Each note supports <b>both writing and drawing</b> — switch modes with the tabs. Drawings stay visible while you write!</p><ul><li>Rich text with formatting, lists, checkboxes</li><li>Freehand drawing layered on top</li></ul>",drawingData:null,starred:true,createdAt:"2025-06-10T10:00:00Z",updatedAt:now()},
|
||||
{id:"n2",title:"Sprint Planning",folderId:"nb2",content:"<h3>Q3 Roadmap</h3><ol><li>Sync engine</li><li>Mobile gestures</li><li>PDF export</li></ol><p>Review: <b>Friday 3pm</b></p>",drawingData:null,starred:false,createdAt:"2025-06-12T14:00:00Z",updatedAt:now()},
|
||||
{id:"n3",title:"UI Wireframes",folderId:"nb3",content:"<p>Dashboard layout concepts...</p>",drawingData:null,starred:false,createdAt:"2025-06-11T08:30:00Z",updatedAt:now()},
|
||||
{id:"n4",title:"Data Structures",folderId:"nb4",content:"<h3>Binary Trees</h3><p>Each node has at most two children. Operations: insert, delete, search, traversal.</p>",drawingData:null,starred:true,createdAt:"2025-06-09T11:00:00Z",updatedAt:now()},
|
||||
]);
|
||||
const [pdfs,setPdfs]=useState([]);
|
||||
|
||||
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]);
|
||||
|
||||
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));}
|
||||
return list.sort((a,b)=>new Date(b.updatedAt)-new Date(a.updatedAt));
|
||||
},[notes,pdfs,activeFolder,searchQuery]);
|
||||
|
||||
const currentNote=activeNote?notes.find(n=>n.id===activeNote)||pdfs.find(p=>p.id===activeNote):null;
|
||||
|
||||
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();
|
||||
};
|
||||
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 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 sim=()=>{setSyncStatus("syncing");setTimeout(()=>setSyncStatus("synced"),1200);};
|
||||
|
||||
// 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;});
|
||||
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)}]});
|
||||
};
|
||||
|
||||
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);},[]);
|
||||
|
||||
const renderSidebar=()=>(
|
||||
<div style={{width:sidebarOpen?260:0,minWidth:sidebarOpen?260:0,background:T.sidebar,borderRight:`1px solid ${T.border}`,display:"flex",flexDirection:"column",overflow:"hidden",transition:"all .25s cubic-bezier(.4,0,.2,1)",fontFamily:"'DM Sans',sans-serif"}}>
|
||||
<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>
|
||||
<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>
|
||||
</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);}} style={{display:"flex",alignItems:"center",gap:10,width:"100%",padding:"9px 12px",borderRadius:10,border:"none",cursor:"pointer",fontSize:13,fontWeight:600,textAlign:"left",fontFamily:"'DM Sans',sans-serif",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);}} style={{display:"flex",alignItems:"center",gap:10,width:"100%",padding:"8px 12px",borderRadius:10,border:"none",cursor:"pointer",fontSize:13,fontWeight:500,textAlign:"left",fontFamily:"'DM Sans',sans-serif",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}}>{notes.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 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"}}>
|
||||
<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);}} 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}}>
|
||||
<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}}>{notes.filter(n=>n.folderId===nb.id).length}</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{marginTop:"auto",padding:"12px 14px",borderTop:`1px solid ${T.borderLight}`}}>
|
||||
<div style={{display:"flex",alignItems:"center",gap:6,color:T.muted,fontSize:12}}>
|
||||
<span style={{color:syncStatus==="synced"?T.success:syncStatus==="syncing"?T.warning:T.danger}}>●</span>
|
||||
<span>{syncStatus==="synced"?"All changes synced":syncStatus==="syncing"?"Syncing...":"Offline"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDashboard=()=>(
|
||||
<div style={{flex:1,display:"flex",flexDirection:"column",background:T.dashBg,fontFamily:"'DM Sans',sans-serif",overflow:"auto"}}>
|
||||
<div style={{padding:"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:24,fontWeight:700,color:T.text,fontFamily:"'Newsreader',Georgia,serif"}}>My Notebooks</h1>
|
||||
</div>
|
||||
<button onClick={()=>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</button>
|
||||
</div>
|
||||
<div style={{padding:"0 36px 32px",display:"grid",gridTemplateColumns:"repeat(auto-fill,minmax(180px,1fr))",gap:"32px 28px",justifyItems:"center"}}>
|
||||
{notebooks.map(nb=>(
|
||||
<NotebookCover key={nb.id} notebook={nb} noteCount={notes.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
|
||||
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:156,height:210,borderRadius:12,border:`2px dashed ${T.border}`,display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",gap:8,color:T.faint,background:dark?"rgba(255,255,255,0.02)":"rgba(0,0,0,0.02)"}}>
|
||||
<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:"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:"repeat(auto-fill,minmax(260px,1fr))",gap:12}}>
|
||||
{notes.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);}}
|
||||
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"}}>
|
||||
<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>}
|
||||
{note.drawingData&&<span style={{fontSize:10,color:T.accent}}>🎨</span>}
|
||||
</div>
|
||||
<div style={{fontSize:12,color:T.muted,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{(note.content||"").replace(/<[^>]*>/g,"").slice(0,90)||"Empty note"}</div>
|
||||
<div style={{display:"flex",alignItems:"center",gap:6,marginTop:8}}>
|
||||
<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>
|
||||
);
|
||||
|
||||
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";})();
|
||||
return(
|
||||
<div style={{width:300,minWidth:300,borderRight:`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}}>
|
||||
<button onClick={createNote} 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>
|
||||
<button onClick={()=>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}}>📄</button>
|
||||
<input ref={fileRef} type="file" accept=".pdf" onChange={importPDF} style={{display:"none"}}/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{flex:1,overflowY:"auto",padding:6}}>
|
||||
{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}}>📝</div><div style={{fontSize:13}}>No notes yet</div></div>
|
||||
):filteredNotes.map(note=>(
|
||||
<button key={note.id} onClick={()=>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"}}>
|
||||
<div style={{display:"flex",alignItems:"center",gap:7,marginBottom:3,width:"100%"}}>
|
||||
<span style={{fontSize:13}}>📝</span>
|
||||
<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>}
|
||||
{note.drawingData&&<span style={{fontSize:10,color:T.accent}}>🎨</span>}
|
||||
</div>
|
||||
{note.content&&<div style={{fontSize:11,color:T.muted,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{note.content.replace(/<[^>]*>/g,"").slice(0,70)}</div>}
|
||||
<div style={{fontSize:10,color:T.faint,marginTop:5}}>{fmtDate(note.updatedAt)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);};
|
||||
|
||||
const renderEditor=()=>{
|
||||
if(!currentNote)return(
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
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}`}}>
|
||||
<input type="text" value={currentNote.title} onChange={e=>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"}}/>
|
||||
<button onClick={()=>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}
|
||||
</button>
|
||||
<button onClick={()=>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}</button>
|
||||
</div>
|
||||
<div style={{flex:1,overflow:"hidden"}}>
|
||||
<NoteEditor key={currentNote.id} note={currentNote} onUpdate={u=>updateNote(currentNote.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}
|
||||
`}</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}/>}
|
||||
{confirmDialog&&<ConfirmDialog title={confirmDialog.title} message={confirmDialog.message} 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user