From 92d525b3ab517ac6d8adff636d4a467596e9766d Mon Sep 17 00:00:00 2001 From: marc Date: Fri, 7 Aug 2026 16:01:35 +0200 Subject: [PATCH] =?UTF-8?q?TipTap=20editor,=20version=20history,=20search,?= =?UTF-8?q?=20attachments=20=E2=80=94=20and=20a=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app is meant to run in daily private use now, so this pass was mostly about the ways it could quietly lose data. Reliability and security: - the server no longer invents a JWT_SECRET or ENCRYPTION_KEY when one is missing; it refuses to start. The old fallback silently made every stored note unreadable after a restart. - notes are encrypted with AES-256-GCM (v2:); existing CBC bodies still read. - login is rate limited and compares against a dummy hash when the user does not exist, so timing does not reveal who is registered. - a server response issued before a local save is never adopted. This had already eaten drawing strokes and note links; sync.hasPendingWrites() now guards every path that takes a body from the server. Editor and features: - TipTap replaces the contenteditable editor: tables, task lists, and links to other notes and notebooks, with a backlinks bar on the target. - content format v2 ({v:2,doc}); v1 {html,tasks} bodies migrate on open. - version history: the state before each write is snapshotted, coalesced into 5-minute windows, 50 versions or 90 days per note. - full-text search runs in the client, because the server cannot read encrypted bodies and a server index would defeat the encryption. - image attachments are stored by SHA-256 and deduplicated; the client fetches them authenticated and caches the blob, so they work offline. - drawings are vector strokes in a fixed 1200x1600 page space and now look the same on every screen (0.0-0.2% deviation across four widths). - login is by username; existing email accounts migrate automatically. Operations: - data lives on host bind mounts, not Docker volumes, so Proxmox Backup covers it. Back up .env with it — without ENCRYPTION_KEY the dump is unreadable. Structure: - NoteApp.jsx is gone. App.jsx now holds only where the user is; note data, search, layout, history and drag each live behind a hook, and the four panes are stateless. - the whole data layer is TypeScript, with 81 unit tests concentrated on the places that have actually broken. - fixed along the way: the browser back button had a dead press, because restoring a history entry pushed a duplicate one. Co-Authored-By: Claude Opus 5 --- .gitignore | 8 + backend/server.js | 774 +++- build.sh | 2 +- docker-compose.dev.yml | 73 + docker-compose.yml | 18 +- frontend/.prettierignore | 3 + frontend/.prettierrc.json | 8 + frontend/index.html | 2 +- frontend/nginx.conf | 4 +- frontend/package-lock.json | 3956 +++++++++++++++++ frontend/package.json | 32 +- frontend/public/apple-touch-icon.png | Bin 0 -> 7579 bytes frontend/public/icon-192.png | Bin 0 -> 7535 bytes frontend/public/icon-512.png | Bin 0 -> 21070 bytes frontend/public/icon-maskable-192.png | Bin 0 -> 3719 bytes frontend/public/icon-maskable-512.png | Bin 0 -> 11850 bytes frontend/public/manifest.json | 11 +- frontend/public/sw.js | 2 +- frontend/src/App.jsx | 563 +++ frontend/src/NoteApp.jsx | 1094 ----- frontend/src/editor/EditorToolbar.jsx | 342 ++ frontend/src/editor/VersionPreview.jsx | 39 + frontend/src/editor/attachmentImage.js | 82 + frontend/src/editor/attachments.js | 73 + frontend/src/editor/content.test.ts | 183 + frontend/src/editor/content.ts | 140 + frontend/src/editor/editor.css | 262 ++ frontend/src/editor/extensions.js | 49 + frontend/src/editor/noteLink.js | 221 + frontend/src/editor/useNoteEditor.jsx | 167 + .../src/features/drawing/DrawingLayer.jsx | 18 + frontend/src/features/drawing/strokes.js | 105 + frontend/src/features/notebooks/Dashboard.jsx | 234 + frontend/src/features/notes/EditorPane.jsx | 221 + frontend/src/features/notes/HistoryModal.jsx | 297 ++ frontend/src/features/notes/NoteEditor.jsx | 691 +++ frontend/src/features/notes/NoteList.jsx | 217 + frontend/src/hooks/useNavigationHistory.ts | 72 + frontend/src/hooks/useNoteData.ts | 302 ++ frontend/src/hooks/useNotebookDnd.ts | 48 + frontend/src/hooks/usePwaInstall.ts | 41 + frontend/src/hooks/useResponsive.ts | 39 + frontend/src/hooks/useSearchIndex.ts | 84 + frontend/src/main.jsx | 29 +- frontend/src/offlineStore.js | 204 - frontend/src/offlineStore.test.ts | 175 + frontend/src/offlineStore.ts | 245 + frontend/src/search.test.ts | 108 + frontend/src/search.ts | 133 + frontend/src/syncEngine.js | 369 -- frontend/src/syncEngine.test.ts | 393 ++ frontend/src/syncEngine.ts | 1063 +++++ frontend/src/test/setup.js | 3 + frontend/src/types.ts | 147 + frontend/src/ui/AccountMenu.jsx | 394 ++ frontend/src/ui/AuthScreen.jsx | 222 + frontend/src/ui/BackButton.jsx | 26 + frontend/src/ui/BacklinksPanel.jsx | 110 + frontend/src/ui/ConfirmDialog.jsx | 98 + frontend/src/ui/ContextMenu.jsx | 158 + frontend/src/ui/NotebookCover.jsx | 242 + frontend/src/ui/NotebookModals.jsx | 273 ++ frontend/src/ui/Sidebar.jsx | 375 ++ frontend/src/ui/constants.jsx | 47 + frontend/src/ui/format.ts | 8 + frontend/src/ui/icons.jsx | 469 ++ frontend/src/ui/theme.ts | 80 + frontend/tsconfig.json | 24 + frontend/vite.config.js | 14 +- frontend/vitest.config.js | 9 + readme.md | 285 +- 71 files changed, 14354 insertions(+), 1826 deletions(-) create mode 100644 .gitignore mode change 100755 => 100644 build.sh create mode 100644 docker-compose.dev.yml create mode 100644 frontend/.prettierignore create mode 100644 frontend/.prettierrc.json create mode 100644 frontend/package-lock.json create mode 100644 frontend/public/apple-touch-icon.png create mode 100644 frontend/public/icon-192.png create mode 100644 frontend/public/icon-512.png create mode 100644 frontend/public/icon-maskable-192.png create mode 100644 frontend/public/icon-maskable-512.png create mode 100644 frontend/src/App.jsx delete mode 100644 frontend/src/NoteApp.jsx create mode 100644 frontend/src/editor/EditorToolbar.jsx create mode 100644 frontend/src/editor/VersionPreview.jsx create mode 100644 frontend/src/editor/attachmentImage.js create mode 100644 frontend/src/editor/attachments.js create mode 100644 frontend/src/editor/content.test.ts create mode 100644 frontend/src/editor/content.ts create mode 100644 frontend/src/editor/editor.css create mode 100644 frontend/src/editor/extensions.js create mode 100644 frontend/src/editor/noteLink.js create mode 100644 frontend/src/editor/useNoteEditor.jsx create mode 100644 frontend/src/features/drawing/DrawingLayer.jsx create mode 100644 frontend/src/features/drawing/strokes.js create mode 100644 frontend/src/features/notebooks/Dashboard.jsx create mode 100644 frontend/src/features/notes/EditorPane.jsx create mode 100644 frontend/src/features/notes/HistoryModal.jsx create mode 100644 frontend/src/features/notes/NoteEditor.jsx create mode 100644 frontend/src/features/notes/NoteList.jsx create mode 100644 frontend/src/hooks/useNavigationHistory.ts create mode 100644 frontend/src/hooks/useNoteData.ts create mode 100644 frontend/src/hooks/useNotebookDnd.ts create mode 100644 frontend/src/hooks/usePwaInstall.ts create mode 100644 frontend/src/hooks/useResponsive.ts create mode 100644 frontend/src/hooks/useSearchIndex.ts delete mode 100644 frontend/src/offlineStore.js create mode 100644 frontend/src/offlineStore.test.ts create mode 100644 frontend/src/offlineStore.ts create mode 100644 frontend/src/search.test.ts create mode 100644 frontend/src/search.ts delete mode 100644 frontend/src/syncEngine.js create mode 100644 frontend/src/syncEngine.test.ts create mode 100644 frontend/src/syncEngine.ts create mode 100644 frontend/src/test/setup.js create mode 100644 frontend/src/types.ts create mode 100644 frontend/src/ui/AccountMenu.jsx create mode 100644 frontend/src/ui/AuthScreen.jsx create mode 100644 frontend/src/ui/BackButton.jsx create mode 100644 frontend/src/ui/BacklinksPanel.jsx create mode 100644 frontend/src/ui/ConfirmDialog.jsx create mode 100644 frontend/src/ui/ContextMenu.jsx create mode 100644 frontend/src/ui/NotebookCover.jsx create mode 100644 frontend/src/ui/NotebookModals.jsx create mode 100644 frontend/src/ui/Sidebar.jsx create mode 100644 frontend/src/ui/constants.jsx create mode 100644 frontend/src/ui/format.ts create mode 100644 frontend/src/ui/icons.jsx create mode 100644 frontend/src/ui/theme.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vitest.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0fcc805 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.env +.env.* +!.env.example +backend/data/ +backend/uploads/ +/data/ diff --git a/backend/server.js b/backend/server.js index 8b8fce7..f93fc0e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -35,8 +35,36 @@ const http = require("http"); /* ─────────── Config ─────────── */ const PORT = process.env.PORT || 4000; -const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production-" + crypto.randomBytes(16).toString("hex"); -const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || crypto.randomBytes(32).toString("hex"); + +/* Secrets must be supplied. They used to fall back to a fresh random value, + * which meant a restart without them silently made every stored note + * undecryptable — the data was still there, just lost forever. Refuse to boot + * instead, and say exactly how to generate what is missing. */ +function requireSecret(name, { hexBytes } = {}) { + const v = process.env[name]; + if (!v) { + const how = hexBytes ? `openssl rand -hex ${hexBytes}` : "openssl rand -base64 32"; + console.error(`\n ✖ ${name} is not set. Refusing to start.`); + console.error(` Generate one with: ${how}`); + console.error(` Then put it in .env or pass it as an environment variable.\n`); + process.exit(1); + } + if (hexBytes && !new RegExp(`^[0-9a-fA-F]{${hexBytes * 2}}$`).test(v)) { + console.error(`\n ✖ ${name} must be ${hexBytes * 2} hex characters (${hexBytes} bytes).`); + console.error(` Generate one with: openssl rand -hex ${hexBytes}\n`); + process.exit(1); + } + return v; +} +const JWT_SECRET = requireSecret("JWT_SECRET"); +const ENCRYPTION_KEY = requireSecret("ENCRYPTION_KEY", { hexBytes: 32 }); +const ENCRYPTION_KEY_BUF = Buffer.from(ENCRYPTION_KEY, "hex"); + +// Which origins may call the API. Same-origin deployments (nginx in front) need +// none; set a comma-separated list only if you serve the UI from another host. +const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || "") + .split(",").map((s) => s.trim()).filter(Boolean); + const DATA_DIR = path.join(__dirname, "data"); const UPLOAD_DIR = path.join(__dirname, "uploads"); if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); @@ -50,7 +78,7 @@ db.pragma("foreign_keys = ON"); db.exec(` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, - email TEXT UNIQUE NOT NULL, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, display_name TEXT, created_at TEXT DEFAULT (datetime('now')), @@ -110,30 +138,128 @@ db.exec(` last_sync TEXT DEFAULT (datetime('now')) ); + -- Which note references which. Note bodies are encrypted, so the server + -- cannot derive this itself; the client sends the link list when it saves. + CREATE TABLE IF NOT EXISTS note_links ( + source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + target_id TEXT NOT NULL, + target_kind TEXT NOT NULL DEFAULT 'note', + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT DEFAULT (datetime('now')), + PRIMARY KEY (source_id, target_id, target_kind) + ); + + -- Past states of a note, so an accidental overwrite can be undone after the + -- fact. Content is encrypted here exactly as it is in the notes table. + CREATE TABLE IF NOT EXISTS note_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT, + content_encrypted BLOB, + drawing_strokes BLOB, + device_id TEXT, + reason TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + + -- Binary blobs referenced from note bodies. Stored on disk under a content + -- hash, so the same image pasted into ten notes is kept once. + CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + sha256 TEXT NOT NULL, + mime TEXT NOT NULL, + size INTEGER NOT NULL, + filename TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_attachments_owner_hash ON attachments(user_id, sha256); + CREATE INDEX IF NOT EXISTS idx_note_versions ON note_versions(note_id, id DESC); CREATE INDEX IF NOT EXISTS idx_notes_notebook ON notes(notebook_id); CREATE INDEX IF NOT EXISTS idx_notebooks_user ON notebooks(user_id); CREATE INDEX IF NOT EXISTS idx_sync_user ON sync_log(user_id, timestamp); + CREATE INDEX IF NOT EXISTS idx_note_links_target ON note_links(user_id, target_id); + CREATE INDEX IF NOT EXISTS idx_note_links_source ON note_links(source_id); `); +/* ─────────── Migrations ─────────── */ +const tableColumns = (t) => db.prepare(`PRAGMA table_info(${t})`).all().map((c) => c.name); + +const USERNAME_RE = /^[a-zA-Z0-9._-]{3,32}$/; +function normalizeUsername(raw) { + const v = String(raw ?? "").trim(); + return USERNAME_RE.test(v) ? v : null; +} + +// Login used to be e-mail based. Rebuild the table with a username column and +// derive each existing user's username from the local part of their address. +(function migrateEmailToUsername() { + const cols = tableColumns("users"); + if (!cols.includes("email")) return; + + db.pragma("foreign_keys = OFF"); + db.transaction(() => { + db.exec(` + CREATE TABLE users_migrated ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + display_name TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + `); + const insert = db.prepare( + "INSERT INTO users_migrated (id, username, password_hash, display_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" + ); + const taken = new Set(); + for (const u of db.prepare("SELECT * FROM users").all()) { + const seed = String(u.email || u.display_name || "user").split("@")[0]; + const base = (seed.replace(/[^a-zA-Z0-9._-]/g, "").slice(0, 32) || "user").padEnd(3, "0"); + let name = base; + for (let i = 2; taken.has(name.toLowerCase()); i++) name = `${base.slice(0, 30)}${i}`; + taken.add(name.toLowerCase()); + insert.run(u.id, name, u.password_hash, u.display_name || name, u.created_at, u.updated_at); + console.log(` ↪ migrated login: ${u.email} → username "${name}"`); + } + db.exec("DROP TABLE users; ALTER TABLE users_migrated RENAME TO users;"); + })(); + db.pragma("foreign_keys = ON"); + console.log(" ✅ users table migrated from e-mail to username login"); +})(); + +// Drawings are stored as resolution-independent vector strokes. The legacy +// raster path (drawing_data_path) stays readable for notes drawn before this. +if (!tableColumns("notes").includes("drawing_strokes")) { + db.exec("ALTER TABLE notes ADD COLUMN drawing_strokes BLOB"); +} + /* ─────────── Encryption helpers ─────────── */ +/* AES-256-GCM. CBC gave confidentiality but no integrity — a flipped bit in the + * ciphertext decrypted to garbage instead of failing loudly. GCM authenticates, + * so tampering and corruption are detected. Records written before this are + * still CBC and stay readable; they re-encrypt as GCM on their next write. */ function encrypt(text) { - const iv = crypto.randomBytes(16); - const key = Buffer.from(ENCRYPTION_KEY, "hex"); - const cipher = crypto.createCipheriv("aes-256-cbc", key, iv); - let encrypted = cipher.update(text, "utf8", "hex"); - encrypted += cipher.final("hex"); - return iv.toString("hex") + ":" + encrypted; + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", ENCRYPTION_KEY_BUF, iv); + const enc = Buffer.concat([cipher.update(String(text), "utf8"), cipher.final()]); + return `v2:${iv.toString("hex")}:${cipher.getAuthTag().toString("hex")}:${enc.toString("hex")}`; } function decrypt(data) { - const [ivHex, encryptedHex] = data.split(":"); - const iv = Buffer.from(ivHex, "hex"); - const key = Buffer.from(ENCRYPTION_KEY, "hex"); - const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv); - let decrypted = decipher.update(encryptedHex, "hex", "utf8"); - decrypted += decipher.final("utf8"); - return decrypted; + const s = String(data); + if (s.startsWith("v2:")) { + const [, ivHex, tagHex, encHex] = s.split(":"); + const decipher = crypto.createDecipheriv("aes-256-gcm", ENCRYPTION_KEY_BUF, Buffer.from(ivHex, "hex")); + decipher.setAuthTag(Buffer.from(tagHex, "hex")); + return decipher.update(encHex, "hex", "utf8") + decipher.final("utf8"); + } + const [ivHex, encryptedHex] = s.split(":"); // legacy AES-256-CBC + const decipher = crypto.createDecipheriv("aes-256-cbc", ENCRYPTION_KEY_BUF, Buffer.from(ivHex, "hex")); + return decipher.update(encryptedHex, "hex", "utf8") + decipher.final("utf8"); } function detectImageMime(buffer, filePath = "") { @@ -188,9 +314,44 @@ function authMiddleware(req, res, next) { /* ─────────── Express app ─────────── */ const app = express(); +app.set("trust proxy", 1); // nginx sits in front; needed for real client IPs app.use(helmet()); -app.use(cors()); -app.use(express.json({ limit: "50mb" })); +app.use(cors(ALLOWED_ORIGINS.length ? { origin: ALLOWED_ORIGINS } : { origin: false })); +app.use(express.json({ limit: "25mb" })); + +/* Password checks are deliberately slow, which makes them both a brute-force + * defence and a denial-of-service lever. Cap attempts per username+IP. */ +const RATE_WINDOW_MS = 15 * 60 * 1000; +const RATE_MAX_ATTEMPTS = 10; +const loginAttempts = new Map(); // key -> { count, resetAt } + +function rateLimitLogin(req, res, next) { + const key = `${req.ip}|${String(req.body?.username ?? "").toLowerCase()}`; + const now = Date.now(); + const entry = loginAttempts.get(key); + if (!entry || now > entry.resetAt) { + loginAttempts.set(key, { count: 1, resetAt: now + RATE_WINDOW_MS }); + return next(); + } + if (entry.count >= RATE_MAX_ATTEMPTS) { + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); + res.set("Retry-After", String(retryAfter)); + return res.status(429).json({ error: "Too many attempts. Try again later.", retryAfter }); + } + entry.count++; + next(); +} +const clearLoginAttempts = (req) => { + loginAttempts.delete(`${req.ip}|${String(req.body?.username ?? "").toLowerCase()}`); +}; +setInterval(() => { + const now = Date.now(); + for (const [k, v] of loginAttempts) if (now > v.resetAt) loginAttempts.delete(k); +}, RATE_WINDOW_MS).unref(); + +// A dummy hash keeps "no such user" and "wrong password" equally slow, so the +// response time does not reveal which usernames exist. +const DUMMY_HASH = bcrypt.hashSync("not-a-real-password", 12); const upload = multer({ dest: UPLOAD_DIR, @@ -202,66 +363,88 @@ const upload = multer({ }); /* ─── Auth routes ─── */ -app.post("/api/auth/register", (req, res) => { - const { email, password, displayName } = req.body; - if (!email || !password) return res.status(400).json({ error: "Email and password required" }); +app.post("/api/auth/register", async (req, res, next) => { + try { + const { username, password, displayName } = req.body; + const name = normalizeUsername(username); + if (!name || !password) return res.status(400).json({ error: "Username and password required" }); + if (String(password).length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" }); - const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email); - if (existing) return res.status(409).json({ error: "Email already registered" }); + const existing = db.prepare("SELECT id FROM users WHERE username = ?").get(name); + if (existing) return res.status(409).json({ error: "Username already taken" }); - const id = uuid(); - const hash = bcrypt.hashSync(password, 12); - db.prepare("INSERT INTO users (id, email, password_hash, display_name) VALUES (?, ?, ?, ?)").run(id, email, hash, displayName || email.split("@")[0]); + const id = uuid(); + const hash = await bcrypt.hash(password, 12); + try { + db.prepare("INSERT INTO users (id, username, password_hash, display_name) VALUES (?, ?, ?, ?)").run(id, name, hash, displayName || name); + } catch (err) { + // Someone claimed the name between the check and the insert. + if (String(err.message).includes("UNIQUE")) return res.status(409).json({ error: "Username already taken" }); + throw err; + } - const token = jwt.sign({ userId: id }, JWT_SECRET, { expiresIn: "30d" }); - res.json({ token, user: { id, email, displayName: displayName || email.split("@")[0] } }); + const token = jwt.sign({ userId: id }, JWT_SECRET, { expiresIn: "30d" }); + res.json({ token, user: { id, username: name, displayName: displayName || name } }); + } catch (err) { next(err); } }); -app.post("/api/auth/login", (req, res) => { - const { email, password } = req.body; - const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email); - if (!user || !bcrypt.compareSync(password, user.password_hash)) { - return res.status(401).json({ error: "Invalid credentials" }); - } - const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" }); - res.json({ token, user: { id: user.id, email: user.email, displayName: user.display_name } }); +app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => { + try { + const { username, password } = req.body; + const name = String(username ?? "").trim(); + const user = name ? db.prepare("SELECT * FROM users WHERE username = ?").get(name) : null; + // Always run a comparison so timing does not leak whether the user exists. + const ok = await bcrypt.compare(String(password ?? ""), user ? user.password_hash : DUMMY_HASH); + if (!user || !ok) return res.status(401).json({ error: "Invalid credentials" }); + + clearLoginAttempts(req); + const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" }); + res.json({ token, user: { id: user.id, username: user.username, displayName: user.display_name } }); + } catch (err) { next(err); } }); /* ─── Auth profile routes ─── */ app.get("/api/auth/me", authMiddleware, (req, res) => { - const user = db.prepare("SELECT id, email, display_name FROM users WHERE id = ?").get(req.userId); + const user = db.prepare("SELECT id, username, display_name FROM users WHERE id = ?").get(req.userId); if (!user) return res.status(404).json({ error: "User not found" }); - res.json({ id: user.id, email: user.email, displayName: user.display_name }); + res.json({ id: user.id, username: user.username, displayName: user.display_name }); }); app.put("/api/auth/profile", authMiddleware, (req, res) => { const { displayName } = req.body; if (!displayName || !displayName.trim()) return res.status(400).json({ error: "Display name required" }); db.prepare("UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?").run(displayName.trim(), req.userId); - const user = db.prepare("SELECT id, email, display_name FROM users WHERE id = ?").get(req.userId); - res.json({ id: user.id, email: user.email, displayName: user.display_name }); + const user = db.prepare("SELECT id, username, display_name FROM users WHERE id = ?").get(req.userId); + res.json({ id: user.id, username: user.username, displayName: user.display_name }); }); -app.put("/api/auth/email", authMiddleware, (req, res) => { - const { newEmail, password } = req.body; - if (!newEmail || !password) return res.status(400).json({ error: "Email and password required" }); +app.put("/api/auth/username", authMiddleware, async (req, res, next) => { + try { + const { newUsername, password } = req.body; + const name = normalizeUsername(newUsername); + if (!name || !password) return res.status(400).json({ error: "Username and password required" }); const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId); - if (!bcrypt.compareSync(password, user.password_hash)) return res.status(401).json({ error: "Wrong password" }); - const exists = db.prepare("SELECT id FROM users WHERE email = ? AND id != ?").get(newEmail, req.userId); - if (exists) return res.status(409).json({ error: "Email already in use" }); - db.prepare("UPDATE users SET email = ?, updated_at = datetime('now') WHERE id = ?").run(newEmail, req.userId); - res.json({ id: user.id, email: newEmail, displayName: user.display_name }); + if (!user) return res.status(404).json({ error: "User not found" }); + if (!(await bcrypt.compare(String(password), user.password_hash))) return res.status(401).json({ error: "Wrong password" }); + const exists = db.prepare("SELECT id FROM users WHERE username = ? AND id != ?").get(name, req.userId); + if (exists) return res.status(409).json({ error: "Username already taken" }); + db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(name, req.userId); + res.json({ id: user.id, username: name, displayName: user.display_name }); + } catch (err) { next(err); } }); -app.put("/api/auth/password", authMiddleware, (req, res) => { - const { currentPassword, newPassword } = req.body; - if (!currentPassword || !newPassword) return res.status(400).json({ error: "Both passwords required" }); - if (newPassword.length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" }); - const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId); - if (!bcrypt.compareSync(currentPassword, user.password_hash)) return res.status(401).json({ error: "Current password is wrong" }); - const hash = bcrypt.hashSync(newPassword, 12); - db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(hash, req.userId); - res.json({ success: true }); +app.put("/api/auth/password", authMiddleware, async (req, res, next) => { + try { + const { currentPassword, newPassword } = req.body; + if (!currentPassword || !newPassword) return res.status(400).json({ error: "Both passwords required" }); + if (String(newPassword).length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" }); + const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId); + if (!user) return res.status(404).json({ error: "User not found" }); + if (!(await bcrypt.compare(String(currentPassword), user.password_hash))) return res.status(401).json({ error: "Current password is wrong" }); + const hash = await bcrypt.hash(String(newPassword), 12); + db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(hash, req.userId); + res.json({ success: true }); + } catch (err) { next(err); } }); /* ─── Notebook routes ─── */ @@ -301,6 +484,61 @@ app.delete("/api/notebooks/:id", authMiddleware, (req, res) => { res.json({ success: true }); }); +/* ─────────── Version history ─────────── + * + * A snapshot records what a note looked like *before* a write, so the previous + * state is always recoverable. + * + * Saves arrive roughly once per second while typing, so snapshotting every one + * would be useless noise. Instead they coalesce: within the window below, the + * existing newest version already represents "before this editing session". + * A write that shrinks a note sharply bypasses the window, because that is + * exactly the case worth being able to undo. */ +const num = (name, fallback) => { + const v = Number(process.env[name]); + return Number.isFinite(v) && v > 0 ? v : fallback; +}; +const VERSION_COALESCE_MS = num("VERSION_COALESCE_MINUTES", 5) * 60 * 1000; +const MAX_VERSIONS_PER_NOTE = num("MAX_VERSIONS_PER_NOTE", 50); +const VERSION_RETENTION_DAYS = num("VERSION_RETENTION_DAYS", 90); + +const sqliteTimeToMs = (t) => (t ? Date.parse(`${String(t).replace(" ", "T")}Z`) : 0); + +function pruneNoteVersions(noteId) { + db.prepare(` + DELETE FROM note_versions + WHERE note_id = ? AND id NOT IN ( + SELECT id FROM note_versions WHERE note_id = ? ORDER BY id DESC LIMIT ? + )`).run(noteId, noteId, MAX_VERSIONS_PER_NOTE); +} + +function snapshotNote(userId, noteId, deviceId, { force = false, reason = "edit" } = {}) { + const note = db.prepare( + "SELECT title, content_encrypted, drawing_strokes FROM notes WHERE id = ? AND user_id = ?" + ).get(noteId, userId); + if (!note) return; + // An empty note has nothing worth going back to. + if (!note.content_encrypted && !note.drawing_strokes) return; + + if (!force) { + const last = db.prepare("SELECT created_at FROM note_versions WHERE note_id = ? ORDER BY id DESC LIMIT 1").get(noteId); + if (last && Date.now() - sqliteTimeToMs(last.created_at) < VERSION_COALESCE_MS) return; + } + + db.prepare(`INSERT INTO note_versions (note_id, user_id, title, content_encrypted, drawing_strokes, device_id, reason) + VALUES (?, ?, ?, ?, ?, ?, ?)`) + .run(noteId, userId, note.title, note.content_encrypted, note.drawing_strokes, deviceId, reason); + pruneNoteVersions(noteId); +} + +/* Ciphertext length tracks plaintext length closely enough to spot a write that + * throws most of a note away, without decrypting to find out. */ +function isBigShrink(previous, next) { + const before = previous ? previous.length : 0; + const after = next ? next.length : 0; + return before > 200 && after < before * 0.5; +} + /* ─── Note routes ─── */ app.get("/api/notes", authMiddleware, (req, res) => { const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, tags, sort_order, created_at, updated_at, version, vector_clock FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId); @@ -316,17 +554,33 @@ app.get("/api/notes/:id", authMiddleware, (req, res) => { const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!note) return res.status(404).json({ error: "Not found" }); - // Decrypt content + /* A failed decrypt used to be reported as empty content. The client then + * treated the note as blank and the next edit overwrote the real text — a + * read error turned into permanent data loss. Fail the request instead, so + * the client keeps showing its local copy. */ if (note.content_encrypted) { try { note.content = decrypt(note.content_encrypted.toString()); - } catch { - note.content = ""; + } catch (err) { + console.error(`[decrypt] note ${note.id} content unreadable:`, err.message); + return res.status(500).json({ error: "Note content could not be decrypted", id: note.id }); } } delete note.content_encrypted; - // Read drawing data from file if exists + // Vector strokes are the source of truth for drawings. + note.drawingStrokes = null; + if (note.drawing_strokes) { + try { + note.drawingStrokes = JSON.parse(decrypt(note.drawing_strokes.toString())); + } catch (err) { + console.error(`[decrypt] note ${note.id} strokes unreadable:`, err.message); + return res.status(500).json({ error: "Note drawing could not be decrypted", id: note.id }); + } + } + delete note.drawing_strokes; + + // Legacy raster drawing — only for notes created before vector strokes existed. if (note.drawing_data_path && fs.existsSync(note.drawing_data_path)) { try { const raw = fs.readFileSync(note.drawing_data_path); @@ -359,8 +613,24 @@ app.post("/api/notes", authMiddleware, (req, res) => { res.json({ id }); }); +/* Replaces a note's outgoing links. Called whenever its body is saved. */ +const MAX_LINKS_PER_NOTE = 500; +const replaceNoteLinks = db.transaction((userId, sourceId, links) => { + db.prepare("DELETE FROM note_links WHERE source_id = ?").run(sourceId); + const insert = db.prepare( + "INSERT OR IGNORE INTO note_links (source_id, target_id, target_kind, user_id) VALUES (?, ?, ?, ?)" + ); + for (const link of links.slice(0, MAX_LINKS_PER_NOTE)) { + const targetId = typeof link === "string" ? link : link?.id; + const kind = (typeof link === "object" && link?.kind) === "notebook" ? "notebook" : "note"; + if (typeof targetId === "string" && targetId && targetId !== sourceId) { + insert.run(sourceId, targetId, kind, userId); + } + } +}); + app.put("/api/notes/:id", authMiddleware, (req, res) => { - const { title, content, notebookId, starred, tags, vectorClock } = req.body; + const { title, content, notebookId, starred, tags, vectorClock, links } = req.body; const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!note) return res.status(404).json({ error: "Not found" }); @@ -381,6 +651,15 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => { const encContent = content ? encrypt(content) : note.content_encrypted; const newClock = { ...mergeVectorClocks(serverClock, clientClock), [req.deviceId]: (serverClock[req.deviceId] || 0) + 1 }; + // Keep the state we are about to replace. + if (content !== undefined) { + const shrank = isBigShrink(note.content_encrypted, encContent); + snapshotNote(req.userId, req.params.id, req.deviceId, { + force: shrank, + reason: shrank ? "large-deletion" : "edit", + }); + } + db.prepare(`UPDATE notes SET title = COALESCE(?, title), content_encrypted = COALESCE(?, content_encrypted), @@ -394,10 +673,106 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => { WHERE id = ?`) .run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, tags ? JSON.stringify(tags) : null, JSON.stringify(newClock), req.deviceId, req.params.id); + if (Array.isArray(links)) replaceNoteLinks(req.userId, req.params.id, links); + broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, title } }, req.deviceId); res.json({ success: true, vectorClock: newClock }); }); +/* ─── Version history ─── */ +app.get("/api/notes/:id/versions", authMiddleware, (req, res) => { + const note = db.prepare("SELECT id FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); + if (!note) return res.status(404).json({ error: "Not found" }); + // Sizes only — sending every past body would be a large, mostly unread payload. + const rows = db.prepare(` + SELECT id, title, device_id, reason, created_at, + LENGTH(content_encrypted) AS content_size, + drawing_strokes IS NOT NULL AS has_drawing + FROM note_versions WHERE note_id = ? ORDER BY id DESC + `).all(req.params.id); + res.json(rows.map((r) => ({ ...r, has_drawing: !!r.has_drawing }))); +}); + +app.get("/api/notes/:id/versions/:versionId", authMiddleware, (req, res) => { + const v = db.prepare("SELECT * FROM note_versions WHERE id = ? AND note_id = ? AND user_id = ?") + .get(req.params.versionId, req.params.id, req.userId); + if (!v) return res.status(404).json({ error: "Not found" }); + + const out = { id: v.id, title: v.title, created_at: v.created_at, device_id: v.device_id, reason: v.reason }; + try { + out.content = v.content_encrypted ? decrypt(v.content_encrypted.toString()) : ""; + out.drawingStrokes = v.drawing_strokes ? JSON.parse(decrypt(v.drawing_strokes.toString())) : null; + } catch (err) { + console.error(`[decrypt] version ${v.id} unreadable:`, err.message); + return res.status(500).json({ error: "This version could not be decrypted" }); + } + res.json(out); +}); + +/* Restoring is itself an edit, so the state being replaced is snapshotted first + * — restoring the wrong version is never a one-way door. */ +app.post("/api/notes/:id/versions/:versionId/restore", authMiddleware, (req, res) => { + const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); + if (!note) return res.status(404).json({ error: "Not found" }); + const v = db.prepare("SELECT * FROM note_versions WHERE id = ? AND note_id = ? AND user_id = ?") + .get(req.params.versionId, req.params.id, req.userId); + if (!v) return res.status(404).json({ error: "Version not found" }); + + snapshotNote(req.userId, req.params.id, req.deviceId, { force: true, reason: "before-restore" }); + + db.prepare(`UPDATE notes SET content_encrypted = ?, drawing_strokes = ?, + updated_at = datetime('now'), version = version + 1, device_id = ? WHERE id = ?`) + .run(v.content_encrypted, v.drawing_strokes, req.deviceId, req.params.id); + + // Links live in the restored body, so the index has to follow it back. + let links = []; + try { + if (v.content_encrypted) { + const doc = JSON.parse(decrypt(v.content_encrypted.toString()))?.doc; + const found = new Set(); + (function walk(n) { + if (!n) return; + if (n.type === "noteLink" && n.attrs?.noteId) found.add(n.attrs.noteId); + if (Array.isArray(n.content)) n.content.forEach(walk); + })(doc); + links = [...found]; + } + } catch { /* a v1 body has no links to recover */ } + replaceNoteLinks(req.userId, req.params.id, links); + + db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, payload, device_id) VALUES (?, 'note', ?, 'update', ?, ?)") + .run(req.userId, req.params.id, JSON.stringify({ restored: v.id }), req.deviceId); + broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, restored: true } }, req.deviceId); + + res.json({ success: true, restoredFrom: v.created_at }); +}); + +/* Notes that reference this one. Trashed sources are excluded — a backlink from + * something in the bin is noise. */ +app.get("/api/notes/:id/backlinks", authMiddleware, (req, res) => { + const rows = db.prepare(` + SELECT n.id, n.title, n.notebook_id, n.updated_at, nb.name AS notebook_name + FROM note_links l + JOIN notes n ON n.id = l.source_id + LEFT JOIN notebooks nb ON nb.id = n.notebook_id + WHERE l.target_id = ? AND l.user_id = ? AND n.deleted_at IS NULL + ORDER BY n.updated_at DESC + `).all(req.params.id, req.userId); + res.json(rows); +}); + +/* Every link in one round trip, so the client can render backlink counts in a + * list without asking per note. */ +app.get("/api/links", authMiddleware, (req, res) => { + const rows = db.prepare(` + SELECT l.source_id, l.target_id, l.target_kind + FROM note_links l + JOIN notes n ON n.id = l.source_id + WHERE l.user_id = ? AND n.deleted_at IS NULL + `).all(req.userId); + res.json(rows); +}); + /* ─── Trash batch endpoint (MUST be before :id routes) ─── */ app.delete("/api/notes/trash/empty", authMiddleware, (req, res) => { db.prepare("DELETE FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL").run(req.userId); @@ -425,30 +800,55 @@ app.delete("/api/notes/:id/permanent", authMiddleware, (req, res) => { }); /* ─── Drawing data route ─── */ +const MAX_STROKES = 20000; + app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => { - const { drawingData } = req.body; + const { drawingStrokes, drawingData } = req.body; const note = db.prepare("SELECT id FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!note) return res.status(404).json({ error: "Not found" }); - if (!drawingData || typeof drawingData !== "string") return res.status(400).json({ error: "drawingData required" }); - const m = drawingData.match(/^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/); - if (!m) return res.status(400).json({ error: "Invalid drawing format" }); - const mime = m[1]; - const ext = mime === "image/png" ? "png" : mime === "image/webp" ? "webp" : "jpg"; - const base64Data = m[2]; - const filePath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.${ext}`); - - // Remove older file variants from previous format writes. - for (const oldExt of ["png", "jpg", "jpeg", "webp"]) { - const oldPath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.${oldExt}`); - if (oldPath !== filePath && fs.existsSync(oldPath)) { - try { fs.unlinkSync(oldPath); } catch {} + if (drawingStrokes !== undefined) { + // Vector strokes in the note's canonical coordinate space — the current format. + if (drawingStrokes !== null) { + if (typeof drawingStrokes !== "object" || !Array.isArray(drawingStrokes.strokes)) + return res.status(400).json({ error: "Invalid strokes format" }); + if (drawingStrokes.strokes.length > MAX_STROKES) + return res.status(413).json({ error: "Too many strokes" }); } + const empty = !drawingStrokes || drawingStrokes.strokes.length === 0; + const previous = db.prepare("SELECT drawing_strokes FROM notes WHERE id = ?").get(req.params.id); + // Clearing a drawing is always worth being able to undo. + snapshotNote(req.userId, req.params.id, req.deviceId, { + force: empty && !!previous?.drawing_strokes, + reason: empty ? "drawing-cleared" : "drawing", + }); + // A note's legacy raster is left untouched — it renders underneath the strokes + // on the client, so migrating an old drawing never loses anything. + db.prepare("UPDATE notes SET drawing_strokes = ?, updated_at = datetime('now'), version = version + 1, device_id = ? WHERE id = ?") + .run(empty ? null : Buffer.from(encrypt(JSON.stringify(drawingStrokes))), req.deviceId, req.params.id); + } else { + // Legacy raster path, kept so older clients keep working. + if (!drawingData || typeof drawingData !== "string") return res.status(400).json({ error: "drawingStrokes or drawingData required" }); + const m = drawingData.match(/^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/); + if (!m) return res.status(400).json({ error: "Invalid drawing format" }); + const mime = m[1]; + const ext = mime === "image/png" ? "png" : mime === "image/webp" ? "webp" : "jpg"; + const base64Data = m[2]; + const filePath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.${ext}`); + + // Remove older file variants from previous format writes. + for (const oldExt of ["png", "jpg", "jpeg", "webp"]) { + const oldPath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.${oldExt}`); + if (oldPath !== filePath && fs.existsSync(oldPath)) { + try { fs.unlinkSync(oldPath); } catch {} + } + } + fs.writeFileSync(filePath, Buffer.from(base64Data, "base64")); + + db.prepare("UPDATE notes SET drawing_data_path = ?, updated_at = datetime('now'), version = version + 1, device_id = ? WHERE id = ?") + .run(filePath, req.deviceId, req.params.id); } - fs.writeFileSync(filePath, Buffer.from(base64Data, "base64")); - db.prepare("UPDATE notes SET drawing_data_path = ?, updated_at = datetime('now'), version = version + 1, device_id = ? WHERE id = ?") - .run(filePath, req.deviceId, req.params.id); db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, payload, device_id) VALUES (?, 'note', ?, 'update', ?, ?)") .run(req.userId, req.params.id, JSON.stringify({ drawingUpdated: true }), req.deviceId); broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, drawingUpdated: true } }, req.deviceId); @@ -456,6 +856,69 @@ app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => { res.json({ success: true }); }); +/* ─── Attachments ─── + * + * Images used to be inlined into the note body as base64 data URIs: a few + * screenshots turned a note into megabytes that were re-encrypted and re-sent on + * every keystroke. They are now separate blobs the body only references. + * + * Files are named by SHA-256 of their bytes, so the same image pasted into + * several notes is stored once. */ +const ATTACHMENT_DIR = path.join(UPLOAD_DIR, "attachments"); +if (!fs.existsSync(ATTACHMENT_DIR)) fs.mkdirSync(ATTACHMENT_DIR, { recursive: true }); + +const ALLOWED_ATTACHMENT_MIME = new Set(["image/png", "image/jpeg", "image/webp", "image/gif", "image/avif"]); +const MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024; + +app.post("/api/attachments", authMiddleware, (req, res) => { + const { data, filename } = req.body || {}; + if (typeof data !== "string") return res.status(400).json({ error: "data required" }); + + const m = data.match(/^data:([a-z0-9.+/-]+);base64,(.+)$/i); + if (!m) return res.status(400).json({ error: "Expected a base64 data URL" }); + const mime = m[1].toLowerCase(); + if (!ALLOWED_ATTACHMENT_MIME.has(mime)) return res.status(415).json({ error: `Unsupported type: ${mime}` }); + + const buf = Buffer.from(m[2], "base64"); + if (buf.length === 0) return res.status(400).json({ error: "Empty file" }); + if (buf.length > MAX_ATTACHMENT_BYTES) return res.status(413).json({ error: "File too large" }); + + // Trust the bytes, not the declared type. + const sniffed = detectImageMime(buf); + if (mime !== "image/gif" && mime !== "image/avif" && sniffed !== mime) { + return res.status(415).json({ error: "File content does not match its type" }); + } + + const sha256 = crypto.createHash("sha256").update(buf).digest("hex"); + const existing = db.prepare("SELECT id, mime, size FROM attachments WHERE user_id = ? AND sha256 = ?").get(req.userId, sha256); + if (existing) return res.json({ id: existing.id, mime: existing.mime, size: existing.size, deduplicated: true }); + + const filePath = path.join(ATTACHMENT_DIR, sha256); + if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, buf); + + const id = uuid(); + db.prepare("INSERT INTO attachments (id, user_id, sha256, mime, size, filename) VALUES (?, ?, ?, ?, ?, ?)") + .run(id, req.userId, sha256, mime, buf.length, typeof filename === "string" ? filename.slice(0, 200) : null); + + res.json({ id, mime, size: buf.length, deduplicated: false }); +}); + +app.get("/api/attachments/:id", authMiddleware, (req, res) => { + const a = db.prepare("SELECT * FROM attachments WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); + if (!a) return res.status(404).json({ error: "Not found" }); + + // The hash comes from our own database, never from the request, so this path + // cannot be steered outside the attachment directory. + const filePath = path.join(ATTACHMENT_DIR, a.sha256); + if (!fs.existsSync(filePath)) return res.status(404).json({ error: "File missing" }); + + res.setHeader("Content-Type", a.mime); + res.setHeader("Content-Length", a.size); + res.setHeader("Cache-Control", "private, max-age=31536000, immutable"); // content-addressed: never changes + res.setHeader("X-Content-Type-Options", "nosniff"); + fs.createReadStream(filePath).pipe(res); +}); + /* ─── PDF upload route ─── */ app.post("/api/notes/pdf", authMiddleware, upload.single("pdf"), (req, res) => { if (!req.file) return res.status(400).json({ error: "No file uploaded" }); @@ -528,42 +991,68 @@ const server = http.createServer(app); const wss = new WebSocketServer({ server, path: "/ws" }); const userConnections = new Map(); // userId -> Set -wss.on("connection", (ws, req) => { - let userId = null; +const AUTH_GRACE_MS = 10000; +const HEARTBEAT_MS = 30000; + +wss.on("connection", (ws) => { + ws._userId = null; + ws._alive = true; + ws.on("pong", () => { ws._alive = true; }); + + // Drop sockets that connect and never authenticate. + const authTimer = setTimeout(() => { + if (!ws._userId) ws.close(4001, "auth timeout"); + }, AUTH_GRACE_MS); ws.on("message", (data) => { try { const msg = JSON.parse(data); - if (msg.type === "auth") { - const payload = jwt.verify(msg.token, JWT_SECRET); - userId = payload.userId; - const deviceId = msg.deviceId || "unknown"; + if (msg.type !== "auth") return; + const payload = jwt.verify(msg.token, JWT_SECRET); + ws._userId = payload.userId; + ws._deviceId = msg.deviceId || "unknown"; + clearTimeout(authTimer); - if (!userConnections.has(userId)) userConnections.set(userId, new Map()); - userConnections.get(userId).set(deviceId, ws); - ws._deviceId = deviceId; + // A Set, not a Map keyed by device: two tabs on one machine share a + // device id, and keying by it meant the second tab silently replaced the + // first and the first stopped receiving updates. + if (!userConnections.has(ws._userId)) userConnections.set(ws._userId, new Set()); + userConnections.get(ws._userId).add(ws); - ws.send(JSON.stringify({ type: "auth_ok" })); - } + ws.send(JSON.stringify({ type: "auth_ok" })); } catch { - ws.send(JSON.stringify({ type: "error", message: "Invalid message" })); + try { ws.send(JSON.stringify({ type: "error", message: "Invalid message" })); } catch {} } }); ws.on("close", () => { - if (userId && userConnections.has(userId)) { - userConnections.get(userId).delete(ws._deviceId); - if (userConnections.get(userId).size === 0) userConnections.delete(userId); - } + clearTimeout(authTimer); + const set = ws._userId && userConnections.get(ws._userId); + if (!set) return; + set.delete(ws); + if (set.size === 0) userConnections.delete(ws._userId); }); + ws.on("error", () => { try { ws.terminate(); } catch {} }); }); +// Reap sockets that stopped answering — otherwise half-open connections from +// sleeping phones accumulate forever. +const heartbeat = setInterval(() => { + for (const ws of wss.clients) { + if (!ws._alive) { ws.terminate(); continue; } + ws._alive = false; + try { ws.ping(); } catch {} + } +}, HEARTBEAT_MS); +heartbeat.unref(); + function broadcastToUser(userId, message, excludeDeviceId) { - if (!userConnections.has(userId)) return; - const connections = userConnections.get(userId); - for (const [deviceId, ws] of connections) { - if (deviceId !== excludeDeviceId && ws.readyState === 1) { - ws.send(JSON.stringify(message)); + const conns = userConnections.get(userId); + if (!conns) return; + const payload = JSON.stringify(message); + for (const ws of conns) { + if (ws._deviceId !== excludeDeviceId && ws.readyState === 1) { + try { ws.send(payload); } catch {} } } } @@ -573,10 +1062,91 @@ app.get("/api/health", (req, res) => { res.json({ status: "ok", version: "1.0.0", uptime: process.uptime() }); }); +/* ─── Error handling ─── */ +app.use((req, res) => res.status(404).json({ error: "Not found" })); + +// Without this, a throw inside a route hangs the request until the client times +// out, and the stack trace goes to the client in Express' default handler. +app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars + console.error("[error]", req.method, req.path, err); + if (res.headersSent) return; + if (err?.type === "entity.too.large") return res.status(413).json({ error: "Payload too large" }); + res.status(500).json({ error: "Internal server error" }); +}); + +process.on("unhandledRejection", (err) => console.error("[unhandledRejection]", err)); +process.on("uncaughtException", (err) => console.error("[uncaughtException]", err)); + +/* ─── Housekeeping ─── */ +// sync_log is append-only and grew without bound; every note edit added a row. +const SYNC_LOG_RETENTION_DAYS = 30; +function pruneSyncLog() { + try { + const r = db.prepare(`DELETE FROM sync_log WHERE timestamp < datetime('now', '-${SYNC_LOG_RETENTION_DAYS} days')`).run(); + if (r.changes) console.log(` 🧹 pruned ${r.changes} sync_log rows older than ${SYNC_LOG_RETENTION_DAYS} days`); + } catch (err) { console.error("[prune] failed:", err.message); } +} + +/* Per-note count is capped on write; this only clears out versions of notes + * that stopped being edited long ago. */ +function pruneOldVersions() { + try { + const r = db.prepare(`DELETE FROM note_versions WHERE created_at < datetime('now', '-${VERSION_RETENTION_DAYS} days')`).run(); + if (r.changes) console.log(` 🧹 pruned ${r.changes} note versions older than ${VERSION_RETENTION_DAYS} days`); + } catch (err) { console.error("[prune] failed:", err.message); } +} + +/* Attachment rows cascade away with their owner, but the files they point at do + * not. The database is authoritative: a blob nobody has a row for is garbage. + * + * Only files that have sat untouched for an hour are considered, so an upload + * in flight — written to disk just before its row is inserted — is never hit. */ +const ORPHAN_GRACE_MS = 60 * 60 * 1000; +function pruneOrphanAttachments() { + try { + if (!fs.existsSync(ATTACHMENT_DIR)) return; + const referenced = new Set(db.prepare("SELECT sha256 FROM attachments").all().map((r) => r.sha256)); + let removed = 0; + for (const name of fs.readdirSync(ATTACHMENT_DIR)) { + if (referenced.has(name)) continue; + const full = path.join(ATTACHMENT_DIR, name); + try { + if (Date.now() - fs.statSync(full).mtimeMs < ORPHAN_GRACE_MS) continue; + fs.unlinkSync(full); + removed++; + } catch { /* leave anything we cannot inspect alone */ } + } + if (removed) console.log(` 🧹 removed ${removed} orphaned attachment file(s)`); + } catch (err) { console.error("[prune] attachments failed:", err.message); } +} + +const housekeeping = () => { pruneSyncLog(); pruneOldVersions(); pruneOrphanAttachments(); }; +housekeeping(); +setInterval(housekeeping, 24 * 60 * 60 * 1000).unref(); + /* ─── Start server ─── */ server.listen(PORT, () => { console.log(`\n ✏️ InkNote Server running on http://localhost:${PORT}`); console.log(` 📡 WebSocket available at ws://localhost:${PORT}/ws`); - console.log(` 🔒 JWT Secret: ${JWT_SECRET.slice(0, 12)}...`); - console.log(` 💾 Database: ${path.join(DATA_DIR, "inknote.db")}\n`); + console.log(` 💾 Database: ${path.join(DATA_DIR, "inknote.db")}`); + console.log(` 🌐 CORS: ${ALLOWED_ORIGINS.length ? ALLOWED_ORIGINS.join(", ") : "same-origin only"}\n`); }); + +/* ─── Graceful shutdown ─── */ +let shuttingDown = false; +function shutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + console.log(`\n ${signal} received — shutting down...`); + clearInterval(heartbeat); + for (const ws of wss.clients) { try { ws.close(1001, "server shutting down"); } catch {} } + server.close(() => { + try { db.close(); } catch (err) { console.error("[shutdown] db.close:", err.message); } + console.log(" ✅ closed cleanly\n"); + process.exit(0); + }); + // Don't hang forever on a stuck connection. + setTimeout(() => { try { db.close(); } catch {} process.exit(1); }, 10000).unref(); +} +process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("SIGINT", () => shutdown("SIGINT")); diff --git a/build.sh b/build.sh old mode 100755 new mode 100644 index 9189000..67e0bc1 --- a/build.sh +++ b/build.sh @@ -2,7 +2,7 @@ set -e REGISTRY="gitea.honeyy.duckdns.org" -OWNER="docker" +OWNER="${GITEA_OWNER:-marc}" TAG="${1:-latest}" FRONTEND_IMAGE="${REGISTRY}/${OWNER}/inknote-web:${TAG}" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..e93c65f --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,73 @@ +# ───────────────────────────────────────────── +# InkNote — lokales Test-Setup (baut aus dem Quellcode) +# +# Prod-nah (nginx + gebautes Bundle) auf :3000 +# docker compose -f docker-compose.dev.yml up -d --build +# +# Mit Hot-Reload-Dev-Server auf :5173 zusätzlich +# docker compose -f docker-compose.dev.yml --profile dev up -d --build +# +# docker compose -f docker-compose.dev.yml logs -f +# docker compose -f docker-compose.dev.yml down +# +# Secrets kommen aus .env (nicht committen!) +# Daten liegen unter ${INKNOTE_DATA_DIR:-./data} auf dem Host. +# ───────────────────────────────────────────── + +services: + inknote-server: + build: + context: ./backend + dockerfile: Dockerfile + container_name: inknote-server-dev + restart: unless-stopped + environment: + - PORT=4000 + - JWT_SECRET=${JWT_SECRET} + - ENCRYPTION_KEY=${ENCRYPTION_KEY} + - NODE_ENV=development + # Bind mounts, not named volumes: the data sits in a plain directory on the + # Docker host where the host's own backup picks it up. + user: "${INKNOTE_UID:-1000}:${INKNOTE_GID:-1000}" + volumes: + - ${INKNOTE_DATA_DIR:-./data}/db:/app/data + - ${INKNOTE_DATA_DIR:-./data}/uploads:/app/uploads + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + inknote-web: + build: + context: ./frontend + dockerfile: dockerfile + container_name: inknote-web-dev + restart: unless-stopped + ports: + - "3000:80" + depends_on: + inknote-server: + condition: service_healthy + + # Vite mit Hot-Reload — nur mit "--profile dev" + inknote-vite: + image: node:20-alpine + container_name: inknote-vite-dev + profiles: ["dev"] + working_dir: /app + command: sh -c "npm install && npm run dev" + environment: + - VITE_API_TARGET=http://inknote-server:4000 + volumes: + - ./frontend:/app + - inknote-dev-node-modules:/app/node_modules + ports: + - "5173:5173" + depends_on: + inknote-server: + condition: service_healthy + +volumes: + inknote-dev-node-modules: diff --git a/docker-compose.yml b/docker-compose.yml index f1d7253..e30d9b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,9 +5,15 @@ version: "3.8" # Pull images from Gitea container registry # # Usage: +# mkdir -p /srv/inknote/db /srv/inknote/uploads +# chown -R 1000:1000 /srv/inknote # docker login gitea.honeyy.duckdns.org -# docker-compose pull -# docker-compose up -d +# docker compose pull +# docker compose up -d +# +# Data lives under INKNOTE_DATA_DIR (default /srv/inknote) on the host. +# Back that path up together with the .env holding ENCRYPTION_KEY — the +# database is encrypted and is unreadable without that key. # ───────────────────────────────────────────── services: @@ -20,9 +26,13 @@ services: - JWT_SECRET=${JWT_SECRET} - ENCRYPTION_KEY=${ENCRYPTION_KEY} - NODE_ENV=production + # Bind mounts, not named volumes: the data sits in a plain directory on the + # Docker host, so whatever backs up the host also backs up InkNote. + # Create it first: mkdir -p /srv/inknote/{db,uploads} && chown -R 1000:1000 /srv/inknote + user: "${INKNOTE_UID:-1000}:${INKNOTE_GID:-1000}" volumes: - - inknote-data:/app/data - - inknote-uploads:/app/uploads + - ${INKNOTE_DATA_DIR:-/srv/inknote}/db:/app/data + - ${INKNOTE_DATA_DIR:-/srv/inknote}/uploads:/app/uploads healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"] interval: 30s diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..1a99321 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +package-lock.json diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 0000000..a46f9fc --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "printWidth": 110, + "singleQuote": false, + "semi": true, + "trailingComma": "es5", + "arrowParens": "always", + "bracketSameLine": false +} diff --git a/frontend/index.html b/frontend/index.html index d89647b..393c4fa 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -11,7 +11,7 @@ - + InkNote diff --git a/frontend/nginx.conf b/frontend/nginx.conf index da34936..6f12c7f 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -29,8 +29,8 @@ server { try_files $uri =404; } - # Icons - location ~* ^/icon-.*\.png$ { + # Icons (icon-*.png and apple-touch-icon.png) + location ~* ^/(icon-.*|apple-touch-icon)\.png$ { add_header Cache-Control "public, max-age=86400"; try_files $uri =404; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..b164984 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3956 @@ +{ + "name": "inknote-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "inknote-frontend", + "version": "1.0.0", + "dependencies": { + "@tiptap/extension-image": "^2.27.2", + "@tiptap/extension-link": "^2.27.2", + "@tiptap/extension-placeholder": "^2.27.2", + "@tiptap/extension-table": "^2.27.2", + "@tiptap/extension-table-cell": "^2.27.2", + "@tiptap/extension-table-header": "^2.27.2", + "@tiptap/extension-table-row": "^2.27.2", + "@tiptap/extension-task-item": "^2.27.2", + "@tiptap/extension-task-list": "^2.27.2", + "@tiptap/extension-underline": "^2.27.2", + "@tiptap/html": "^2.27.2", + "@tiptap/pm": "^2.27.2", + "@tiptap/react": "^2.27.2", + "@tiptap/starter-kit": "^2.27.2", + "@tiptap/suggestion": "^2.27.2", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.3.1", + "fake-indexeddb": "^6.2.5", + "jsdom": "^25.0.1", + "prettier": "^3.9.6", + "typescript": "^5.9.3", + "vite": "^5.4.0", + "vitest": "^2.1.9" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tiptap/core": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz", + "integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz", + "integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz", + "integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz", + "integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz", + "integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz", + "integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz", + "integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz", + "integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz", + "integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz", + "integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz", + "integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz", + "integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz", + "integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-history": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz", + "integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz", + "integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-image": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.27.2.tgz", + "integrity": "sha512-5zL/BY41FIt72azVrCrv3n+2YJ/JyO8wxCcA4Dk1eXIobcgVyIdo4rG39gCqIOiqziAsqnqoj12QHTBtHsJ6mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz", + "integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz", + "integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz", + "integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz", + "integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz", + "integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.27.2.tgz", + "integrity": "sha512-IjsgSVYJRjpAKmIoapU0E2R4E2FPY3kpvU7/1i7PUYisylqejSJxmtJPGYw0FOMQY9oxnEEvfZHMBA610tqKpg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz", + "integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-table": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.27.2.tgz", + "integrity": "sha512-pDbhOpT5phZkcsyPjGBQlXv0+0hmdrvqHJ+dJjkGcCtlfy2pHiEIhmIItOFagc7wXy8G9iUFZ9Jie4zvDf+brg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-table-cell": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.27.2.tgz", + "integrity": "sha512-9Lk46MjZMFzVZfOj9Kd7VgC6Odt6vmEhlCYVumErShUY7EkFqCw3b2IYoUtQkntfOEx/Afnhff/okNQwPsJeUA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-table-header": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.27.2.tgz", + "integrity": "sha512-ZEb6lbG0NbbodWLV0b4BS/QrDIPlUbCcuOsUxzqVvlMUY1Vg6Fj6fKwLaBcsIUDHi8sxZDBEgYEDw3BR/zcO6A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-table-row": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.27.2.tgz", + "integrity": "sha512-Nw9+tA56Y5HtLVP01NGCZSUuTQhJPtfK9OfmDgGgcxynn2cRVdEtj+9FNZqRhQ1iRVaAI+Rd4xRvX9qYePMOxw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-task-item": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-2.27.2.tgz", + "integrity": "sha512-ZBSqj/dygB/Rp5K9qOxRVwASTZCmKVoTq8C59KvMgD/aFjJxhq/w2dZaWkCUEXEep+NmvJqo0kfeAEMY5UDnGg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-task-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-2.27.2.tgz", + "integrity": "sha512-5nupAewdzZ9F3599oAcaK0WkDH04wdACAVBPM4zG7InlIpkbho3txB7zWmm64OxfhCMIMGKiXY1q0bw9i0QBGQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz", + "integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz", + "integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.27.2.tgz", + "integrity": "sha512-gPOsbAcw1S07ezpAISwoO8f0RxpjcSH7VsHEFDVuXm4ODE32nhvSinvHQjv2icRLOXev+bnA7oIBu7Oy859gWQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/html": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/html/-/html-2.27.2.tgz", + "integrity": "sha512-WiZgAvFjUprjyAczxAHLN9k++w7klwsCJ7EJDljtW/QXQ476Q0GqNtaHG4WRuW25cBH7c7AWZFptALeak2OE4Q==", + "license": "MIT", + "dependencies": { + "zeed-dom": "^0.15.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz", + "integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.23.0", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.4.1", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.37.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.2.tgz", + "integrity": "sha512-0EAs8Cpkfbvben1PZ34JN2Nd79Dhioynm2jML27DBbf1VWPk+FFWFGTMLUT0bu+Np5iVxio8fqV9t0mc4D6thA==", + "license": "MIT", + "dependencies": { + "@tiptap/extension-bubble-menu": "^2.27.2", + "@tiptap/extension-floating-menu": "^2.27.2", + "@types/use-sync-external-store": "^0.0.6", + "fast-deep-equal": "^3", + "use-sync-external-store": "^1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz", + "integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^2.27.2", + "@tiptap/extension-blockquote": "^2.27.2", + "@tiptap/extension-bold": "^2.27.2", + "@tiptap/extension-bullet-list": "^2.27.2", + "@tiptap/extension-code": "^2.27.2", + "@tiptap/extension-code-block": "^2.27.2", + "@tiptap/extension-document": "^2.27.2", + "@tiptap/extension-dropcursor": "^2.27.2", + "@tiptap/extension-gapcursor": "^2.27.2", + "@tiptap/extension-hard-break": "^2.27.2", + "@tiptap/extension-heading": "^2.27.2", + "@tiptap/extension-history": "^2.27.2", + "@tiptap/extension-horizontal-rule": "^2.27.2", + "@tiptap/extension-italic": "^2.27.2", + "@tiptap/extension-list-item": "^2.27.2", + "@tiptap/extension-ordered-list": "^2.27.2", + "@tiptap/extension-paragraph": "^2.27.2", + "@tiptap/extension-strike": "^2.27.2", + "@tiptap/extension-text": "^2.27.2", + "@tiptap/extension-text-style": "^2.27.2", + "@tiptap/pm": "^2.27.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/suggestion": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.27.2.tgz", + "integrity": "sha512-dQyvCIg0hcAVeh4fCIVCxogvbp+bF+GpbUb8sNlgnGrmHXnapGxzkvrlHnvneXZxLk/j7CxmBPKJNnm4Pbx4zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.402", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz", + "integrity": "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz", + "integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.5.tgz", + "integrity": "sha512-ac8trNQ01ybKDRTcfUc56LZufG3oYyU4N25qSXgp8dS0U4JtzzCj7oQlKu5v09VSmS5IseYoQ2yDkTbo7f7D8Q==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz", + "integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.2", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.2.tgz", + "integrity": "sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.9.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz", + "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zeed-dom": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/zeed-dom/-/zeed-dom-0.15.1.tgz", + "integrity": "sha512-dtZ0aQSFyZmoJS0m06/xBN1SazUBPL5HpzlAcs/KcRW0rzadYw12deQBjeMhGKMMeGEp7bA9vmikMLaO4exBcg==", + "license": "MIT", + "dependencies": { + "css-what": "^6.1.0", + "entities": "^5.0.0" + }, + "engines": { + "node": ">=14.13.1" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/holtwick" + } + }, + "node_modules/zeed-dom/node_modules/entities": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-5.0.0.tgz", + "integrity": "sha512-BeJFvFRJddxobhvEdm5GqHzRV/X+ACeuw0/BuuxsCh1EUZcAIz8+kYmBp/LrQuloy6K1f3a0M7+IhmZ7QnkISA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json index 794e5f5..81d0f0a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,14 +6,40 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "format": "prettier --write 'src/**/*.{js,jsx,ts,tsx,css}'" }, "dependencies": { + "@tiptap/extension-image": "^2.27.2", + "@tiptap/extension-link": "^2.27.2", + "@tiptap/extension-placeholder": "^2.27.2", + "@tiptap/extension-table": "^2.27.2", + "@tiptap/extension-table-cell": "^2.27.2", + "@tiptap/extension-table-header": "^2.27.2", + "@tiptap/extension-table-row": "^2.27.2", + "@tiptap/extension-task-item": "^2.27.2", + "@tiptap/extension-task-list": "^2.27.2", + "@tiptap/extension-underline": "^2.27.2", + "@tiptap/html": "^2.27.2", + "@tiptap/pm": "^2.27.2", + "@tiptap/react": "^2.27.2", + "@tiptap/starter-kit": "^2.27.2", + "@tiptap/suggestion": "^2.27.2", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", "@vitejs/plugin-react": "^4.3.1", - "vite": "^5.4.0" + "fake-indexeddb": "^6.2.5", + "jsdom": "^25.0.1", + "prettier": "^3.9.6", + "typescript": "^5.9.3", + "vite": "^5.4.0", + "vitest": "^2.1.9" } -} \ No newline at end of file +} diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..99135c1981f0c24d82de0a14fe51cb4901dba1dc GIT binary patch literal 7579 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�T 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 literal 0 HcmV?d00001 diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..696f1a14357b1be1b57834ac11b637ab30c01a66 GIT binary patch literal 7535 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 V7|bXX1Ye{AHkM~CYRr6+{tuwWlnwv@ literal 0 HcmV?d00001 diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..8c693839a3cfc85bbc86e296fc2d6bdc0c99818d GIT binary patch literal 21070 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 literal 0 HcmV?d00001 diff --git a/frontend/public/icon-maskable-192.png b/frontend/public/icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b93f1e3925c223fd55684d85089e952065122bb1 GIT binary patch literal 3719 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 literal 0 HcmV?d00001 diff --git a/frontend/public/icon-maskable-512.png b/frontend/public/icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..ed07243ff7487b45eacf9874a00d96fb5607da40 GIT binary patch literal 11850 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 { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..9e8d6e1 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,563 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +import * as sync from "./syncEngine"; +import { useNoteData } from "./hooks/useNoteData"; +import { useSearchIndex } from "./hooks/useSearchIndex"; +import { useResponsive } from "./hooks/useResponsive"; +import { usePwaInstall } from "./hooks/usePwaInstall"; +import { useNotebookDnd } from "./hooks/useNotebookDnd"; +import { useNavigationHistory } from "./hooks/useNavigationHistory"; + +import { COVERS, DEFAULT_FOLDERS } from "./ui/constants.jsx"; +import { I } from "./ui/icons.jsx"; +import { makeTheme, FONT_UI } from "./ui/theme"; +import { ConfirmDialog } from "./ui/ConfirmDialog.jsx"; +import { ContextMenu } from "./ui/ContextMenu.jsx"; +import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx"; +import { AccountMenu } from "./ui/AccountMenu.jsx"; +import { AuthScreen } from "./ui/AuthScreen.jsx"; +import { Sidebar } from "./ui/Sidebar.jsx"; +import { Dashboard } from "./features/notebooks/Dashboard.jsx"; +import { NoteList } from "./features/notes/NoteList.jsx"; +import { EditorPane } from "./features/notes/EditorPane.jsx"; +import { HistoryModal } from "./features/notes/HistoryModal.jsx"; +import { DrawingLayer } from "./features/drawing/DrawingLayer.jsx"; +import { CANON_W, CANON_H, PREVIEW_DRAWING_W } from "./features/drawing/strokes.js"; + +/** Views that are not a notebook and therefore cannot hold a new note. */ +const VIRTUAL_FOLDERS = ["all", "starred", "trash", "dashboard"]; + +const EMPTY_BODY = { content: "", drawingData: null, drawingStrokes: null }; + +/** + * Root of the application. + * + * Holds the things that describe *where the user is* — the open notebook, the + * open note, the search box, whichever dialog is up — and hands the data itself + * to `useNoteData`. Rendering is delegated to the three panes below. + */ +export default function App() { + const [dark, setDark] = useState(true); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [syncStatus, setSyncStatus] = useState("synced"); + + const [activeFolder, setActiveFolder] = useState("dashboard"); + const [activeNote, setActiveNote] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + + const [showNewNb, setShowNewNb] = useState(false); + const [editingNb, setEditingNb] = useState(null); + const [showAccount, setShowAccount] = useState(false); + const [historyFor, setHistoryFor] = useState(null); + const [ctxMenu, setCtxMenu] = useState(null); + const [confirmDialog, setConfirmDialog] = useState(null); + // Bumped on a version restore to force a fresh editor on the restored body. + const [restoreEpoch, setRestoreEpoch] = useState(0); + + const T = useMemo(() => makeTheme(dark), [dark]); + const { isMobile, sidebarOpen, setSidebarOpen, mobileView, setMobileView } = useResponsive(); + const { canInstall, promptInstall } = usePwaInstall(); + + const data = useNoteData(user, activeNote); + const { notes, notebooks, noteContents, backlinks } = data; + const search = useSearchIndex(user, notes, searchQuery); + const dnd = useNotebookDnd(data.reorderNotebooks); + + useEffect(() => sync.onSyncStatus(setSyncStatus), []); + + /* ── Session ── */ + useEffect(() => { + (async () => { + try { + const u = await sync.restoreSession(); + if (u) { + setUser(u); + await data.reload(); + try { + await sync.fullSync(); + await data.reload(); + } catch (e) { + if (e.message === "AUTH") setUser(null); + } + } + } catch { + /* a broken session must not block the app from starting */ + } + setLoading(false); + })(); + }, []); + + const onLogin = async (u) => { + setUser(u); + try { + await sync.fullSync(); + } catch { + /* first sync can fail offline; local data still loads */ + } + await data.reload(); + }; + + const onLogout = async () => { + await sync.logout(); + setUser(null); + data.reset(); + setActiveNote(null); + setActiveFolder("dashboard"); + }; + + /* ── Navigation ── */ + const openFolder = useCallback( + (id) => { + setActiveFolder(id); + setActiveNote(null); + if (isMobile) { + setSidebarOpen(false); + setMobileView("list"); + } + }, + [isMobile, setSidebarOpen, setMobileView] + ); + + const openNote = useCallback( + (id) => { + setActiveNote(id); + if (isMobile) setMobileView("editor"); + }, + [isMobile, setMobileView] + ); + + const closeNote = useCallback(() => { + setActiveNote(null); + if (isMobile) setMobileView("list"); + }, [isMobile, setMobileView]); + + // Adopts a location the user reached with the browser's back button. + const applyNavState = useCallback( + (s) => { + setActiveFolder(s.folder); + setActiveNote(s.note); + setMobileView(s.view); + }, + [setMobileView] + ); + useNavigationHistory({ folder: activeFolder, note: activeNote, view: mobileView }, isMobile, applyNavState); + + /* ── Derived note lists ── */ + const activeNotes = useMemo(() => notes.filter((n) => !n.trashed), [notes]); + const trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]); + const isTrashView = activeFolder === "trash"; + + const filteredNotes = useMemo(() => { + let list; + if (activeFolder === "trash") list = trashedNotes; + else if (activeFolder === "starred") list = activeNotes.filter((n) => n.starred); + else if (activeFolder === "all" || activeFolder === "dashboard") list = activeNotes; + else list = activeNotes.filter((n) => n.folderId === activeFolder); + + if (searchQuery.trim()) { + // A search reads every note's text, so results keep the index's relevance + // order instead of being re-sorted by date. + if (search.results) { + const rank = new Map(search.results.map((h, i) => [h.id, i])); + return list.filter((n) => rank.has(n.id)).sort((a, b) => rank.get(a.id) - rank.get(b.id)); + } + // Until the index answers, fall back to matching titles only. + const q = searchQuery.toLowerCase(); + return list.filter((n) => n.title.toLowerCase().includes(q)); + } + return [...list].sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); + }, [activeFolder, searchQuery, search.results, activeNotes, trashedNotes]); + + const noteMeta = activeNote ? notes.find((n) => n.id === activeNote) : null; + // The editor opens as soon as the metadata is known; the body fills in when + // it arrives, rather than blocking on a fetch. + const openNoteData = noteMeta ? { ...noteMeta, ...EMPTY_BODY, ...noteContents[activeNote] } : null; + + const folderName = useMemo(() => { + const builtIn = DEFAULT_FOLDERS.find((f) => f.id === activeFolder); + if (builtIn) return builtIn.name; + return notebooks.find((n) => n.id === activeFolder)?.name || "Notes"; + }, [activeFolder, notebooks]); + + /* ── Note links ── */ + // Candidates for the @-menu. Notes rank above notebooks, trashed notes never + // appear, and the open note cannot link to itself. + const searchLinkTargets = useCallback( + (query) => { + const q = (query || "").toLowerCase().trim(); + const match = (name) => !q || name.toLowerCase().includes(q); + return [ + ...notes + .filter((n) => !n.trashed && n.id !== activeNote && match(n.title || "Untitled")) + .slice(0, 8) + .map((n) => ({ id: n.id, label: n.title || "Untitled", kind: "note" })), + ...notebooks + .filter((nb) => match(nb.name)) + .slice(0, 4) + .map((nb) => ({ id: nb.id, label: nb.name, kind: "notebook" })), + ]; + }, + [notes, notebooks, activeNote] + ); + + // A link stores the label it had when inserted; this keeps it showing the + // target's current title, and returns null once the target is gone. + const resolveLinkLabel = useCallback( + (id) => { + const n = notes.find((x) => x.id === id && !x.trashed); + if (n) return n.title || "Untitled"; + return notebooks.find((x) => x.id === id)?.name ?? null; + }, + [notes, notebooks] + ); + + const openLinkTarget = useCallback( + (id, kind) => { + if (kind === "notebook") return openFolder(id); + const target = notes.find((n) => n.id === id); + if (!target) return; + if (target.folderId) setActiveFolder(target.folderId); + openNote(id); + }, + [notes, openFolder, openNote] + ); + + const linkProps = useMemo( + () => ({ openLinkTarget, searchLinkTargets, resolveLinkLabel }), + [openLinkTarget, searchLinkTargets, resolveLinkLabel] + ); + + /* ── Actions ── */ + const createNote = async () => { + const folderId = VIRTUAL_FOLDERS.includes(activeFolder) ? notebooks[0]?.id : activeFolder; + if (!folderId) return; + const note = await data.createNote(folderId); + if (activeFolder === "dashboard") setActiveFolder(folderId); + openNote(note.id); + }; + + /** Runs a destructive action behind a confirmation prompt. */ + const confirm = (dialog) => + setConfirmDialog({ + ...dialog, + onConfirm: async () => { + await dialog.onConfirm(); + setConfirmDialog(null); + }, + }); + + const trashNote = (id) => + confirm({ + title: "Move to Trash", + message: `Move "${notes.find((n) => n.id === id)?.title}" to trash?`, + confirmLabel: "Move to Trash", + onConfirm: async () => { + await data.trashNote(id); + if (activeNote === id) closeNote(); + }, + }); + + const restoreNote = async (id) => { + await data.restoreNote(id); + if (activeNote === id) closeNote(); + }; + + const permanentDeleteNote = (id) => + confirm({ + title: "Delete Permanently", + message: `Permanently delete "${notes.find((n) => n.id === id)?.title}"? This cannot be undone.`, + onConfirm: async () => { + await data.permanentDeleteNote(id); + if (activeNote === id) closeNote(); + }, + }); + + const emptyTrash = () => + confirm({ + title: "Empty Trash", + message: `Permanently delete all ${trashedNotes.length} trashed notes?`, + onConfirm: async () => { + await data.emptyTrash(); + setActiveNote(null); + }, + }); + + const createNotebook = async (fields) => { + const nb = await data.createNotebook(fields); + setShowNewNb(false); + setActiveFolder(nb.id); + }; + + const updateNotebook = async (id, updates) => { + await data.updateNotebook(id, updates); + setEditingNb(null); + }; + + const deleteNotebook = (id) => { + const nb = notebooks.find((n) => n.id === id); + const count = activeNotes.filter((n) => n.folderId === id).length; + confirm({ + title: "Delete Notebook", + message: `Delete "${nb?.name}"${count > 0 ? ` and trash its ${count} notes` : ""} permanently?`, + onConfirm: async () => { + await data.deleteNotebook(id); + if (activeFolder === id) setActiveFolder("dashboard"); + }, + }); + }; + + /* ── Context menus ── */ + const notebookContextMenu = (e, nb) => { + e.preventDefault(); + e.stopPropagation(); + setCtxMenu({ + x: e.clientX, + y: e.clientY, + items: [ + { label: "Edit", icon: I.edit, action: () => setEditingNb(nb) }, + { divider: true }, + { label: "Delete Notebook", icon: I.trash, danger: true, action: () => deleteNotebook(nb.id) }, + ], + }); + }; + + /** "Move to..." lists every notebook except the one the note is already in. */ + const moveSubmenu = (note) => { + const targets = notebooks + .filter((nb) => nb.id !== note.folderId) + .map((nb) => ({ + label: nb.name, + colorDot: COVERS[nb.cover % COVERS.length].bg, + action: () => data.moveNote(note.id, nb.id), + })); + return targets.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : []; + }; + + const noteContextMenu = (e, note) => { + e.preventDefault(); + e.stopPropagation(); + const items = note.trashed + ? [ + { label: "Restore", icon: I.restore, action: () => restoreNote(note.id) }, + { divider: true }, + { + label: "Delete Permanently", + icon: I.trash, + danger: true, + action: () => permanentDeleteNote(note.id), + }, + ] + : [ + { + label: note.starred ? "Unstar" : "Star", + icon: note.starred ? I.starFill : I.star, + action: () => data.toggleStar(note.id), + }, + ...moveSubmenu(note), + { divider: true }, + { label: "Move to Trash", icon: I.trash, danger: true, action: () => trashNote(note.id) }, + ]; + setCtxMenu({ x: e.clientX, y: e.clientY, items }); + }; + + /* ── Render ── */ + if (loading) + return ( +
+ Loading... +
+ ); + + if (!user) return ; + + // On a phone the list and the editor take turns; on a desktop both are shown. + const showList = !(isMobile && mobileView === "editor" && activeNote); + const showEditor = !isMobile || mobileView === "editor"; + + return ( +
+ + + setDark(!dark)} + isMobile={isMobile} + open={sidebarOpen} + onClose={() => setSidebarOpen(false)} + searchQuery={searchQuery} + onSearchChange={setSearchQuery} + activeFolder={activeFolder} + onSelectFolder={openFolder} + notebooks={notebooks} + activeNotes={activeNotes} + trashedCount={trashedNotes.length} + onNewNotebook={() => setShowNewNb(true)} + onOpenAccount={() => setShowAccount(true)} + onNotebookContextMenu={notebookContextMenu} + dnd={dnd} + canInstall={canInstall} + onInstall={promptInstall} + syncStatus={syncStatus} + online={sync.isOnline()} + /> + + {activeFolder === "dashboard" ? ( + setSidebarOpen(!sidebarOpen)} + onOpenNotebook={openFolder} + onOpenNote={(note) => { + setActiveFolder(note.folderId); + openNote(note.id); + }} + onNewNotebook={() => setShowNewNb(true)} + onNotebookContextMenu={notebookContextMenu} + dnd={dnd} + /> + ) : ( + <> + {showList && ( + setSidebarOpen(!sidebarOpen)} + onSelectNote={openNote} + onNoteContextMenu={noteContextMenu} + onCreateNote={createNote} + onEmptyTrash={emptyTrash} + /> + )} + {showEditor && ( + data.updateNote(activeNote, u)} + onToggleStar={() => data.toggleStar(activeNote)} + onTrash={() => trashNote(activeNote)} + onRestore={() => restoreNote(activeNote)} + onPermanentDelete={() => permanentDeleteNote(activeNote)} + onOpenHistory={() => setHistoryFor(activeNote)} + onCreateNote={createNote} + /> + )} + + )} + + {showNewNb && ( + setShowNewNb(false)} onCreate={createNotebook} dark={dark} /> + )} + {editingNb && ( + setEditingNb(null)} + onSave={(u) => updateNotebook(editingNb.id, u)} + dark={dark} + /> + )} + {showAccount && ( + setShowAccount(false)} + onLogout={() => { + setShowAccount(false); + onLogout(); + }} + onUserUpdate={setUser} + dark={dark} + /> + )} + {historyFor && ( + n.id === historyFor)?.title} + dark={dark} + onClose={() => setHistoryFor(null)} + onRestored={(content) => { + data.setNoteContent(historyFor, content); + setRestoreEpoch((e) => e + 1); + }} + renderDrawing={(strokes) => ( + + )} + /> + )} + {confirmDialog && ( + setConfirmDialog(null)} + dark={dark} + /> + )} + {ctxMenu && ( + setCtxMenu(null)} + dark={dark} + /> + )} +
+ ); +} diff --git a/frontend/src/NoteApp.jsx b/frontend/src/NoteApp.jsx deleted file mode 100644 index afaceb9..0000000 --- a/frontend/src/NoteApp.jsx +++ /dev/null @@ -1,1094 +0,0 @@ -import { useState, useRef, useEffect, useMemo } from "react"; -import * as sync from "./syncEngine.js"; - -const uid = () => Math.random().toString(36).slice(2, 10); -const now = () => new Date().toISOString(); -const fmtDate = (d) => new Date(d).toLocaleDateString("en-US",{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"}); - -const COLORS = ["#0d1117","#e94560","#0f3460","#533483","#e07c24","#21bf73","#fd5e53","#3282b8","#ff6b6b","#4ecdc4","#f7dc6f","#bb8fce","#85c1e9","#f8c471","#82e0aa","#f1948a","#aeb6bf","#ffffff","#f39c12","#000000"]; -const PEN_SIZES = [1,2,3,5,8,12,20]; -const COVERS = [ - {bg:"linear-gradient(135deg,#0f0c29,#302b63,#24243e)",spine:"#1a1540",accent:"#6c63ff"}, - {bg:"linear-gradient(135deg,#1a1a2e,#16213e,#0f3460)",spine:"#0a1628",accent:"#4fc3f7"}, - {bg:"linear-gradient(135deg,#134e5e,#71b280)",spine:"#0e3d4a",accent:"#a5d6a7"}, - {bg:"linear-gradient(135deg,#8e2de2,#4a00e0)",spine:"#3a0099",accent:"#ce93d8"}, - {bg:"linear-gradient(135deg,#fc4a1a,#f7b733)",spine:"#c43d15",accent:"#ffe082"}, - {bg:"linear-gradient(135deg,#c94b4b,#4b134f)",spine:"#3d0f40",accent:"#f48fb1"}, - {bg:"linear-gradient(135deg,#11998e,#38ef7d)",spine:"#0d7a71",accent:"#b9f6ca"}, - {bg:"linear-gradient(135deg,#373b44,#4286f4)",spine:"#2a2e35",accent:"#90caf9"}, - {bg:"linear-gradient(135deg,#141e30,#243b55)",spine:"#0e1520",accent:"#78909c"}, - {bg:"linear-gradient(135deg,#f12711,#f5af19)",spine:"#c01f0d",accent:"#fff59d"}, - {bg:"linear-gradient(135deg,#2c3e50,#3498db)",spine:"#1e2b38",accent:"#81d4fa"}, - {bg:"linear-gradient(135deg,#0f2027,#203a43,#2c5364)",spine:"#0a171c",accent:"#80cbc4"}, - {bg:"linear-gradient(135deg,#aa4b6b,#6b6b83,#3b8d99)",spine:"#7a3550",accent:"#f48fb1"}, - {bg:"linear-gradient(135deg,#1f1c2c,#928dab)",spine:"#15131f",accent:"#b39ddb"}, - {bg:"linear-gradient(135deg,#654ea3,#eaafc8)",spine:"#4e3c7e",accent:"#f8bbd0"}, -]; -const DEFAULT_FOLDERS = [{id:"all",name:"All Notes",icon:"📋"},{id:"starred",name:"Starred",icon:"⭐"},{id:"trash",name:"Trash",icon:"🗑️"}]; -const I = { - plus:, - search:, - bold:, - italic:, - underline:, - list:, - listOrd:123, - check:, - trash:, - star:, - starFill:, - pen:, - eraser:, - highlight:, - undo:, - redo:, - sun:, - moon:, - menu:, - text:, - draw:, - edit:, - move:, - restore:, - grip:, - logout:, - user:, - x:, - save:, -}; -/* ═══════ Shared Components ═══════ */ -function ConfirmDialog({title,message,confirmLabel="Delete",onConfirm,onCancel,dark}){ - const T=useMemo(()=>({bg:dark?"#161b22":"#fff",border:dark?"#30363d":"#d0d7de",text:dark?"#e6edf3":"#1f2328",muted:dark?"#8b949e":"#656d76",danger:dark?"#f85149":"#cf222e"}),[dark]); - return(
-
e.stopPropagation()}> -
{title}
-
{message}
-
- - -
-
-
); -} - -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(
e.stopPropagation()}> - {items.map((item,i)=>item.divider?
:item.submenu?:( - - ))} -
); -} - -function SubMenu({item,T,onClose}){ - const [open,setOpen]=useState(false); - return(
setOpen(true)} onMouseLeave={()=>setOpen(false)}> -
- {item.icon&&{item.icon}}{item.label}▸ -
- {open&&
- {item.submenu.map((sub,j)=>)} -
} -
); -} - -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(
-
e.stopPropagation()}> -

