diff --git a/apps/backend/app/api/routes/notes/api.py b/apps/backend/app/api/routes/notes/api.py index 71067f9c..22c8113c 100644 --- a/apps/backend/app/api/routes/notes/api.py +++ b/apps/backend/app/api/routes/notes/api.py @@ -19,6 +19,11 @@ async def list_notes( return await note_svc.list_notes_paginated(uid, skip=skip, limit=limit) +@router.get("/{note_id}", response_model=NoteOut, summary="Get a single note with full content") +async def get_note(note_id: str, uid: str = Depends(get_current_uid)) -> NoteOut: + return await note_svc.get_note(uid, note_id) + + @router.post("", response_model=NoteOut, summary="Create a note") async def create_note(body: NoteCreate, uid: str = Depends(get_current_uid)) -> NoteOut: return await note_svc.create_note(uid, body) diff --git a/apps/backend/app/api/routes/notes/schema.py b/apps/backend/app/api/routes/notes/schema.py index ccf070f1..2ac55c18 100644 --- a/apps/backend/app/api/routes/notes/schema.py +++ b/apps/backend/app/api/routes/notes/schema.py @@ -1,29 +1,80 @@ +import json from typing import Any, Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator + +_CONTENT_MAX_BYTES = 1_000_000 # 1 MB +_TITLE_MAX_LEN = 500 +_TAG_MAX_LEN = 100 +_TAGS_MAX_COUNT = 50 +_ICON_MAX_LEN = 10 + + +def _validate_content(value: Any) -> Any: + if value is None: + return value + try: + size = len(json.dumps(value, ensure_ascii=False).encode("utf-8")) + except (TypeError, ValueError): + raise ValueError("content must be JSON-serialisable") + if size > _CONTENT_MAX_BYTES: + raise ValueError(f"content exceeds maximum size of {_CONTENT_MAX_BYTES // 1000} KB") + return value class NoteCreate(BaseModel): model_config = ConfigDict(extra="ignore") - title: Optional[str] = Field(default=None, min_length=1) + title: Optional[str] = Field(default=None, min_length=1, max_length=_TITLE_MAX_LEN) content: Any = Field(default_factory=dict) - parentId: Optional[str] = None - icon: Optional[str] = None + parentId: Optional[str] = Field(default=None, max_length=128) + icon: Optional[str] = Field(default=None, max_length=_ICON_MAX_LEN) pinned: Optional[bool] = None tags: Optional[list[str]] = None + @field_validator("content", mode="before") + @classmethod + def validate_content_size(cls, v: Any) -> Any: + return _validate_content(v) + + @field_validator("tags", mode="before") + @classmethod + def validate_tags(cls, v: Any) -> Any: + if v is None: + return v + if not isinstance(v, list): + raise ValueError("tags must be a list") + if len(v) > _TAGS_MAX_COUNT: + raise ValueError(f"too many tags (max {_TAGS_MAX_COUNT})") + return [str(t)[:_TAG_MAX_LEN] for t in v] + class NoteUpdate(BaseModel): model_config = ConfigDict(extra="ignore") - title: Optional[str] = Field(default=None, min_length=1) + title: Optional[str] = Field(default=None, min_length=1, max_length=_TITLE_MAX_LEN) content: Optional[Any] = None - parentId: Optional[str] = None - icon: Optional[str] = None + parentId: Optional[str] = Field(default=None, max_length=128) + icon: Optional[str] = Field(default=None, max_length=_ICON_MAX_LEN) pinned: Optional[bool] = None tags: Optional[list[str]] = None + @field_validator("content", mode="before") + @classmethod + def validate_content_size(cls, v: Any) -> Any: + return _validate_content(v) + + @field_validator("tags", mode="before") + @classmethod + def validate_tags(cls, v: Any) -> Any: + if v is None: + return v + if not isinstance(v, list): + raise ValueError("tags must be a list") + if len(v) > _TAGS_MAX_COUNT: + raise ValueError(f"too many tags (max {_TAGS_MAX_COUNT})") + return [str(t)[:_TAG_MAX_LEN] for t in v] + class NoteOut(BaseModel): model_config = ConfigDict(extra="ignore") diff --git a/apps/backend/app/api/routes/notes/services.py b/apps/backend/app/api/routes/notes/services.py index d11ac3c6..bf2b5d93 100644 --- a/apps/backend/app/api/routes/notes/services.py +++ b/apps/backend/app/api/routes/notes/services.py @@ -42,10 +42,14 @@ def _to_iso(v: Any) -> str: ) +_LIST_PROJECTION = {"content": 0} + + async def list_notes(uid: str) -> list[NoteOut]: docs = await db_manager.find( NOTES, {"created_by": uid}, + projection=_LIST_PROJECTION, sort=[("createdAt", 1)], ) return [_doc_to_out(d) for d in docs] @@ -55,6 +59,7 @@ async def list_notes_paginated(uid: str, *, skip: int = 0, limit: int = 200) -> docs = await db_manager.find( NOTES, {"created_by": uid}, + projection=_LIST_PROJECTION, sort=[("createdAt", 1)], skip=max(0, skip), limit=max(1, limit), @@ -112,24 +117,26 @@ async def update_note(uid: str, note_id: str, body: NoteUpdate) -> NoteOut: async def _descendant_ids(uid: str, root_id: str) -> list[str]: - docs = await db_manager.find(NOTES, {"created_by": uid}, {"_id": 1, "parentId": 1}) - by_parent: dict[Optional[str], list[str]] = {} - for d in docs: - pid = d.get("parentId") - by_parent.setdefault(pid, []).append(str(d.get("_id"))) - - out: list[str] = [] - stack = [root_id] - visited: set[str] = set() - while stack: - nid = stack.pop() - if nid in visited: - continue - visited.add(nid) - out.append(nid) - for child in by_parent.get(nid, []): - stack.append(child) - return out + """BFS using targeted per-level queries instead of loading all user notes.""" + collected: list[str] = [root_id] + frontier: list[str] = [root_id] + visited: set[str] = {root_id} + + while frontier: + docs = await db_manager.find( + NOTES, + {"created_by": uid, "parentId": {"$in": frontier}}, + projection={"_id": 1}, + ) + frontier = [] + for d in docs: + nid = str(d.get("_id")) + if nid not in visited: + visited.add(nid) + collected.append(nid) + frontier.append(nid) + + return collected async def delete_note(uid: str, note_id: str, *, recursive: bool = True) -> None: diff --git a/apps/web/src/app/app/notes/context/NotesContext.tsx b/apps/web/src/app/app/notes/context/NotesContext.tsx index 81317dfe..45728103 100644 --- a/apps/web/src/app/app/notes/context/NotesContext.tsx +++ b/apps/web/src/app/app/notes/context/NotesContext.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { createContext, useContext, useEffect, useState, useCallback } from "react"; +import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from "react"; import { Note } from "../types/Note"; import { useAuthState } from "react-firebase-hooks/auth"; import { auth } from "@/database/firebase"; @@ -16,6 +16,7 @@ const NOTES_PAGE_SIZE = 500; interface NotesContextType { notes: Note[]; isLoading: boolean; + isContentLoading: boolean; activeNoteId: string | null; setActiveNoteId: (id: string | null) => void; focusMode: boolean; @@ -24,6 +25,8 @@ interface NotesContextType { updateNote: (id: string, updates: Partial) => Promise; deleteNote: (id: string) => Promise; pinNote: (id: string, pinned: boolean) => Promise; + duplicateNote: (id: string) => Promise; + moveNote: (id: string, newParentId: string | null) => Promise; } const NotesContext = createContext(undefined); @@ -33,8 +36,11 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { const [user] = useAuthState(auth); const [notes, setNotes] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isContentLoading, setIsContentLoading] = useState(false); const [activeNoteId, setActiveNoteId] = useState(null); const [focusMode, setFocusMode] = useState(false); + // Tracks which note IDs have full content loaded in state + const contentLoadedIds = useRef>(new Set()); const apiRequest = useCallback( async (method: string, path: string, body?: unknown): Promise => { @@ -88,10 +94,26 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { `/api/v1/notes?skip=${skip}&limit=${limit}` ), }); - // Keep stable ordering in case server ordering changes. + contentLoadedIds.current.clear(); setNotes([...allNotes].sort((a, b) => a.createdAt.localeCompare(b.createdAt))); }, [apiRequest, user]); + // Lazy-load full content when active note changes + useEffect(() => { + if (!activeNoteId || contentLoadedIds.current.has(activeNoteId)) return; + let cancelled = false; + setIsContentLoading(true); + apiRequest("GET", `/api/v1/notes/${activeNoteId}`) + .then((full) => { + if (cancelled) return; + contentLoadedIds.current.add(activeNoteId); + setNotes((prev) => prev.map((n) => (n.id === full.id ? full : n))); + }) + .catch(() => { /* note may have been deleted */ }) + .finally(() => { if (!cancelled) setIsContentLoading(false); }); + return () => { cancelled = true; }; + }, [activeNoteId, apiRequest]); + useEffect(() => { if (!user) { setNotes([]); @@ -116,9 +138,9 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { title: t("defaultTitle"), content: {}, parentId, - // Backend/UI uses `note.icon || "πŸ“„"` fallback. icon: undefined, }); + contentLoadedIds.current.add(created.id); setActiveNoteId(created.id); setNotes((prev) => [...prev, created].sort((a, b) => a.createdAt.localeCompare(b.createdAt))); return created.id; @@ -126,13 +148,16 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { const updateNote = useCallback(async (id: string, updates: Partial) => { if (!user) return; - const payload: Partial> = {}; + const payload: Partial> = {}; if (updates.title !== undefined) payload.title = updates.title; if (updates.content !== undefined) payload.content = updates.content; if (updates.parentId !== undefined) payload.parentId = updates.parentId; if (updates.icon !== undefined) payload.icon = updates.icon; + if (updates.pinned !== undefined) payload.pinned = updates.pinned; + if (updates.tags !== undefined) payload.tags = updates.tags; const updated = await apiRequest("PATCH", `/api/v1/notes/${id}`, payload); + if (updates.content !== undefined) contentLoadedIds.current.add(id); setNotes((prev) => prev.map((n) => (n.id === updated.id ? updated : n))); }, [apiRequest, user]); @@ -140,9 +165,38 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { await updateNote(id, { pinned }); }, [updateNote]); + const duplicateNote = useCallback(async (id: string) => { + const src = notes.find((n) => n.id === id); + if (!src) throw new Error("Note not found"); + // Fetch full content if not yet loaded + let content = src.content; + if (!contentLoadedIds.current.has(id)) { + const full = await apiRequest("GET", `/api/v1/notes/${id}`); + contentLoadedIds.current.add(id); + setNotes((prev) => prev.map((n) => (n.id === full.id ? full : n))); + content = full.content; + } + const created = await apiRequest("POST", "/api/v1/notes", { + title: `${src.title || t("defaultTitle")} (copy)`, + content, + parentId: src.parentId ?? null, + icon: src.icon, + tags: src.tags, + }); + contentLoadedIds.current.add(created.id); + setActiveNoteId(created.id); + setNotes((prev) => [...prev, created].sort((a, b) => a.createdAt.localeCompare(b.createdAt))); + return created.id; + }, [apiRequest, notes, t]); + + const moveNote = useCallback(async (id: string, newParentId: string | null) => { + await updateNote(id, { parentId: newParentId }); + }, [updateNote]); + const deleteNote = useCallback(async (id: string) => { if (!user) return; await apiRequest("DELETE", `/api/v1/notes/${id}?recursive=true`); + contentLoadedIds.current.delete(id); if (activeNoteId === id) { setActiveNoteId(null); } @@ -154,6 +208,7 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { value={{ notes, isLoading, + isContentLoading, activeNoteId, setActiveNoteId, focusMode, @@ -162,6 +217,8 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { updateNote, deleteNote, pinNote, + duplicateNote, + moveNote, }} > {children} diff --git a/apps/web/src/components/notes/NotesSidebar.tsx b/apps/web/src/components/notes/NotesSidebar.tsx index 8098dfe7..bf91ff44 100644 --- a/apps/web/src/components/notes/NotesSidebar.tsx +++ b/apps/web/src/components/notes/NotesSidebar.tsx @@ -17,6 +17,9 @@ import { Loader2, Pin, PinOff, + Copy, + FolderInput, + ArrowUpDown, } from "lucide-react"; import { DropdownMenu, @@ -24,6 +27,11 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, } from "@/components/ui/dropdown-menu"; import { AlertDialog, @@ -35,14 +43,97 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Note } from "@/app/app/notes/types/Note"; import { useTranslations } from "next-intl"; import { extractPlainText, extractSnippet } from "@/app/app/notes/utils/noteContentUtils"; +type SortKey = "createdAt" | "updatedAt" | "title"; + +// Pre-computed map from parentId -> children (avoids O(nΒ²) per render) +type ChildrenMap = Map; + +function buildChildrenMap(notes: Note[]): ChildrenMap { + const map: ChildrenMap = new Map(); + for (const note of notes) { + const pid = note.parentId ?? null; + if (!map.has(pid)) map.set(pid, []); + map.get(pid)!.push(note); + } + return map; +} + +// Simple emoji picker with common note icons +const EMOJI_OPTIONS = ["πŸ“„", "πŸ“", "πŸ“‹", "πŸ“Œ", "πŸ—’οΈ", "πŸ’‘", "⭐", "πŸ”–", "🎯", "πŸ“Š", "πŸ”", "πŸ’Ό", "🧠", "βœ…", "πŸ“…", "πŸ”", "πŸš€", "🎨", "πŸ’»", "πŸ“š"]; + +function EmojiPicker({ onSelect, onClose }: { onSelect: (e: string) => void; onClose: () => void }) { + return ( +
+ {EMOJI_OPTIONS.map((e) => ( + + ))} +
+ ); +} + +interface MoveDialogProps { + note: Note; + notes: Note[]; + open: boolean; + onOpenChange: (v: boolean) => void; + onMove: (newParentId: string | null) => void; +} + +function MoveDialog({ note, notes, open, onOpenChange, onMove }: MoveDialogProps) { + const targets = notes.filter((n) => n.id !== note.id && n.parentId !== note.id); + return ( + + + + Move note + +
+ + {targets.map((n) => ( + + ))} +
+
+
+ ); +} + interface NoteItemProps { note: Note; level: number; + childrenMap: ChildrenMap; onDeleteClick: (note: Note) => void; + onMoveClick: (note: Note) => void; parentTitle?: string; snippet?: string; expandedIds: Set; @@ -51,11 +142,15 @@ interface NoteItemProps { isSearching: boolean; } -const NoteItem = ({ note, level, onDeleteClick, parentTitle, snippet, expandedIds, onToggleExpand, onExpandPath, isSearching }: NoteItemProps) => { +const NoteItem = ({ + note, level, childrenMap, onDeleteClick, onMoveClick, + parentTitle, snippet, expandedIds, onToggleExpand, onExpandPath, isSearching, +}: NoteItemProps) => { const t = useTranslations("Notes.sidebar"); - const { notes, activeNoteId, setActiveNoteId, createNote, pinNote } = useNotes(); + const { notes, activeNoteId, setActiveNoteId, createNote, pinNote, duplicateNote, updateNote } = useNotes(); + const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); - const children = notes.filter(n => n.parentId === note.id); + const children = childrenMap.get(note.id) ?? []; const hasChildren = children.length > 0; const isActive = activeNoteId === note.id; const isExpanded = isSearching || expandedIds.has(note.id); @@ -81,6 +176,15 @@ const NoteItem = ({ note, level, onDeleteClick, parentTitle, snippet, expandedId onDeleteClick(note); }; + const handleDuplicate = async (e: React.MouseEvent) => { + e.stopPropagation(); + await duplicateNote(note.id); + }; + + const handleEmojiSelect = async (emoji: string) => { + await updateNote(note.id, { icon: emoji }); + }; + return (
- {note.icon || "πŸ“„"} + + + { e.stopPropagation(); setEmojiPickerOpen(true); }} + title="Change icon" + > + {note.icon || "πŸ“„"} + + + + setEmojiPickerOpen(false)} /> + + +
{parentTitle && (
@@ -116,6 +234,15 @@ const NoteItem = ({ note, level, onDeleteClick, parentTitle, snippet, expandedId {snippet}
)} + {note.tags && note.tags.length > 0 && ( +
+ {note.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+ )}
{note.pinned && ( @@ -155,6 +282,12 @@ const NoteItem = ({ note, level, onDeleteClick, parentTitle, snippet, expandedId <>Pin to top )} + + Duplicate + + { e.stopPropagation(); onMoveClick(note); }}> + Move to… + @@ -172,7 +305,9 @@ const NoteItem = ({ note, level, onDeleteClick, parentTitle, snippet, expandedId key={child.id} note={child} level={level + 1} + childrenMap={childrenMap} onDeleteClick={onDeleteClick} + onMoveClick={onMoveClick} expandedIds={expandedIds} onToggleExpand={onToggleExpand} onExpandPath={onExpandPath} @@ -187,28 +322,39 @@ const NoteItem = ({ note, level, onDeleteClick, parentTitle, snippet, expandedId export default function NotesSidebar() { const t = useTranslations("Notes.sidebar"); - const { notes, createNote, deleteNote, isLoading, activeNoteId } = useNotes(); + const { notes, createNote, deleteNote, moveNote, isLoading, activeNoteId } = useNotes(); const [noteToDelete, setNoteToDelete] = useState(null); + const [noteToMove, setNoteToMove] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [expandedIds, setExpandedIds] = useState>(new Set()); + const [sortKey, setSortKey] = useState("createdAt"); const searchInputRef = useRef(null); - // Pre-compute plain text for full-text search (memoised by note IDs + content changes) + const childrenMap = useMemo(() => buildChildrenMap(notes), [notes]); + + // Pre-compute plain text for full-text search const noteTextMap = useMemo(() => { const map = new Map(); notes.forEach(n => map.set(n.id, extractPlainText(n.content))); return map; }, [notes]); - const rootNotes = notes.filter(n => !n.parentId); - const pinnedNotes = rootNotes.filter(n => n.pinned); - const unpinnedNotes = rootNotes.filter(n => !n.pinned); + const sortedNotes = useMemo(() => { + return [...notes].sort((a, b) => { + if (sortKey === "title") return (a.title || "").localeCompare(b.title || ""); + if (sortKey === "updatedAt") return b.updatedAt.localeCompare(a.updatedAt); + return a.createdAt.localeCompare(b.createdAt); + }); + }, [notes, sortKey]); + + const rootNotes = useMemo(() => sortedNotes.filter(n => !n.parentId), [sortedNotes]); + const pinnedNotes = useMemo(() => rootNotes.filter(n => n.pinned), [rootNotes]); + const unpinnedNotes = useMemo(() => rootNotes.filter(n => !n.pinned), [rootNotes]); - // Full-text search: title + content const searchResults = useMemo(() => { if (!searchQuery.trim()) return null; const q = searchQuery.toLowerCase(); - return notes + return sortedNotes .filter(n => (n.title || "").toLowerCase().includes(q) || (noteTextMap.get(n.id) || "").toLowerCase().includes(q) @@ -220,7 +366,7 @@ export default function NotesSidebar() { : undefined, parent: n.parentId ? notes.find(p => p.id === n.parentId) : undefined, })); - }, [notes, searchQuery, noteTextMap]); + }, [sortedNotes, searchQuery, noteTextMap, notes]); const notesScrollRef = useRef(null); const listLength = searchResults ? searchResults.length : rootNotes.length; @@ -280,6 +426,19 @@ export default function NotesSidebar() { } }; + const handleMove = async (newParentId: string | null) => { + if (noteToMove) { + await moveNote(noteToMove.id, newParentId); + setNoteToMove(null); + } + }; + + const sortLabel: Record = { + createdAt: "Date created", + updatedAt: "Last modified", + title: "Title", + }; + return ( <>
@@ -291,15 +450,33 @@ export default function NotesSidebar() { {t("title")}
- +
+ + + + + + setSortKey(v as SortKey)}> + {(Object.keys(sortLabel) as SortKey[]).map((k) => ( + + {sortLabel[k]} + + ))} + + + + +
@@ -329,7 +506,6 @@ export default function NotesSidebar() { {isLoading ? (
{t("loading")}
) : searchResults !== null ? ( - // Search results searchResults.length === 0 ? (
{t("noNotesFound")}
) : ( @@ -338,7 +514,9 @@ export default function NotesSidebar() { key={note.id} note={note} level={0} + childrenMap={childrenMap} onDeleteClick={setNoteToDelete} + onMoveClick={setNoteToMove} parentTitle={parent?.title} snippet={snippet} expandedIds={expandedIds} @@ -349,9 +527,7 @@ export default function NotesSidebar() { )) ) ) : ( - // Normal tree view <> - {/* Pinned section */} {pinnedNotes.length > 0 && ( <>
@@ -362,7 +538,9 @@ export default function NotesSidebar() { key={note.id} note={note} level={0} + childrenMap={childrenMap} onDeleteClick={setNoteToDelete} + onMoveClick={setNoteToMove} expandedIds={expandedIds} onToggleExpand={toggleExpand} onExpandPath={expandPath} @@ -376,7 +554,6 @@ export default function NotesSidebar() { )} )} - {/* All / remaining notes */} {unpinnedNotes.length === 0 && pinnedNotes.length === 0 ? (
{t("noNotesYet")}
) : ( @@ -385,7 +562,9 @@ export default function NotesSidebar() { key={note.id} note={note} level={0} + childrenMap={childrenMap} onDeleteClick={setNoteToDelete} + onMoveClick={setNoteToMove} expandedIds={expandedIds} onToggleExpand={toggleExpand} onExpandPath={expandPath} @@ -420,6 +599,16 @@ export default function NotesSidebar() { + + {noteToMove && ( + !open && setNoteToMove(null)} + onMove={handleMove} + /> + )} ); } diff --git a/apps/web/src/components/notes/NotionEditor.tsx b/apps/web/src/components/notes/NotionEditor.tsx index 202ec842..d8ba46b9 100644 --- a/apps/web/src/components/notes/NotionEditor.tsx +++ b/apps/web/src/components/notes/NotionEditor.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useMemo, useCallback } from "react"; +import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { useNotes } from "@/app/app/notes/context/NotesContext"; import { Editor, EditorProvider, createEmptyContent } from "@/components/ui/rich-editor"; import { useDebouncedCallback } from "use-debounce"; @@ -17,7 +17,12 @@ import { Maximize2, Minimize2, LayoutTemplate, + FileCode, + Tag, + X, + Loader2, } from "lucide-react"; +import { serializeToHtml } from "@/components/ui/rich-editor/utils/serialize-to-html"; import { Dialog, DialogContent, @@ -26,6 +31,13 @@ import { } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import { contentToMarkdown, countWords, extractPlainText, readingTimeMinutes } from "@/app/app/notes/utils/noteContentUtils"; + +function sanitizeFileName(name: string): string { + return name + .replace(/[^a-zA-Z0-9._-]/g, "_") + .replace(/_{2,}/g, "_") + .slice(0, 200); +} import { NOTE_TEMPLATES, NoteTemplate } from "@/app/app/notes/utils/noteTemplates"; function TemplatePickerDialog({ @@ -67,7 +79,7 @@ function TemplatePickerDialog({ export default function NotionEditor() { const tEditor = useTranslations("Notes.editor"); const tCtx = useTranslations("Notes.context"); - const { notes, activeNoteId, updateNote, focusMode, setFocusMode } = useNotes(); + const { notes, activeNoteId, updateNote, focusMode, setFocusMode, isContentLoading } = useNotes(); const activeNote = notes.find(n => n.id === activeNoteId); const { user } = useAuth(); @@ -77,6 +89,12 @@ export default function NotionEditor() { const [lastSavedAt, setLastSavedAt] = useState(null); const [templateDialogOpen, setTemplateDialogOpen] = useState(false); const [currentContent, setCurrentContent] = useState(null); + const [editorKey, setEditorKey] = useState(0); + const isDirtyRef = useRef(false); + const [tagInput, setTagInput] = useState(""); + const [tags, setTags] = useState([]); + // Holds template content until EditorProvider mounts with it; cleared on note switch + const [pendingTemplateContent, setPendingTemplateContent] = useState(null); useEffect(() => { setIsMounted(true); @@ -87,7 +105,9 @@ export default function NotionEditor() { useEffect(() => { if (activeNote && activeNote.id !== lastSyncedNoteId) { setTitle(activeNote.title); + setTags(activeNote.tags ?? []); setLastSyncedNoteId(activeNote.id); + setPendingTemplateContent(null); } }, [activeNoteId, activeNote, lastSyncedNoteId]); @@ -97,23 +117,23 @@ export default function NotionEditor() { return () => clearTimeout(timeout); }, [saveState]); - const sanitizeForFirestore = (obj: any): any => { + const stripUndefined = (obj: any): any => { if (obj === null || obj === undefined) return null; if (typeof obj !== 'object') return obj; - if (Array.isArray(obj)) return obj.map(sanitizeForFirestore); - const sanitized: any = {}; + if (Array.isArray(obj)) return obj.map(stripUndefined); + const out: any = {}; for (const key in obj) { const value = obj[key]; - if (value !== undefined) sanitized[key] = sanitizeForFirestore(value); + if (value !== undefined) out[key] = stripUndefined(value); } - return sanitized; + return out; }; const handleUpdate = useCallback(async (id: string, updates: any) => { if (id) { setSaveState("saving"); - const sanitizedUpdates = sanitizeForFirestore(updates); - await updateNote(id, sanitizedUpdates); + isDirtyRef.current = false; + await updateNote(id, stripUndefined(updates)); setLastSavedAt(new Date()); setSaveState("saved"); } @@ -134,6 +154,7 @@ export default function NotionEditor() { const container = state.history[state.historyIndex]; setCurrentContent(container); if (activeNoteId) { + isDirtyRef.current = true; setSaveState("saving"); debouncedUpdate(activeNoteId, { content: container }); } @@ -142,12 +163,15 @@ export default function NotionEditor() { const handleUploadImage = async (file: File): Promise => { if (!user) throw new Error(tCtx("authRequiredError")); const timestamp = Date.now(); - const storageRef = ref(storage, `notes/${user.uid}/${activeNoteId}/${timestamp}_${file.name}`); + const safeName = sanitizeFileName(file.name); + const storageRef = ref(storage, `notes/${user.uid}/${activeNoteId}/${timestamp}_${safeName}`); await uploadBytes(storageRef, file); return getDownloadURL(storageRef); }; const initialContent = useMemo(() => { + // Template was just applied β€” use it directly (activeNote.content not updated yet) + if (pendingTemplateContent) return pendingTemplateContent; if (activeNote && activeNote.content) { const content = activeNote.content as any; if (content.type === 'container' && Array.isArray(content.children)) { @@ -160,8 +184,9 @@ export default function NotionEditor() { children: createEmptyContent(), attributes: {}, } as ContainerNode; + // editorKey in deps so memo re-runs when template forces remount // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeNoteId]); + }, [activeNoteId, editorKey]); const activePath = useMemo(() => { if (!activeNote) return []; @@ -185,6 +210,20 @@ export default function NotionEditor() { await debouncedUpdate.flush(); }, [activeNoteId, debouncedUpdate]); + const addTag = useCallback((raw: string) => { + const tag = raw.trim().toLowerCase().replace(/\s+/g, "-").slice(0, 50); + if (!tag || tags.includes(tag) || tags.length >= 20) return; + const next = [...tags, tag]; + setTags(next); + if (activeNoteId) debouncedUpdate(activeNoteId, { tags: next }); + }, [tags, activeNoteId, debouncedUpdate]); + + const removeTag = useCallback((tag: string) => { + const next = tags.filter(t => t !== tag); + setTags(next); + if (activeNoteId) debouncedUpdate(activeNoteId, { tags: next }); + }, [tags, activeNoteId, debouncedUpdate]); + // Word count from current in-memory content const { wordCount, readTime } = useMemo(() => { const src = currentContent ?? (activeNote?.content as ContainerNode | undefined); @@ -205,13 +244,30 @@ export default function NotionEditor() { URL.revokeObjectURL(url); }, [currentContent, activeNote?.content, title]); + const handleExportHtml = useCallback(() => { + const src = currentContent ?? (activeNote?.content as ContainerNode | undefined); + if (!src) return; + const bodyHtml = serializeToHtml(src, { wrapperClass: "note-content max-w-3xl mx-auto px-6 py-8 font-sans" }); + const html = `\n\n\n\n${title || "Note"}\n\n\n

${title || "Untitled"}

\n${bodyHtml}\n`; + const blob = new Blob([html], { type: "text/html" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${(title || "note").replace(/\s+/g, "-")}.html`; + a.click(); + URL.revokeObjectURL(url); + }, [currentContent, activeNote?.content, title]); + const handleApplyTemplate = useCallback((tpl: NoteTemplate) => { if (!activeNoteId) return; - const newContent = { ...tpl.content, id: "root" }; + const newContent = { ...tpl.content, id: "root" } as ContainerNode; setSaveState("saving"); debouncedUpdate(activeNoteId, { content: newContent, title: tpl.defaultTitle }); setTitle(tpl.defaultTitle); - setLastSyncedNoteId(null); // force re-sync + setCurrentContent(newContent); + setPendingTemplateContent(newContent); // initialContent reads this on remount + setLastSyncedNoteId(null); + setEditorKey(k => k + 1); }, [activeNoteId, debouncedUpdate]); useEffect(() => { @@ -227,7 +283,17 @@ export default function NotionEditor() { }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [saveNow, setFocusMode]); + }, [saveNow, setFocusMode, focusMode]); + + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (isDirtyRef.current) { + e.preventDefault(); + } + }; + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, []); if (!activeNoteId) { return ( @@ -303,6 +369,19 @@ export default function NotionEditor() { .md + {/* Export HTML */} + + {/* Focus mode */} + + ))} + setTagInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + addTag(tagInput); + setTagInput(""); + } else if (e.key === "Backspace" && !tagInput && tags.length > 0) { + removeTag(tags[tags.length - 1]); + } + }} + placeholder={tags.length === 0 ? "Add tag…" : ""} + className="text-[11px] bg-transparent outline-none text-muted-foreground placeholder:text-muted-foreground/40 min-w-[60px] max-w-[120px]" + /> +
+ )}
- - - + {isContentLoading ? ( +
+ + Loading… +
+ ) : ( + + + + )}