Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/backend/app/api/routes/notes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
65 changes: 58 additions & 7 deletions apps/backend/app/api/routes/notes/schema.py
Original file line number Diff line number Diff line change
@@ -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")
Expand Down
43 changes: 25 additions & 18 deletions apps/backend/app/api/routes/notes/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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),
Expand Down Expand Up @@ -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:
Expand Down
65 changes: 61 additions & 4 deletions apps/web/src/app/app/notes/context/NotesContext.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -24,6 +25,8 @@ interface NotesContextType {
updateNote: (id: string, updates: Partial<Note>) => Promise<void>;
deleteNote: (id: string) => Promise<void>;
pinNote: (id: string, pinned: boolean) => Promise<void>;
duplicateNote: (id: string) => Promise<string>;
moveNote: (id: string, newParentId: string | null) => Promise<void>;
}

const NotesContext = createContext<NotesContextType | undefined>(undefined);
Expand All @@ -33,8 +36,11 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
const [user] = useAuthState(auth);
const [notes, setNotes] = useState<Note[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isContentLoading, setIsContentLoading] = useState(false);
const [activeNoteId, setActiveNoteId] = useState<string | null>(null);
const [focusMode, setFocusMode] = useState(false);
// Tracks which note IDs have full content loaded in state
const contentLoadedIds = useRef<Set<string>>(new Set());

const apiRequest = useCallback(
async <T,>(method: string, path: string, body?: unknown): Promise<T> => {
Expand Down Expand Up @@ -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<Note>("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([]);
Expand All @@ -116,33 +138,65 @@ 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;
}, [apiRequest, user, t]);

const updateNote = useCallback(async (id: string, updates: Partial<Note>) => {
if (!user) return;
const payload: Partial<Pick<Note, "title" | "content" | "parentId" | "icon">> = {};
const payload: Partial<Pick<Note, "title" | "content" | "parentId" | "icon" | "pinned" | "tags">> = {};
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<Note>("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]);

const pinNote = useCallback(async (id: string, pinned: boolean) => {
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<Note>("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<Note>("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<void>("DELETE", `/api/v1/notes/${id}?recursive=true`);
contentLoadedIds.current.delete(id);
if (activeNoteId === id) {
setActiveNoteId(null);
}
Expand All @@ -154,6 +208,7 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
value={{
notes,
isLoading,
isContentLoading,
activeNoteId,
setActiveNoteId,
focusMode,
Expand All @@ -162,6 +217,8 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
updateNote,
deleteNote,
pinNote,
duplicateNote,
moveNote,
}}
>
{children}
Expand Down
Loading