Edit Notebook

- setName(e.target.value)} autoFocus style={{width:"100%",padding:"10px 14px",borderRadius:10,border:`1px solid ${T.border}`,background:dark?"#0d1117":"#f6f8fa",color:T.text,fontSize:14,outline:"none",marginBottom:16,boxSizing:"border-box"}} - onKeyDown={e=>{if(e.key==="Enter"&&name.trim())onSave({name:name.trim(),cover:sel});}}/> -
-
Cover style
-
- {COVERS.map((c,i)=>)} -
-
-
- - -
-
-
); -} - -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(
-
e.stopPropagation()}> -

New Notebook

- setName(e.target.value)} placeholder="Notebook name..." autoFocus style={{width:"100%",padding:"10px 14px",borderRadius:10,border:`1px solid ${T.border}`,background:dark?"#0d1117":"#f6f8fa",color:T.text,fontSize:14,outline:"none",marginBottom:16,boxSizing:"border-box"}} - onKeyDown={e=>{if(e.key==="Enter"&&name.trim())onCreate({name:name.trim(),cover:sel});}}/> -
-
Choose cover style
-
- {COVERS.map((c,i)=>)} -
-
-
- - -
-
-
); -} - - -/* ═══════ ACCOUNT MENU ═══════ */ -function AccountMenu({user,onClose,onLogout,onUserUpdate,dark}){ - const [tab,setTab]=useState("profile"); // profile | email | password - const [displayName,setDisplayName]=useState(user?.displayName||""); - const [newEmail,setNewEmail]=useState(user?.email||""); - const [emailPass,setEmailPass]=useState(""); - const [curPass,setCurPass]=useState("");const [newPass,setNewPass]=useState("");const [newPass2,setNewPass2]=useState(""); - const [msg,setMsg]=useState("");const [err,setErr]=useState("");const [saving,setSaving]=useState(false); - const T=useMemo(()=>({bg:dark?"#161b22":"#fff",border:dark?"#30363d":"#d0d7de",text:dark?"#e6edf3":"#1f2328",muted:dark?"#8b949e":"#656d76",accent:dark?"#388bfd":"#0969da",danger:dark?"#f85149":"#cf222e",success:dark?"#3fb950":"#1a7f37",inputBg:dark?"#0d1117":"#f6f8fa"}),[dark]); - const inp={width:"100%",padding:"10px 14px",borderRadius:10,border:`1px solid ${T.border}`,background:T.inputBg,color:T.text,fontSize:14,outline:"none",boxSizing:"border-box",marginBottom:10,fontFamily:"'DM Sans',sans-serif"}; - const tbtn=(active)=>({padding:"7px 14px",borderRadius:8,border:"none",cursor:"pointer",fontSize:12,fontWeight:600,background:active?T.accent+"22":"transparent",color:active?T.accent:T.muted}); - const clear=()=>{setMsg("");setErr("");}; - - const saveProfile=async()=>{if(!displayName.trim())return;clear();setSaving(true); - try{const u=await sync.updateProfile(displayName.trim());onUserUpdate(u);setMsg("Display name updated");}catch(e){setErr(e.message);}setSaving(false);}; - const saveEmail=async()=>{if(!newEmail||!emailPass)return;clear();setSaving(true); - try{const u=await sync.updateEmail(newEmail,emailPass);onUserUpdate(u);setEmailPass("");setMsg("Email updated");}catch(e){setErr(e.message);}setSaving(false);}; - const savePassword=async()=>{if(!curPass||!newPass)return;if(newPass!==newPass2){setErr("Passwords don't match");return;}clear();setSaving(true); - try{await sync.updatePassword(curPass,newPass);setCurPass("");setNewPass("");setNewPass2("");setMsg("Password changed");}catch(e){setErr(e.message);}setSaving(false);}; - - return(
-
e.stopPropagation()}> -
-

Account

- -
-
- - - -
-
- {msg&&
{msg}
} - {err&&
{err}
} - - {tab==="profile"&&<> - - setDisplayName(e.target.value)} style={inp} onKeyDown={e=>{if(e.key==="Enter")saveProfile();}}/> - - } - - {tab==="email"&&<> - - setNewEmail(e.target.value)} style={inp}/> - - setEmailPass(e.target.value)} style={inp} onKeyDown={e=>{if(e.key==="Enter")saveEmail();}}/> - - } - - {tab==="password"&&<> - - setCurPass(e.target.value)} style={inp}/> - - setNewPass(e.target.value)} style={inp}/> - - setNewPass2(e.target.value)} style={inp} onKeyDown={e=>{if(e.key==="Enter")savePassword();}}/> - - } -
-
- -
-
-
); -} - -/* ═══════ AUTH SCREEN ═══════ */ -function AuthScreen({onLogin,dark}){ - const [isLogin,setIsLogin]=useState(true); - const [email,setEmail]=useState("");const [pass,setPass]=useState("");const [name,setName]=useState(""); - const [err,setErr]=useState("");const [loading,setLoading]=useState(false); - const T=useMemo(()=>({bg:dark?"#0d1117":"#f6f8fa",card:dark?"#161b22":"#fff",border:dark?"#30363d":"#d0d7de",text:dark?"#e6edf3":"#1f2328",muted:dark?"#8b949e":"#656d76",accent:dark?"#388bfd":"#0969da",danger:dark?"#f85149":"#cf222e"}),[dark]); - const submit=async()=>{ - if(!email||!pass){setErr("Email and password required");return;} - setLoading(true);setErr(""); - try{ - const user=isLogin?await sync.login(email,pass):await sync.register(email,pass,name||undefined); - onLogin(user); - }catch(e){setErr(e.message==="AUTH"?"Invalid credentials":e.message);} - finally{setLoading(false);} - }; - return(
-
-
-
✏️
-
InkNote
-
{isLogin?"Sign in to sync your notes":"Create your account"}
-
- {!isLogin&&setName(e.target.value)} placeholder="Display name" style={{width:"100%",padding:"12px 16px",borderRadius:12,border:`1px solid ${T.border}`,background:T.bg,color:T.text,fontSize:14,outline:"none",marginBottom:12,boxSizing:"border-box"}}/>} - setEmail(e.target.value)} placeholder="Email" autoFocus style={{width:"100%",padding:"12px 16px",borderRadius:12,border:`1px solid ${T.border}`,background:T.bg,color:T.text,fontSize:14,outline:"none",marginBottom:12,boxSizing:"border-box"}}/> - setPass(e.target.value)} placeholder="Password" style={{width:"100%",padding:"12px 16px",borderRadius:12,border:`1px solid ${T.border}`,background:T.bg,color:T.text,fontSize:14,outline:"none",marginBottom:16,boxSizing:"border-box"}} onKeyDown={e=>{if(e.key==="Enter")submit();}}/> - {err&&
{err}
} - -
- {isLogin?"Don't have an account? ":"Already have an account? "} - -
-
-
); -} -/* ═══════ TASK LIST (proper React state, not contentEditable) ═══════ */ -function TaskList({tasks,onChange,dark}){ - const T=useMemo(()=>({border:dark?"#30363d":"#d0d7de",text:dark?"#e6edf3":"#1f2328",muted:dark?"#8b949e":"#656d76",active:dark?"#388bfd":"#0969da",faint:dark?"#484f58":"#afb8c1",done:dark?"#484f58":"#afb8c1"}),[dark]); - const inputRefs=useRef({}); - const toggle=(i)=>{const t=[...tasks];t[i]={...t[i],done:!t[i].done};onChange(t);}; - const setText=(i,text)=>{const t=[...tasks];t[i]={...t[i],text};onChange(t);}; - const addAfter=(i)=>{const t=[...tasks];const nid=uid();t.splice(i+1,0,{id:nid,text:"",done:false});onChange(t);setTimeout(()=>inputRefs.current[nid]?.focus(),0);}; - const remove=(i)=>{ - if(tasks.length<=1&&tasks[0].text==="")return; - const t=[...tasks];t.splice(i,1);onChange(t); - if(t.length>0){const fi=Math.max(0,i-1);setTimeout(()=>{const el=inputRefs.current[t[fi]?.id];if(el){el.focus();el.selectionStart=el.value.length;}},0);} - }; - const handleKey=(e,i)=>{ - if(e.key==="Enter"){e.preventDefault();if(tasks[i].text===""){remove(i);}else{addAfter(i);}} - else if(e.key==="Backspace"&&tasks[i].text===""&&tasks.length>1){e.preventDefault();remove(i);} - }; - if(!tasks||tasks.length===0)return null; - return(
-
Tasks ({tasks.filter(t=>t.done).length}/{tasks.length})
- {tasks.map((task,i)=>( -
- toggle(i)} style={{width:16,height:16,accentColor:T.active,cursor:"pointer",flexShrink:0}}/> - inputRefs.current[task.id]=el} type="text" value={task.text} onChange={e=>setText(i,e.target.value)} - onKeyDown={e=>handleKey(e,i)} placeholder="Task..." - style={{flex:1,border:"none",background:"transparent",outline:"none",fontSize:14,color:task.done?T.done:T.text,textDecoration:task.done?"line-through":"none",fontFamily:"'DM Sans',sans-serif"}}/> - -
- ))} - -
); -} - -/* ═══════ NOTE EDITOR ═══════ */ -function NoteEditor({note,onUpdate,dark}){ - const [mode,setMode]=useState("text"); - const editorRef=useRef(null);const canvasRef=useRef(null);const ctxRef=useRef(null); - const [isDrawing,setIsDrawing]=useState(false); - const [tool,setTool]=useState("pen");const [penColor,setPenColor]=useState(dark?"#e6edf3":"#1a1a2e");const [penSize,setPenSize]=useState(3); - const [undoStack,setUndoStack]=useState([]);const [redoStack,setRedoStack]=useState([]); - const [showColors,setShowColors]=useState(false);const [showSizes,setShowSizes]=useState(false); - const [selectedImg,setSelectedImg]=useState(null); - const [hideDrawing,setHideDrawing]=useState(false); - const [drawingBlend,setDrawingBlend]=useState({mode:"normal",opacity:1}); - const [isMobileViewport,setIsMobileViewport]=useState(window.innerWidth<768); - const stageRef=useRef(null); - const saveTimerRef=useRef(null); - const dirtyRef=useRef(false); - const CW=1200,CH=1600; // Canonical drawing resolution shared by all devices. - const SAVE_DEBOUNCE_MS=600; - const [stageSize,setStageSize]=useState({w:CW,h:CH}); - - const parsed=useMemo(()=>{try{const p=JSON.parse(note.content);return{html:p.html||"",tasks:p.tasks||[]};}catch{return{html:note.content||"",tasks:[]};}},[note.content,note.id]); - const T=useMemo(()=>({bg:dark?"#0d1117":"#ffffff",toolbar:dark?"#161b22":"#f6f8fa",border:dark?"#30363d":"#d0d7de",text:dark?"#e6edf3":"#1f2328",muted:dark?"#8b949e":"#656d76",active:dark?"#388bfd":"#0969da",faint:dark?"#484f58":"#afb8c1"}),[dark]); - - useEffect(()=>{ - const el=editorRef.current; - if(!el)return; - const prevNoteId=el.dataset.noteId; - const isNewNote=prevNoteId!==note.id; - if(isNewNote){ - el.innerHTML=parsed.html; - el.dataset.noteId=note.id; - return; - } - // Late content arrival for the same note (mobile/network): fill only if editor is still empty. - const hasLocalText=(el.textContent||"").trim().length>0; - if(!hasLocalText && parsed.html){ - el.innerHTML=parsed.html; - } - },[note.id,parsed.html]); - - useEffect(()=>{ - setHideDrawing(false); - const isLegacyRaster=typeof note.drawingData==="string"&&/^data:image\/(jpeg|jpg|webp)/i.test(note.drawingData); - if(!note.drawingData){setDrawingBlend({mode:"normal",opacity:1});return;} - let cancelled=false; - const img=new Image(); - img.onload=()=>{ - if(cancelled)return; - try{ - const c=document.createElement("canvas");c.width=32;c.height=32; - const cx=c.getContext("2d",{willReadFrequently:true});if(!cx)throw new Error("ctx"); - cx.drawImage(img,0,0,32,32); - const data=cx.getImageData(0,0,32,32).data; - let solid=0,black=0; - for(let i=0;i0.9)setDrawingBlend({mode:"screen",opacity:0.62}); - else if(isLegacyRaster)setDrawingBlend({mode:"screen",opacity:0.42}); - else setDrawingBlend({mode:"normal",opacity:1}); - }catch{ - setDrawingBlend(isLegacyRaster?{mode:"screen",opacity:0.42}:{mode:"normal",opacity:1}); - } - }; - img.onerror=()=>{if(!cancelled)setHideDrawing(true);}; - img.src=note.drawingData; - return()=>{cancelled=true;}; - },[note.id,note.drawingData]); - - // Keep one fixed-aspect drawing stage regardless of device dimensions. - useEffect(()=>{ - const fitStage=()=>{ - const host=stageRef.current; - if(!host)return; - const W=Math.max(1,host.clientWidth); - const mobile=window.innerWidth<768; - setIsMobileViewport(mobile); - const sidePad=mobile?10:28; - const desktopMax=760; - const minW=mobile?220:320; - const maxAllowed=Math.max(220,W-sidePad*2); - const targetW=mobile?maxAllowed:Math.min(desktopMax,maxAllowed); - const w=Math.floor(Math.max(minW,targetW)); - const h=Math.floor((w*CH)/CW); - setStageSize({w,h}); - }; - fitStage(); - if(typeof ResizeObserver!=="undefined"){ - const ro=new ResizeObserver(fitStage); - if(stageRef.current)ro.observe(stageRef.current); - window.addEventListener("resize",fitStage); - return()=>{ro.disconnect();window.removeEventListener("resize",fitStage);}; - } - window.addEventListener("resize",fitStage); - return()=>window.removeEventListener("resize",fitStage); - },[]); - - const saveDrawingNow=()=>{ - if(!canvasRef.current||!dirtyRef.current)return; - onUpdate({drawingData:canvasRef.current.toDataURL("image/png")}); - dirtyRef.current=false; - }; - const scheduleDrawingSave=()=>{ - if(saveTimerRef.current)clearTimeout(saveTimerRef.current); - saveTimerRef.current=setTimeout(()=>{saveTimerRef.current=null;saveDrawingNow();},SAVE_DEBOUNCE_MS); - }; - - /* ── DRAWING: init canvas once on mode switch ── */ - useEffect(()=>{ - if(mode!=="draw")return; - const c=canvasRef.current;if(!c)return; - c.width=CW;c.height=CH; - const ctx=c.getContext("2d");ctx.lineCap="round";ctx.lineJoin="round";ctxRef.current=ctx; - ctx.clearRect(0,0,CW,CH); - dirtyRef.current=false; - if(note.drawingData){ - const img=new Image();img.onload=()=>{ - const t=document.createElement("canvas");t.width=CW;t.height=CH; - const tctx=t.getContext("2d",{willReadFrequently:true});if(!tctx){ctx.drawImage(img,0,0,CW,CH);return;} - tctx.drawImage(img,0,0,CW,CH); - try{ - const sc=document.createElement("canvas");sc.width=40;sc.height=40; - const sctx=sc.getContext("2d",{willReadFrequently:true});if(!sctx)throw new Error("ctx"); - sctx.drawImage(t,0,0,40,40); - const sample=sctx.getImageData(0,0,40,40).data; - let solid=0,black=0; - for(let i=0;i0.9){ - const im=tctx.getImageData(0,0,CW,CH);const d=im.data; - for(let i=0;i245)d[i+3]=0; - } - tctx.putImageData(im,0,0); - } - }catch{} - ctx.drawImage(t,0,0,CW,CH); - };img.src=note.drawingData; - } - },[mode]); - - /* Save drawing on mode switch and debounced stroke-end updates. */ - const switchMode=(m)=>{ - if(mode==="draw"&&m==="text"&&dirtyRef.current&&canvasRef.current){ - if(saveTimerRef.current){clearTimeout(saveTimerRef.current);saveTimerRef.current=null;} - saveDrawingNow(); - } - setMode(m);setShowColors(false);setShowSizes(false);setSelectedImg(null); - }; - - const pushUndo=()=>{if(!canvasRef.current)return;const d=canvasRef.current.toDataURL("image/png");setUndoStack(p=>[...p.slice(-15),d]);setRedoStack([]);}; - const undo=()=>{if(!undoStack.length)return;const s=undoStack[undoStack.length-1];setUndoStack(p=>p.slice(0,-1)); - setRedoStack(p=>[...p,canvasRef.current.toDataURL("image/png")]); - const i=new Image();i.onload=()=>{ctxRef.current.clearRect(0,0,CW,CH);ctxRef.current.drawImage(i,0,0,CW,CH);dirtyRef.current=true;scheduleDrawingSave();};i.src=s;}; - const redo=()=>{if(!redoStack.length)return;const s=redoStack[redoStack.length-1];setRedoStack(p=>p.slice(0,-1)); - setUndoStack(p=>[...p,canvasRef.current.toDataURL("image/png")]); - const i=new Image();i.onload=()=>{ctxRef.current.clearRect(0,0,CW,CH);ctxRef.current.drawImage(i,0,0,CW,CH);dirtyRef.current=true;scheduleDrawingSave();};i.src=s;}; - const clearCanvas=()=>{pushUndo();ctxRef.current?.clearRect(0,0,CW,CH);dirtyRef.current=true;scheduleDrawingSave();}; - - const getPos=(e)=>{const c=canvasRef.current,r=c.getBoundingClientRect();const t=e.touches?e.touches[0]:e;return{x:(t.clientX-r.left)*(CW/r.width),y:(t.clientY-r.top)*(CH/r.height)};}; - const startDraw=(e)=>{e.preventDefault();pushUndo();const p=getPos(e);setIsDrawing(true); - const ctx=ctxRef.current;ctx.beginPath();ctx.moveTo(p.x,p.y); - if(tool==="eraser"){ctx.globalCompositeOperation="destination-out";ctx.lineWidth=penSize*5;} - else if(tool==="highlighter"){ctx.globalCompositeOperation="source-over";ctx.strokeStyle=penColor+"44";ctx.lineWidth=penSize*6;} - else{ctx.globalCompositeOperation="source-over";ctx.strokeStyle=penColor;ctx.lineWidth=penSize;}}; - const doDraw=(e)=>{if(!isDrawing)return;e.preventDefault();const p=getPos(e);ctxRef.current.lineTo(p.x,p.y);ctxRef.current.stroke();}; - const stopDraw=(e)=>{if(!isDrawing)return;e?.preventDefault();setIsDrawing(false);ctxRef.current.closePath();ctxRef.current.globalCompositeOperation="source-over";dirtyRef.current=true;scheduleDrawingSave();}; - - useEffect(()=>()=>{if(saveTimerRef.current)clearTimeout(saveTimerRef.current);saveDrawingNow();},[]); - - /* ── TEXT ── */ - const saveContent=(html,tasks)=>{onUpdate({content:JSON.stringify({html,tasks})});}; - const onHtmlChange=()=>{if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks);}; - const onTasksChange=(tasks)=>{saveContent(parsed.html,tasks);}; - const addTaskList=()=>{if(parsed.tasks.length===0)onTasksChange([{id:uid(),text:"",done:false}]);}; - const exec=(cmd,val=null)=>{document.execCommand(cmd,false,val);editorRef.current?.focus();onHtmlChange();}; - - /* ── IMAGE PASTE ── */ - useEffect(()=>{ - const el=editorRef.current;if(!el||mode!=="text")return; - const h=(e)=>{ - const items=e.clipboardData?.items;if(!items)return; - for(const item of items){ - if(item.type.startsWith("image/")){ - e.preventDefault(); - const blob=item.getAsFile();if(!blob)return; - const reader=new FileReader(); - reader.onload=(ev)=>{ - document.execCommand("insertHTML",false, - ``); - if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks); - }; - reader.readAsDataURL(blob);return; - } - } - }; - el.addEventListener("paste",h);return()=>el.removeEventListener("paste",h); - },[mode,parsed.tasks]); - - /* ── IMAGE SELECT + DRAG RESIZE ── */ - useEffect(()=>{ - const el=editorRef.current;if(!el||mode!=="text")return; - - const onClick=(e)=>{ - const img=e.target.closest("img[data-rid]"); - setSelectedImg(img||null); - }; - - const onPointerDown=(e)=>{ - const img=e.target.closest("img[data-rid]");if(!img)return; - const rect=img.getBoundingClientRect(); - const px=e.clientX||(e.touches?.[0]?.clientX); - // Only start resize if clicking within 20px of right edge - if(px{ - ev.preventDefault(); - const mx=ev.clientX||(ev.touches?.[0]?.clientX); - const nw=Math.max(40,Math.min(cW,startW+(mx-startX))); - const pct=Math.round(nw/cW*100); - img.style.width=pct+"%";img.style.maxWidth=pct+"%"; - }; - const up=()=>{ - document.removeEventListener("mousemove",move);document.removeEventListener("mouseup",up); - document.removeEventListener("touchmove",move);document.removeEventListener("touchend",up); - if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks); - }; - document.addEventListener("mousemove",move);document.addEventListener("mouseup",up); - document.addEventListener("touchmove",move,{passive:false});document.addEventListener("touchend",up); - }; - - // Cursor hint near right edge - const onMove=(e)=>{ - const img=e.target.closest("img[data-rid]");if(!img){return;} - const r=img.getBoundingClientRect(); - img.style.cursor=e.clientX>r.right-20?"ew-resize":"pointer"; - }; - - el.addEventListener("click",onClick); - el.addEventListener("mousedown",onPointerDown); - el.addEventListener("touchstart",onPointerDown,{passive:false}); - el.addEventListener("mousemove",onMove); - return()=>{el.removeEventListener("click",onClick);el.removeEventListener("mousedown",onPointerDown); - el.removeEventListener("touchstart",onPointerDown);el.removeEventListener("mousemove",onMove);}; - },[mode,parsed.tasks]); - - // Blue outline on selected image - useEffect(()=>{ - const el=editorRef.current;if(!el)return; - el.querySelectorAll("img[data-rid]").forEach(img=>{ - if(img===selectedImg){img.style.outline=`3px solid ${T.active}`;img.style.outlineOffset="2px";} - else{img.style.outline="none";img.style.outlineOffset="0";} - }); - },[selectedImg,T.active]); - - // Deselect on outside click - useEffect(()=>{if(!selectedImg)return; - const h=(e)=>{if(!editorRef.current?.contains(e.target)&&!e.target.closest("[data-imgtb]"))setSelectedImg(null);}; - document.addEventListener("pointerdown",h);return()=>document.removeEventListener("pointerdown",h); - },[selectedImg]); - - const setImgSize=(p)=>{if(!selectedImg)return;selectedImg.style.width=p+"%";selectedImg.style.maxWidth=p+"%";if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks);}; - const deleteImg=()=>{if(!selectedImg)return;selectedImg.remove();setSelectedImg(null);if(editorRef.current)saveContent(editorRef.current.innerHTML,parsed.tasks);}; - - /* ── RENDER ── */ - const tb=(a)=>({display:"flex",alignItems:"center",justifyContent:"center",width:30,height:30,borderRadius:6,border:"none",cursor:"pointer",background:a?T.active+"22":"transparent",color:a?T.active:T.text,transition:"all .12s",flexShrink:0}); - const mb=(m,ic,lb)=>; - - return(
- {/* Main toolbar */} -
- {mb("text",I.text,"Write")}{mb("draw",I.draw,"Draw")} -
- {mode==="text"?(<> - - - -
- - - -
- - ):(<> - - - -
- -
-
- - {showColors&&
- {COLORS.map(c=>
} -
-
- - {showSizes&&
- {PEN_SIZES.map(s=>)} -
} -
-
- )} -
- - {/* Image toolbar — appears when an image is selected */} - {selectedImg&&mode==="text"&&
- Size: - {[25,33,50,75,100].map(p=>)} -
- - · drag right edge to resize -
} - - {/* Content area */} -
-
-
-
- {note.drawingData&&!hideDrawing&&setHideDrawing(true)} style={{position:"absolute",left:0,top:0,width:"100%",height:stageSize.h,pointerEvents:"none",objectFit:"fill",zIndex:2,opacity:drawingBlend.opacity,mixBlendMode:drawingBlend.mode}}/>} - {mode==="draw"&&} - {mode==="text"&&} -
-
-
-
); -} -/* ═══════ 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(); -} - - -/* ════════════════════════════ MAIN APP (offline-first) ════════════════════════════ */ -export default function NoteApp(){ - const [dark,setDark]=useState(true); - const [user,setUser]=useState(null);const [loading,setLoading]=useState(true); - const [sidebarOpen,setSidebarOpen]=useState(window.innerWidth>768); - const [activeFolder,setActiveFolder]=useState("dashboard"); - const [activeNote,setActiveNote]=useState(null); - const [searchQuery,setSearchQuery]=useState(""); - const [showNewNb,setShowNewNb]=useState(false); - const [syncStatus,setSyncStatus]=useState("synced"); - const [ctxMenu,setCtxMenu]=useState(null); - const [confirmDialog,setConfirmDialog]=useState(null); - const [editingNb,setEditingNb]=useState(null); - const [showAccount,setShowAccount]=useState(false); - const [dragNbId,setDragNbId]=useState(null);const [dragOverNbId,setDragOverNbId]=useState(null); - const [notebooks,setNotebooks]=useState([]);const [notes,setNotes]=useState([]); - const [noteContents,setNoteContents]=useState({}); - const [isMobile,setIsMobile]=useState(window.innerWidth<768); - const [mobileView,setMobileView]=useState("list"); // list | editor - const [installPrompt,setInstallPrompt]=useState(null); - - const T=useMemo(()=>dark?{ - bg:"#0d1117",sidebar:"#010409",sidebarActive:"#161b22",card:"#161b22",toolbar:"#161b22",border:"#30363d",borderLight:"#21262d",text:"#e6edf3",textSec:"#c9d1d9",muted:"#8b949e",faint:"#484f58",accent:"#388bfd",accentSoft:"#388bfd22",accentText:"#58a6ff",danger:"#f85149",success:"#3fb950",warning:"#d29922",inputBg:"#0d1117",dashBg:"#0d1117", - }:{ - bg:"#f6f8fa",sidebar:"#ffffff",sidebarActive:"#eef2ff",card:"#ffffff",toolbar:"#ffffff",border:"#d0d7de",borderLight:"#e5e7eb",text:"#1f2328",textSec:"#424a53",muted:"#656d76",faint:"#afb8c1",accent:"#0969da",accentSoft:"#0969da18",accentText:"#0550ae",danger:"#cf222e",success:"#1a7f37",warning:"#9a6700",inputBg:"#f6f8fa",dashBg:"#f0f2f5", - },[dark]); - - // ── Responsive ── - useEffect(()=>{const h=()=>{const m=window.innerWidth<768;setIsMobile(m);if(!m)setSidebarOpen(true);};window.addEventListener("resize",h);return()=>window.removeEventListener("resize",h);},[]); - - // ── PWA install prompt ── - useEffect(()=>{ - const h=(e)=>{e.preventDefault();setInstallPrompt(e);}; - window.addEventListener("beforeinstallprompt",h); - return()=>window.removeEventListener("beforeinstallprompt",h); - },[]); - const handleInstall=async()=>{if(!installPrompt)return;installPrompt.prompt();const r=await installPrompt.userChoice;if(r.outcome==="accepted")setInstallPrompt(null);}; - - // ── Sync status listener ── - useEffect(()=>sync.onSyncStatus(setSyncStatus),[]); - - // ── Data change listener — reload from IndexedDB ── - useEffect(()=>sync.onDataChange(async()=>{ - setNotebooks(await sync.getNotebooks()); - setNotes(await sync.getNotes()); - // Keep loaded note content cached to avoid editor flicker/stalls on periodic sync - }),[]); - - // ── Startup: restore session → load local data → sync ── - useEffect(()=>{(async()=>{ - try{ - const u=await sync.restoreSession(); - if(u){setUser(u);await reloadLocal(); - // Background sync if online - try{await sync.fullSync();await reloadLocal();}catch(e){if(e.message==="AUTH"){setUser(null);}} - } - }catch{} - setLoading(false); - })();},[]); - - // Reload from IndexedDB - const reloadLocal=async()=>{ - setNotebooks(await sync.getNotebooks()); - setNotes(await sync.getNotes()); - }; - - const onLogin=async(u)=>{setUser(u); - try{await sync.fullSync();}catch{} - await reloadLocal(); - }; - const onLogout=async()=>{await sync.logout();setUser(null);setNotes([]);setNotebooks([]);setNoteContents({});setActiveNote(null);setActiveFolder("dashboard");}; - - const activeNotes=useMemo(()=>notes.filter(n=>!n.trashed),[notes]); - const trashedNotes=useMemo(()=>notes.filter(n=>n.trashed),[notes]); - const filteredNotes=useMemo(()=>{ - let list; - if(activeFolder==="trash")list=trashedNotes; - else if(activeFolder==="starred")list=activeNotes.filter(n=>n.starred); - else if(activeFolder==="all")list=activeNotes; - else if(activeFolder!=="dashboard")list=activeNotes.filter(n=>n.folderId===activeFolder); - else list=activeNotes; - if(searchQuery){const q=searchQuery.toLowerCase();list=list.filter(n=>n.title.toLowerCase().includes(q));} - return list.sort((a,b)=>new Date(b.updatedAt)-new Date(a.updatedAt)); - },[notes,activeFolder,searchQuery,activeNotes,trashedNotes]); - - const currentNoteMeta=activeNote?notes.find(n=>n.id===activeNote):null; - const currentNoteContent=activeNote?noteContents[activeNote]:null; - const activeNoteHasContent=!!currentNoteContent; - const currentNote=currentNoteMeta&¤tNoteContent?{...currentNoteMeta,...currentNoteContent}:null; - const currentNoteSafe=currentNote||( - currentNoteMeta - ? {...currentNoteMeta,content:currentNoteContent?.content||"",drawingData:currentNoteContent?.drawingData||null} - : null - ); - - // Load note content on demand — fetches server-first on cache miss (page load = all cache empty) - useEffect(()=>{ - if(!activeNote || activeNoteHasContent) return; - let cancelled=false; - sync.loadNoteContent(activeNote) - .then(c=>{ if(!cancelled) setNoteContents(p=>({...p,[activeNote]:c})); }) - .catch(()=>{ if(!cancelled) setNoteContents(p=>({...p,[activeNote]:{content:"",drawingData:null}})); }); - return ()=>{cancelled=true;}; - },[activeNote,activeNoteHasContent]); - - // Hard fallback: never stay in loading state forever. - useEffect(()=>{ - if(!activeNote || activeNoteHasContent) return; - const t=setTimeout(()=>{ - setNoteContents(p=>p[activeNote]?p:{...p,[activeNote]:{content:"",drawingData:null}}); - },5000); - return ()=>clearTimeout(t); - },[activeNote,activeNoteHasContent]); - - // Keep currently open note content fresh across devices. - useEffect(()=>{ - if(!user||!activeNote)return; - const i=setInterval(()=>{ - if(!sync.isOnline())return; - sync.loadNoteContent(activeNote,{forceRemote:true}).then(c=>{ - setNoteContents(p=>{ - const prev=p[activeNote]; - if(prev&&prev.content===c.content&&prev.drawingData===c.drawingData)return p; - return {...p,[activeNote]:c}; - }); - }).catch(()=>{}); - },6000); - return()=>clearInterval(i); - },[user,activeNote]); - - // ── CRUD — all go through sync engine ── - const createNote=async()=>{ - const fid=["all","starred","trash","dashboard"].includes(activeFolder)?(notebooks[0]?.id):activeFolder;if(!fid)return; - const note=await sync.createNote(fid); - setNotes(p=>[note,...p]);setNoteContents(p=>({...p,[note.id]:{content:"",drawingData:null}})); - setActiveNote(note.id);if(activeFolder==="dashboard")setActiveFolder(fid); - if(isMobile)setMobileView("editor"); - }; - - const updateNote=async(id,u)=>{ - if(u.title!==undefined){setNotes(p=>p.map(n=>n.id===id?{...n,title:u.title,updatedAt:now()}:n));sync.updateNoteTitle(id,u.title);} - if(u.content!==undefined){setNoteContents(p=>({...p,[id]:{...p[id],content:u.content}}));sync.updateNoteContent(id,u.content);} - if(u.drawingData!==undefined){setNoteContents(p=>({...p,[id]:{...p[id],drawingData:u.drawingData}}));sync.updateNoteDrawing(id,u.drawingData);} - }; - - const toggleStar=async(id)=>{const nv=await sync.toggleNoteStar(id);setNotes(p=>p.map(n=>n.id===id?{...n,starred:nv}:n));}; - - const trashNoteAction=(id)=>{const note=notes.find(n=>n.id===id); - setConfirmDialog({title:"Move to Trash",message:`Move "${note?.title}" to trash?`,confirmLabel:"Move to Trash", - onConfirm:async()=>{await sync.trashNote(id);setNotes(p=>p.map(n=>n.id===id?{...n,trashed:true}:n)); - if(activeNote===id){setActiveNote(null);if(isMobile)setMobileView("list");}setConfirmDialog(null);}}); - }; - - const restoreNote=async(id)=>{await sync.restoreNoteLocal(id);setNotes(p=>p.map(n=>n.id===id?{...n,trashed:false}:n));if(activeNote===id){setActiveNote(null);if(isMobile)setMobileView("list");}}; - - const permanentDeleteNote=(id)=>{const note=notes.find(n=>n.id===id); - setConfirmDialog({title:"Delete Permanently",message:`Permanently delete "${note?.title}"? This cannot be undone.`, - onConfirm:async()=>{await sync.permanentDeleteNoteLocal(id);setNotes(p=>p.filter(n=>n.id!==id)); - if(activeNote===id){setActiveNote(null);if(isMobile)setMobileView("list");}setConfirmDialog(null);}}); - }; - - const emptyTrash=()=>{ - setConfirmDialog({title:"Empty Trash",message:`Permanently delete all ${trashedNotes.length} trashed notes?`, - onConfirm:async()=>{await sync.emptyTrashLocal();setNotes(p=>p.filter(n=>!n.trashed));setActiveNote(null);setConfirmDialog(null);}}); - }; - - const moveNoteAction=async(id,newFolderId)=>{await sync.moveNote(id,newFolderId);setNotes(p=>p.map(n=>n.id===id?{...n,folderId:newFolderId}:n));}; - - const createNb=async({name,cover})=>{const nb=await sync.createNotebook({name,cover});setNotebooks(p=>[...p,nb]);setShowNewNb(false);setActiveFolder(nb.id);}; - const updateNb=async(id,updates)=>{await sync.updateNotebook(id,updates);setNotebooks(p=>p.map(nb=>nb.id===id?{...nb,...updates}:nb));setEditingNb(null);}; - - const requestDeleteNb=(id)=>{const nb=notebooks.find(n=>n.id===id);const nc=activeNotes.filter(n=>n.folderId===id).length; - setConfirmDialog({title:"Delete Notebook",message:`Delete "${nb?.name}"${nc>0?` and trash its ${nc} notes`:""} permanently?`, - onConfirm:async()=>{await sync.deleteNotebookLocal(id); - setNotes(p=>p.map(n=>n.folderId===id?{...n,trashed:true}:n));setNotebooks(p=>p.filter(n=>n.id!==id)); - if(activeFolder===id)setActiveFolder("dashboard");setConfirmDialog(null);}}); - }; - - const handleNbDragStart=(e,id)=>{setDragNbId(id);e.dataTransfer.effectAllowed="move";}; - const handleNbDragOver=(e,id)=>{e.preventDefault();if(dragNbId&&dragNbId!==id)setDragOverNbId(id);}; - const handleNbDrop=async(e,targetId)=>{e.preventDefault();if(!dragNbId||dragNbId===targetId)return; - setNotebooks(prev=>{const a=[...prev];const fi=a.findIndex(n=>n.id===dragNbId);const ti=a.findIndex(n=>n.id===targetId);if(fi<0||ti<0)return prev;const[item]=a.splice(fi,1);a.splice(ti,0,item); - const ids=a.map(n=>n.id);sync.reorderNotebooks(ids);return a.map((n,i)=>({...n,sortOrder:i}));}); - setDragNbId(null);setDragOverNbId(null);}; - const handleNbDragEnd=()=>{setDragNbId(null);setDragOverNbId(null);}; - - const handleNbContextMenu=(e,nb)=>{e.preventDefault();e.stopPropagation(); - setCtxMenu({x:e.clientX,y:e.clientY,items:[{label:"Edit",icon:I.edit,action:()=>setEditingNb(nb)},{divider:true},{label:"Delete Notebook",icon:I.trash,danger:true,action:()=>requestDeleteNb(nb.id)}]});}; - - const handleNoteContextMenu=(e,note)=>{e.preventDefault();e.stopPropagation(); - if(note.trashed){setCtxMenu({x:e.clientX,y:e.clientY,items:[{label:"Restore",icon:I.restore,action:()=>restoreNote(note.id)},{divider:true},{label:"Delete Permanently",icon:I.trash,danger:true,action:()=>permanentDeleteNote(note.id)}]});} - else{const subs=notebooks.filter(nb=>nb.id!==note.folderId).map(nb=>({label:nb.name,colorDot:COVERS[nb.cover%COVERS.length].bg,action:()=>moveNoteAction(note.id,nb.id)})); - setCtxMenu({x:e.clientX,y:e.clientY,items:[{label:note.starred?"Unstar":"Star",icon:note.starred?I.starFill:I.star,action:()=>toggleStar(note.id)},...(subs.length>0?[{label:"Move to...",icon:I.move,submenu:subs}]:[]),{divider:true},{label:"Move to Trash",icon:I.trash,danger:true,action:()=>trashNoteAction(note.id)}]});} - }; - - useEffect(()=>{const l=document.createElement("link");l.href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&family=DM+Sans:wght@400;500;600;700&display=swap";l.rel="stylesheet";document.head.appendChild(l);},[]); - - // ── Periodic sync (every 30s if online) ── - useEffect(()=>{const i=setInterval(()=>{if(sync.isOnline())sync.fullSync().then(()=>reloadLocal()).catch(()=>{});},30000);return()=>clearInterval(i);},[]); - - // ── History API — browser/phone back button ── - const navStateRef=useRef({folder:"dashboard",note:null,view:"list"}); - useEffect(()=>{ - const cur=navStateRef.current; - if(cur.folder!==activeFolder||cur.note!==activeNote||cur.view!==mobileView){ - const state={folder:activeFolder,note:activeNote,view:mobileView}; - try{window.history.pushState(state,"");}catch{} - navStateRef.current=state; - } - },[activeFolder,activeNote,mobileView]); - - useEffect(()=>{ - const handler=(e)=>{ - const s=e.state; - if(s){setActiveFolder(s.folder||"dashboard");setActiveNote(s.note||null);if(isMobile)setMobileView(s.view||"list");} - else{if(activeNote){setActiveNote(null);if(isMobile)setMobileView("list");}else if(activeFolder!=="dashboard"){setActiveFolder("dashboard");setActiveNote(null);}} - }; - window.addEventListener("popstate",handler);return()=>window.removeEventListener("popstate",handler); - },[activeNote,activeFolder,isMobile]); - - if(loading)return
Loading...
; - if(!user)return; - const isTrashView=activeFolder==="trash"; - - /* ── Back button for mobile ── */ - const backBtn=(label,onClick)=>; - - /* ── Sidebar ── */ - const renderSidebar=()=>( -
- {isMobile&&sidebarOpen&&
setSidebarOpen(false)} style={{position:"absolute",inset:0,background:"rgba(0,0,0,0.4)",zIndex:-1}}/>} -
-
-
-
✏️
- InkNote -
-
- - -
-
-
-
- {I.search} - setSearchQuery(e.target.value)} placeholder="Search notes..." style={{border:"none",background:"transparent",outline:"none",flex:1,color:T.text,fontSize:13,fontFamily:"'DM Sans',sans-serif"}}/> -
-
-
- -
-
- {DEFAULT_FOLDERS.map(f=>( - - ))} -
-
-
- Notebooks - -
- {notebooks.map(nb=>( -
handleNbDragStart(e,nb.id)} onDragOver={e=>handleNbDragOver(e,nb.id)} onDrop={e=>handleNbDrop(e,nb.id)} onDragEnd={handleNbDragEnd} onContextMenu={e=>handleNbContextMenu(e,nb)} - style={{display:"flex",alignItems:"center",borderRadius:10,transition:"all .15s",background:dragOverNbId===nb.id?T.accentSoft:activeFolder===nb.id?T.sidebarActive:"transparent",borderTop:dragOverNbId===nb.id?`2px solid ${T.accent}`:"2px solid transparent"}}> - {!isMobile&&{I.grip}} - -
- ))} -
-
- {installPrompt&&} -
- ● - {syncStatus==="synced"?"All synced":syncStatus==="syncing"?"Syncing...":syncStatus==="offline"?"Offline mode":syncStatus==="pending"?"Changes pending":"Sync error"} - {!sync.isOnline()&&offline} -
-
-
-
- ); - - /* ── Dashboard ── */ - const renderDashboard=()=>( -
-
-
- -

My Notebooks

-
-
-
- {notebooks.map(nb=>( - n.folderId===nb.id).length} - onClick={()=>{setActiveFolder(nb.id);setActiveNote(null);}} onContextMenu={e=>handleNbContextMenu(e,nb)} - isActive={activeFolder===nb.id} isDragOver={dragOverNbId===nb.id} draggable={!isMobile} - onDragStart={e=>handleNbDragStart(e,nb.id)} onDragEnd={handleNbDragEnd} onDragOver={e=>handleNbDragOver(e,nb.id)} onDrop={e=>handleNbDrop(e,nb.id)}/> - ))} - -
-
-

Recent Notes

-
- {activeNotes.slice(0,6).map(note=>{const nb=notebooks.find(b=>b.id===note.folderId);return( - - );})} -
-
-
- ); - - /* ── Note List ── */ - const renderNoteList=()=>{ - const fname=(()=>{const sf=DEFAULT_FOLDERS.find(f=>f.id===activeFolder);if(sf)return sf.name;const nb=notebooks.find(n=>n.id===activeFolder);return nb?nb.name:"Notes";})(); - // On mobile, hide list when editor is open - if(isMobile&&mobileView==="editor"&&activeNote)return null; - return( -
-
-
- -

{fname}

-
-
- {!isTrashView&&} - {isTrashView&&trashedNotes.length>0&&} -
-
-
- {filteredNotes.length===0?( -
-
{isTrashView?"🗑️":"📝"}
{isTrashView?"Trash is empty":"No notes yet"}
- {!isTrashView&&} -
- ):filteredNotes.map(note=>( - - ))} -
-
- ); - }; - - /* ── Editor ── */ - const renderEditor=()=>{ - // On mobile, hide editor when list is shown - if(isMobile&&mobileView!=="editor")return null; - if(!currentNoteSafe)return isMobile?null:( -
-
✏️
-
Select a note to edit
- {!isTrashView&&} -
- ); - if(isTrashView&¤tNoteMeta)return( -
-
- {isMobile&&backBtn("Back",()=>{setActiveNote(null);setMobileView("list");})} - {currentNoteMeta.title} - - -
-
Trashed notes are read-only. Restore to edit.
-
- ); - return( -
-
- {isMobile&&backBtn("Back",()=>{setActiveNote(null);setMobileView("list");})} - updateNote(currentNoteSafe.id,{title:e.target.value})} - style={{flex:1,border:"none",background:"transparent",outline:"none",fontSize:isMobile?15:17,fontWeight:600,color:T.text,fontFamily:"'Newsreader',Georgia,serif",minWidth:0}}/> - - -
-
- updateNote(currentNoteSafe.id,u)} dark={dark}/> -
-
- ); - }; - - return( -
- - {renderSidebar()} - {activeFolder==="dashboard"?renderDashboard():(<>{renderNoteList()}{renderEditor()})} - {showNewNb&&setShowNewNb(false)} onCreate={createNb} dark={dark}/>} - {editingNb&&setEditingNb(null)} onSave={u=>updateNb(editingNb.id,u)} dark={dark}/>} - {showAccount&&setShowAccount(false)} onLogout={()=>{setShowAccount(false);onLogout();}} onUserUpdate={u=>setUser(u)} dark={dark}/>} - {confirmDialog&&setConfirmDialog(null)} dark={dark}/>} - {ctxMenu&&setCtxMenu(null)} dark={dark}/>} -
- ); -} diff --git a/frontend/src/editor/EditorToolbar.jsx b/frontend/src/editor/EditorToolbar.jsx new file mode 100644 index 0000000..b8a929f --- /dev/null +++ b/frontend/src/editor/EditorToolbar.jsx @@ -0,0 +1,342 @@ +import { useEffect, useState } from "react"; + +/* Icons the main app does not already provide, drawn to match its stroke style. */ +const strokeProps = { + width: 15, + height: 15, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 2, + strokeLinecap: "round", + strokeLinejoin: "round", +}; + +const TableIcon = () => ( + + + + + + +); +const LinkIcon = () => ( + + + + +); +const QuoteIcon = () => ( + + + + +); +const CodeIcon = () => ( + + + + +); +const AtIcon = () => ( + + + + +); + +/** + * Formatting controls for the note body. + * + * Reads its active states straight off the editor, so the buttons reflect the + * cursor even when formatting changed by keyboard shortcut. + */ +export function EditorToolbar({ editor, T, icons, compact }) { + const [, force] = useState(0); + const [showTable, setShowTable] = useState(false); + + // ProseMirror state changes do not go through React; subscribe so the active + // states stay in sync with the cursor. + useEffect(() => { + if (!editor) return; + const rerender = () => force((n) => n + 1); + editor.on("transaction", rerender); + editor.on("selectionUpdate", rerender); + return () => { + editor.off("transaction", rerender); + editor.off("selectionUpdate", rerender); + }; + }, [editor]); + + useEffect(() => { + if (!showTable) return; + const close = (e) => { + if (!e.target.closest?.("[data-table-menu]")) setShowTable(false); + }; + document.addEventListener("pointerdown", close); + return () => document.removeEventListener("pointerdown", close); + }, [showTable]); + + if (!editor) return null; + + const btn = (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 divider = ( +
+ ); + const inTable = editor.isActive("table"); + + const menuItem = { + display: "block", + width: "100%", + padding: "6px 10px", + border: "none", + borderRadius: 6, + background: "transparent", + color: T.text, + fontSize: 12, + textAlign: "left", + cursor: "pointer", + }; + + const promptLink = () => { + const previous = editor.getAttributes("link").href || ""; + const url = window.prompt("Link URL (leave empty to remove)", previous); + if (url === null) return; + if (url === "") { + editor.chain().focus().extendMarkRange("link").unsetLink().run(); + return; + } + if (!/^(https?|mailto):/i.test(url)) { + window.alert("Only http(s) and mailto links are allowed."); + return; + } + editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run(); + }; + + return ( + <> + + + + {divider} + + + + {divider} + + {/* Tables */} +
+ + {showTable && ( +
+ + {inTable && ( + <> +
+ + + + +
+ + +
+ + + + + )} +
+ )} +
+ + + + + {!compact && ( + <> + {divider} + + + + )} + + {divider} + + + {divider} + + + + ); +} + +export default EditorToolbar; diff --git a/frontend/src/editor/VersionPreview.jsx b/frontend/src/editor/VersionPreview.jsx new file mode 100644 index 0000000..44c1d38 --- /dev/null +++ b/frontend/src/editor/VersionPreview.jsx @@ -0,0 +1,39 @@ +import { useEffect, useMemo } from "react"; +import { useEditor, EditorContent } from "@tiptap/react"; + +import { buildExtensions } from "./extensions.js"; +import { parseNoteContent } from "./content"; +import "./editor.css"; + +/** + * Read-only render of a stored note body. + * + * Uses the same extension set as the real editor, so a past version is shown + * with the formatting it actually had — including tables and note links — + * rather than a stripped-down approximation. + */ +export function VersionPreview({ content, dark }) { + const extensions = useMemo(() => buildExtensions(), []); + + const editor = useEditor( + { + extensions, + editable: false, + content: parseNoteContent(content, extensions), + }, + [] + ); + + useEffect(() => { + if (!editor || editor.isDestroyed) return; + editor.commands.setContent(parseNoteContent(content, extensions), false); + }, [editor, content, extensions]); + + return ( +
+ +
+ ); +} + +export default VersionPreview; diff --git a/frontend/src/editor/attachmentImage.js b/frontend/src/editor/attachmentImage.js new file mode 100644 index 0000000..a604286 --- /dev/null +++ b/frontend/src/editor/attachmentImage.js @@ -0,0 +1,82 @@ +/** + * Images in a note body. + * + * New images are stored as attachments and the document only keeps their id. + * Older notes inlined the bytes as a base64 data URI; those still render, so + * nothing written before this change is lost — they simply stay heavy until + * re-inserted. + */ + +import Image from "@tiptap/extension-image"; +import { resolveUrl } from "./attachments.js"; + +export const AttachmentImage = Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + attachmentId: { + default: null, + parseHTML: (el) => el.getAttribute("data-attachment"), + renderHTML: (attrs) => (attrs.attachmentId ? { "data-attachment": attrs.attachmentId } : {}), + }, + // Preserved from the old inline-style images so their sizing survives. + width: { + default: null, + parseHTML: (el) => el.style?.width || el.getAttribute("width") || null, + renderHTML: (attrs) => (attrs.width ? { style: `width: ${attrs.width}` } : {}), + }, + }; + }, + + renderHTML({ HTMLAttributes }) { + // A blob URL is meaningless outside this page, so an attachment-backed image + // is serialised without a src; the node view supplies one at display time. + const attrs = { ...HTMLAttributes }; + if (attrs["data-attachment"]) delete attrs.src; + return ["img", attrs]; + }, + + addNodeView() { + return ({ node }) => { + const dom = document.createElement("img"); + dom.className = "note-image"; + if (node.attrs.alt) dom.alt = node.attrs.alt; + if (node.attrs.title) dom.title = node.attrs.title; + if (node.attrs.width) dom.style.width = node.attrs.width; + + let currentId = node.attrs.attachmentId; + + const paint = (attrs) => { + if (attrs.attachmentId) { + dom.dataset.attachment = attrs.attachmentId; + resolveUrl(attrs.attachmentId) + .then((url) => { + if (url) dom.src = url; + }) + .catch(() => { + dom.alt = attrs.alt || "Image unavailable"; + dom.dataset.missing = "true"; + }); + } else if (attrs.src) { + dom.src = attrs.src; // legacy inline data URI + } + }; + paint(node.attrs); + + return { + dom, + update(updated) { + if (updated.type.name !== node.type.name) return false; + if (updated.attrs.width !== dom.style.width) dom.style.width = updated.attrs.width || ""; + if (updated.attrs.attachmentId !== currentId) { + currentId = updated.attrs.attachmentId; + paint(updated.attrs); + } + return true; + }, + }; + }; + }, +}); + +export default AttachmentImage; diff --git a/frontend/src/editor/attachments.js b/frontend/src/editor/attachments.js new file mode 100644 index 0000000..3967a98 --- /dev/null +++ b/frontend/src/editor/attachments.js @@ -0,0 +1,73 @@ +/** + * Attachment plumbing for the editor. + * + * An `` tag cannot carry an Authorization header, so attachment bytes are + * fetched with an authenticated request, kept in IndexedDB and handed to the + * DOM as a blob URL. That also means images render offline, and each one is + * downloaded at most once per device. + */ + +import * as sync from "../syncEngine"; +import * as db from "../offlineStore"; + +const _urls = new Map(); // attachmentId -> object URL +const _inflight = new Map(); // attachmentId -> Promise + +export const MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024; +export const ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/avif"]; + +const readAsDataUrl = (file) => + new Promise((resolve, reject) => { + const r = new FileReader(); + r.onload = () => resolve(r.result); + r.onerror = () => reject(r.error || new Error("Could not read file")); + r.readAsDataURL(file); + }); + +/** Uploads a pasted or dropped image and returns its attachment id. */ +export async function uploadImage(file) { + if (!ACCEPTED_TYPES.includes(file.type)) + throw new Error(`Unsupported image type: ${file.type || "unknown"}`); + if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("Image is larger than 15 MB"); + + const dataUrl = await readAsDataUrl(file); + const { id } = await sync.uploadAttachment(dataUrl, file.name); + + // Seed the cache from the bytes already in hand — no round trip to display it. + try { + await db.putAttachmentBlob(id, file); + } catch { + /* cache is best-effort */ + } + return id; +} + +/** A displayable URL for an attachment, from cache when possible. */ +export function resolveUrl(id) { + if (!id) return Promise.resolve(null); + const cached = _urls.get(id); + if (cached) return Promise.resolve(cached); + if (_inflight.has(id)) return _inflight.get(id); + + const job = (async () => { + let blob = await db.getAttachmentBlob(id).catch(() => null); + if (!blob) { + blob = await sync.fetchAttachmentBlob(id); + db.putAttachmentBlob(id, blob).catch(() => {}); + } + const url = URL.createObjectURL(blob); + _urls.set(id, url); + return url; + })().finally(() => _inflight.delete(id)); + + _inflight.set(id, job); + return job; +} + +/* Object URLs live as long as the page does. They are a few dozen bytes of + * bookkeeping each and the same image usually reappears as the user moves + * between notes, so they are kept rather than revoked per unmount. */ +export function releaseAll() { + for (const url of _urls.values()) URL.revokeObjectURL(url); + _urls.clear(); +} diff --git a/frontend/src/editor/content.test.ts b/frontend/src/editor/content.test.ts new file mode 100644 index 0000000..93ea93c --- /dev/null +++ b/frontend/src/editor/content.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from "vitest"; + +import { parseNoteContent, serializeNoteContent, extractNoteLinks, docToText } from "./content"; +import { parsingExtensions } from "./extensions.js"; + +const parse = (raw: unknown) => parseNoteContent(raw, parsingExtensions); + +/** Collects every node type in a document, for asserting on structure. */ +const typesIn = (doc: any): string[] => { + const out: string[] = []; + (function walk(n: any) { + if (!n) return; + if (n.type) out.push(n.type); + if (Array.isArray(n.content)) n.content.forEach(walk); + })(doc); + return out; +}; + +describe("parseNoteContent", () => { + it("returns an empty document for empty input", () => { + for (const empty of [null, undefined, ""]) { + expect(parse(empty)).toEqual({ type: "doc", content: [{ type: "paragraph" }] }); + } + }); + + it("passes a v2 document through untouched", () => { + const doc = { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "hi" }] }] }; + expect(parse(JSON.stringify({ v: 2, doc }))).toEqual(doc); + }); + + describe("v1 migration", () => { + it("keeps headings, marks and lists", () => { + const doc = parse( + JSON.stringify({ + html: "

Title

Text with bold and italic.

  • one
", + tasks: [], + }) + ); + const types = typesIn(doc); + expect(types).toContain("heading"); + expect(types).toContain("bulletList"); + const marks = JSON.stringify(doc); + expect(marks).toContain("bold"); + expect(marks).toContain("italic"); + }); + + it("turns the detached task array into a task list, preserving checked state", () => { + const doc = parse( + JSON.stringify({ + html: "

body

", + tasks: [ + { id: "a", text: "done thing", done: true }, + { id: "b", text: "open thing", done: false }, + ], + }) + ); + expect(typesIn(doc)).toContain("taskList"); + const json = JSON.stringify(doc); + expect(json).toContain("done thing"); + expect(json).toContain("open thing"); + // exactly one of the two items is checked + expect((json.match(/"checked":true/g) || []).length).toBe(1); + }); + + it("does not let task text smuggle in markup", () => { + const doc = parse( + JSON.stringify({ html: "", tasks: [{ id: "a", text: "", done: false }] }) + ); + expect(typesIn(doc)).not.toContain("script"); + expect(docToText(doc)).toContain("" })); + expect(text).toContain("safe"); + expect(text).not.toContain("