TipTap editor, version history, search, attachments — and a cleanup
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
d97591b242
commit
92d525b3ab
71 files changed
+14350
-1822
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
backend/data/
|
||||||
|
backend/uploads/
|
||||||
|
/data/
|
||||||
+672
-102
@@ -35,8 +35,36 @@ const http = require("http");
|
|||||||
|
|
||||||
/* ─────────── Config ─────────── */
|
/* ─────────── Config ─────────── */
|
||||||
const PORT = process.env.PORT || 4000;
|
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 DATA_DIR = path.join(__dirname, "data");
|
||||||
const UPLOAD_DIR = path.join(__dirname, "uploads");
|
const UPLOAD_DIR = path.join(__dirname, "uploads");
|
||||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
@@ -50,7 +78,7 @@ db.pragma("foreign_keys = ON");
|
|||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
email TEXT UNIQUE NOT NULL,
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
display_name TEXT,
|
display_name TEXT,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
@@ -110,30 +138,128 @@ db.exec(`
|
|||||||
last_sync TEXT DEFAULT (datetime('now'))
|
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 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_notes_notebook ON notes(notebook_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_notebooks_user ON notebooks(user_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_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 ─────────── */
|
/* ─────────── 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) {
|
function encrypt(text) {
|
||||||
const iv = crypto.randomBytes(16);
|
const iv = crypto.randomBytes(12);
|
||||||
const key = Buffer.from(ENCRYPTION_KEY, "hex");
|
const cipher = crypto.createCipheriv("aes-256-gcm", ENCRYPTION_KEY_BUF, iv);
|
||||||
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
const enc = Buffer.concat([cipher.update(String(text), "utf8"), cipher.final()]);
|
||||||
let encrypted = cipher.update(text, "utf8", "hex");
|
return `v2:${iv.toString("hex")}:${cipher.getAuthTag().toString("hex")}:${enc.toString("hex")}`;
|
||||||
encrypted += cipher.final("hex");
|
|
||||||
return iv.toString("hex") + ":" + encrypted;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function decrypt(data) {
|
function decrypt(data) {
|
||||||
const [ivHex, encryptedHex] = data.split(":");
|
const s = String(data);
|
||||||
const iv = Buffer.from(ivHex, "hex");
|
if (s.startsWith("v2:")) {
|
||||||
const key = Buffer.from(ENCRYPTION_KEY, "hex");
|
const [, ivHex, tagHex, encHex] = s.split(":");
|
||||||
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
const decipher = crypto.createDecipheriv("aes-256-gcm", ENCRYPTION_KEY_BUF, Buffer.from(ivHex, "hex"));
|
||||||
let decrypted = decipher.update(encryptedHex, "hex", "utf8");
|
decipher.setAuthTag(Buffer.from(tagHex, "hex"));
|
||||||
decrypted += decipher.final("utf8");
|
return decipher.update(encHex, "hex", "utf8") + decipher.final("utf8");
|
||||||
return decrypted;
|
}
|
||||||
|
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 = "") {
|
function detectImageMime(buffer, filePath = "") {
|
||||||
@@ -188,9 +314,44 @@ function authMiddleware(req, res, next) {
|
|||||||
|
|
||||||
/* ─────────── Express app ─────────── */
|
/* ─────────── Express app ─────────── */
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set("trust proxy", 1); // nginx sits in front; needed for real client IPs
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
app.use(cors());
|
app.use(cors(ALLOWED_ORIGINS.length ? { origin: ALLOWED_ORIGINS } : { origin: false }));
|
||||||
app.use(express.json({ limit: "50mb" }));
|
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({
|
const upload = multer({
|
||||||
dest: UPLOAD_DIR,
|
dest: UPLOAD_DIR,
|
||||||
@@ -202,66 +363,88 @@ const upload = multer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/* ─── Auth routes ─── */
|
/* ─── Auth routes ─── */
|
||||||
app.post("/api/auth/register", (req, res) => {
|
app.post("/api/auth/register", async (req, res, next) => {
|
||||||
const { email, password, displayName } = req.body;
|
try {
|
||||||
if (!email || !password) return res.status(400).json({ error: "Email and password required" });
|
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);
|
const existing = db.prepare("SELECT id FROM users WHERE username = ?").get(name);
|
||||||
if (existing) return res.status(409).json({ error: "Email already registered" });
|
if (existing) return res.status(409).json({ error: "Username already taken" });
|
||||||
|
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
const hash = bcrypt.hashSync(password, 12);
|
const hash = await bcrypt.hash(password, 12);
|
||||||
db.prepare("INSERT INTO users (id, email, password_hash, display_name) VALUES (?, ?, ?, ?)").run(id, email, hash, displayName || email.split("@")[0]);
|
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" });
|
const token = jwt.sign({ userId: id }, JWT_SECRET, { expiresIn: "30d" });
|
||||||
res.json({ token, user: { id, email, displayName: displayName || email.split("@")[0] } });
|
res.json({ token, user: { id, username: name, displayName: displayName || name } });
|
||||||
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/auth/login", (req, res) => {
|
app.post("/api/auth/login", rateLimitLogin, async (req, res, next) => {
|
||||||
const { email, password } = req.body;
|
try {
|
||||||
const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email);
|
const { username, password } = req.body;
|
||||||
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
const name = String(username ?? "").trim();
|
||||||
return res.status(401).json({ error: "Invalid credentials" });
|
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 token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: "30d" });
|
const ok = await bcrypt.compare(String(password ?? ""), user ? user.password_hash : DUMMY_HASH);
|
||||||
res.json({ token, user: { id: user.id, email: user.email, displayName: user.display_name } });
|
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 ─── */
|
/* ─── Auth profile routes ─── */
|
||||||
app.get("/api/auth/me", authMiddleware, (req, res) => {
|
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" });
|
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) => {
|
app.put("/api/auth/profile", authMiddleware, (req, res) => {
|
||||||
const { displayName } = req.body;
|
const { displayName } = req.body;
|
||||||
if (!displayName || !displayName.trim()) return res.status(400).json({ error: "Display name required" });
|
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);
|
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);
|
const user = db.prepare("SELECT id, username, display_name FROM users WHERE id = ?").get(req.userId);
|
||||||
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/email", authMiddleware, (req, res) => {
|
app.put("/api/auth/username", authMiddleware, async (req, res, next) => {
|
||||||
const { newEmail, password } = req.body;
|
try {
|
||||||
if (!newEmail || !password) return res.status(400).json({ error: "Email and password required" });
|
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);
|
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" });
|
if (!user) return res.status(404).json({ error: "User not found" });
|
||||||
const exists = db.prepare("SELECT id FROM users WHERE email = ? AND id != ?").get(newEmail, req.userId);
|
if (!(await bcrypt.compare(String(password), user.password_hash))) return res.status(401).json({ error: "Wrong password" });
|
||||||
if (exists) return res.status(409).json({ error: "Email already in use" });
|
const exists = db.prepare("SELECT id FROM users WHERE username = ? AND id != ?").get(name, req.userId);
|
||||||
db.prepare("UPDATE users SET email = ?, updated_at = datetime('now') WHERE id = ?").run(newEmail, req.userId);
|
if (exists) return res.status(409).json({ error: "Username already taken" });
|
||||||
res.json({ id: user.id, email: newEmail, displayName: user.display_name });
|
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) => {
|
app.put("/api/auth/password", authMiddleware, async (req, res, next) => {
|
||||||
const { currentPassword, newPassword } = req.body;
|
try {
|
||||||
if (!currentPassword || !newPassword) return res.status(400).json({ error: "Both passwords required" });
|
const { currentPassword, newPassword } = req.body;
|
||||||
if (newPassword.length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" });
|
if (!currentPassword || !newPassword) return res.status(400).json({ error: "Both passwords required" });
|
||||||
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
|
if (String(newPassword).length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" });
|
||||||
if (!bcrypt.compareSync(currentPassword, user.password_hash)) return res.status(401).json({ error: "Current password is wrong" });
|
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
|
||||||
const hash = bcrypt.hashSync(newPassword, 12);
|
if (!user) return res.status(404).json({ error: "User not found" });
|
||||||
db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(hash, req.userId);
|
if (!(await bcrypt.compare(String(currentPassword), user.password_hash))) return res.status(401).json({ error: "Current password is wrong" });
|
||||||
res.json({ success: true });
|
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 ─── */
|
/* ─── Notebook routes ─── */
|
||||||
@@ -301,6 +484,61 @@ app.delete("/api/notebooks/:id", authMiddleware, (req, res) => {
|
|||||||
res.json({ success: true });
|
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 ─── */
|
/* ─── Note routes ─── */
|
||||||
app.get("/api/notes", authMiddleware, (req, res) => {
|
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);
|
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);
|
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" });
|
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) {
|
if (note.content_encrypted) {
|
||||||
try {
|
try {
|
||||||
note.content = decrypt(note.content_encrypted.toString());
|
note.content = decrypt(note.content_encrypted.toString());
|
||||||
} catch {
|
} catch (err) {
|
||||||
note.content = "";
|
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;
|
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)) {
|
if (note.drawing_data_path && fs.existsSync(note.drawing_data_path)) {
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(note.drawing_data_path);
|
const raw = fs.readFileSync(note.drawing_data_path);
|
||||||
@@ -359,8 +613,24 @@ app.post("/api/notes", authMiddleware, (req, res) => {
|
|||||||
res.json({ id });
|
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) => {
|
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);
|
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" });
|
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 encContent = content ? encrypt(content) : note.content_encrypted;
|
||||||
const newClock = { ...mergeVectorClocks(serverClock, clientClock), [req.deviceId]: (serverClock[req.deviceId] || 0) + 1 };
|
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
|
db.prepare(`UPDATE notes SET
|
||||||
title = COALESCE(?, title),
|
title = COALESCE(?, title),
|
||||||
content_encrypted = COALESCE(?, content_encrypted),
|
content_encrypted = COALESCE(?, content_encrypted),
|
||||||
@@ -394,10 +673,106 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => {
|
|||||||
WHERE id = ?`)
|
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);
|
.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);
|
broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, title } }, req.deviceId);
|
||||||
res.json({ success: true, vectorClock: newClock });
|
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) ─── */
|
/* ─── Trash batch endpoint (MUST be before :id routes) ─── */
|
||||||
app.delete("/api/notes/trash/empty", authMiddleware, (req, res) => {
|
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);
|
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 ─── */
|
/* ─── Drawing data route ─── */
|
||||||
|
const MAX_STROKES = 20000;
|
||||||
|
|
||||||
app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => {
|
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);
|
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 (!note) return res.status(404).json({ error: "Not found" });
|
||||||
|
|
||||||
if (!drawingData || typeof drawingData !== "string") return res.status(400).json({ error: "drawingData required" });
|
if (drawingStrokes !== undefined) {
|
||||||
const m = drawingData.match(/^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/);
|
// Vector strokes in the note's canonical coordinate space — the current format.
|
||||||
if (!m) return res.status(400).json({ error: "Invalid drawing format" });
|
if (drawingStrokes !== null) {
|
||||||
const mime = m[1];
|
if (typeof drawingStrokes !== "object" || !Array.isArray(drawingStrokes.strokes))
|
||||||
const ext = mime === "image/png" ? "png" : mime === "image/webp" ? "webp" : "jpg";
|
return res.status(400).json({ error: "Invalid strokes format" });
|
||||||
const base64Data = m[2];
|
if (drawingStrokes.strokes.length > MAX_STROKES)
|
||||||
const filePath = path.join(UPLOAD_DIR, `drawing_${req.params.id}.${ext}`);
|
return res.status(413).json({ error: "Too many strokes" });
|
||||||
|
|
||||||
// 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 {}
|
|
||||||
}
|
}
|
||||||
|
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', ?, ?)")
|
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);
|
.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);
|
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 });
|
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 ─── */
|
/* ─── PDF upload route ─── */
|
||||||
app.post("/api/notes/pdf", authMiddleware, upload.single("pdf"), (req, res) => {
|
app.post("/api/notes/pdf", authMiddleware, upload.single("pdf"), (req, res) => {
|
||||||
if (!req.file) return res.status(400).json({ error: "No file uploaded" });
|
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 wss = new WebSocketServer({ server, path: "/ws" });
|
||||||
const userConnections = new Map(); // userId -> Set<ws>
|
const userConnections = new Map(); // userId -> Set<ws>
|
||||||
|
|
||||||
wss.on("connection", (ws, req) => {
|
const AUTH_GRACE_MS = 10000;
|
||||||
let userId = null;
|
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) => {
|
ws.on("message", (data) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(data);
|
const msg = JSON.parse(data);
|
||||||
if (msg.type === "auth") {
|
if (msg.type !== "auth") return;
|
||||||
const payload = jwt.verify(msg.token, JWT_SECRET);
|
const payload = jwt.verify(msg.token, JWT_SECRET);
|
||||||
userId = payload.userId;
|
ws._userId = payload.userId;
|
||||||
const deviceId = msg.deviceId || "unknown";
|
ws._deviceId = msg.deviceId || "unknown";
|
||||||
|
clearTimeout(authTimer);
|
||||||
|
|
||||||
if (!userConnections.has(userId)) userConnections.set(userId, new Map());
|
// A Set, not a Map keyed by device: two tabs on one machine share a
|
||||||
userConnections.get(userId).set(deviceId, ws);
|
// device id, and keying by it meant the second tab silently replaced the
|
||||||
ws._deviceId = deviceId;
|
// 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 {
|
} catch {
|
||||||
ws.send(JSON.stringify({ type: "error", message: "Invalid message" }));
|
try { ws.send(JSON.stringify({ type: "error", message: "Invalid message" })); } catch {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
if (userId && userConnections.has(userId)) {
|
clearTimeout(authTimer);
|
||||||
userConnections.get(userId).delete(ws._deviceId);
|
const set = ws._userId && userConnections.get(ws._userId);
|
||||||
if (userConnections.get(userId).size === 0) userConnections.delete(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) {
|
function broadcastToUser(userId, message, excludeDeviceId) {
|
||||||
if (!userConnections.has(userId)) return;
|
const conns = userConnections.get(userId);
|
||||||
const connections = userConnections.get(userId);
|
if (!conns) return;
|
||||||
for (const [deviceId, ws] of connections) {
|
const payload = JSON.stringify(message);
|
||||||
if (deviceId !== excludeDeviceId && ws.readyState === 1) {
|
for (const ws of conns) {
|
||||||
ws.send(JSON.stringify(message));
|
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() });
|
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 ─── */
|
/* ─── Start server ─── */
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`\n ✏️ InkNote Server running on http://localhost:${PORT}`);
|
console.log(`\n ✏️ InkNote Server running on http://localhost:${PORT}`);
|
||||||
console.log(` 📡 WebSocket available at ws://localhost:${PORT}/ws`);
|
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")}`);
|
||||||
console.log(` 💾 Database: ${path.join(DATA_DIR, "inknote.db")}\n`);
|
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"));
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
REGISTRY="gitea.honeyy.duckdns.org"
|
REGISTRY="gitea.honeyy.duckdns.org"
|
||||||
OWNER="docker"
|
OWNER="${GITEA_OWNER:-marc}"
|
||||||
TAG="${1:-latest}"
|
TAG="${1:-latest}"
|
||||||
|
|
||||||
FRONTEND_IMAGE="${REGISTRY}/${OWNER}/inknote-web:${TAG}"
|
FRONTEND_IMAGE="${REGISTRY}/${OWNER}/inknote-web:${TAG}"
|
||||||
|
|||||||
@@ -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:
|
||||||
+14
-4
@@ -5,9 +5,15 @@ version: "3.8"
|
|||||||
# Pull images from Gitea container registry
|
# Pull images from Gitea container registry
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
|
# mkdir -p /srv/inknote/db /srv/inknote/uploads
|
||||||
|
# chown -R 1000:1000 /srv/inknote
|
||||||
# docker login gitea.honeyy.duckdns.org
|
# docker login gitea.honeyy.duckdns.org
|
||||||
# docker-compose pull
|
# docker compose pull
|
||||||
# docker-compose up -d
|
# 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:
|
services:
|
||||||
@@ -20,9 +26,13 @@ services:
|
|||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
- NODE_ENV=production
|
- 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:
|
volumes:
|
||||||
- inknote-data:/app/data
|
- ${INKNOTE_DATA_DIR:-/srv/inknote}/db:/app/data
|
||||||
- inknote-uploads:/app/uploads
|
- ${INKNOTE_DATA_DIR:-/srv/inknote}/uploads:/app/uploads
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
|
test: ["CMD", "wget", "-qO-", "http://localhost:4000/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 110,
|
||||||
|
"singleQuote": false,
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"arrowParens": "always",
|
||||||
|
"bracketSameLine": false
|
||||||
|
}
|
||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
<meta name="description" content="Write & draw — notes that sync everywhere" />
|
<meta name="description" content="Write & draw — notes that sync everywhere" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<link rel="manifest" href="./manifest.json" />
|
<link rel="manifest" href="./manifest.json" />
|
||||||
<link rel="apple-touch-icon" href="./icon-192.png" />
|
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="./icon-192.png" />
|
<link rel="icon" type="image/png" sizes="192x192" href="./icon-192.png" />
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>✏️</text></svg>" />
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>✏️</text></svg>" />
|
||||||
<title>InkNote</title>
|
<title>InkNote</title>
|
||||||
|
|||||||
+2
-2
@@ -29,8 +29,8 @@ server {
|
|||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Icons
|
# Icons (icon-*.png and apple-touch-icon.png)
|
||||||
location ~* ^/icon-.*\.png$ {
|
location ~* ^/(icon-.*|apple-touch-icon)\.png$ {
|
||||||
add_header Cache-Control "public, max-age=86400";
|
add_header Cache-Control "public, max-age=86400";
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3956
File diff suppressed because it is too large.
Load diff
+28
-2
@@ -6,14 +6,40 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"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": {
|
"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": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.31",
|
||||||
|
"@types/react-dom": "^18.3.7",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -9,7 +9,10 @@
|
|||||||
"background_color": "#0d1117",
|
"background_color": "#0d1117",
|
||||||
"theme_color": "#0d1117",
|
"theme_color": "#0d1117",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"categories": ["productivity", "utilities"],
|
"categories": [
|
||||||
|
"productivity",
|
||||||
|
"utilities"
|
||||||
|
],
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "./icon-192.png",
|
"src": "./icon-192.png",
|
||||||
@@ -24,13 +27,13 @@
|
|||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./icon-192.png",
|
"src": "./icon-maskable-192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./icon-512.png",
|
"src": "./icon-maskable-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = 'inknote-v6';
|
const CACHE_NAME = 'inknote-v7';
|
||||||
|
|
||||||
// Install — don't precache, just activate immediately
|
// Install — don't precache, just activate immediately
|
||||||
self.addEventListener('install', () => {
|
self.addEventListener('install', () => {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100vh",
|
||||||
|
background: T.bg,
|
||||||
|
color: T.text,
|
||||||
|
fontFamily: FONT_UI,
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) return <AuthScreen onLogin={onLogin} dark={dark} />;
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
height: "100vh",
|
||||||
|
width: "100vw",
|
||||||
|
overflow: "hidden",
|
||||||
|
background: T.bg,
|
||||||
|
color: T.text,
|
||||||
|
fontFamily: FONT_UI,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&family=DM+Sans:wght@400;500;600;700&display=swap');
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:transparent}
|
||||||
|
::-webkit-scrollbar-thumb{background:${T.borderLight};border-radius:3px}
|
||||||
|
button:hover{opacity:0.88}
|
||||||
|
input::placeholder{color:${T.faint}}
|
||||||
|
[contenteditable]:empty:before{content:'Start writing...';color:${T.faint};pointer-events:none}
|
||||||
|
[draggable]{user-select:none}
|
||||||
|
@media(max-width:767px){
|
||||||
|
::-webkit-scrollbar{display:none}
|
||||||
|
body{-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<Sidebar
|
||||||
|
T={T}
|
||||||
|
dark={dark}
|
||||||
|
onToggleDark={() => 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" ? (
|
||||||
|
<Dashboard
|
||||||
|
T={T}
|
||||||
|
isMobile={isMobile}
|
||||||
|
notebooks={notebooks}
|
||||||
|
activeNotes={activeNotes}
|
||||||
|
activeFolder={activeFolder}
|
||||||
|
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
onOpenNotebook={openFolder}
|
||||||
|
onOpenNote={(note) => {
|
||||||
|
setActiveFolder(note.folderId);
|
||||||
|
openNote(note.id);
|
||||||
|
}}
|
||||||
|
onNewNotebook={() => setShowNewNb(true)}
|
||||||
|
onNotebookContextMenu={notebookContextMenu}
|
||||||
|
dnd={dnd}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{showList && (
|
||||||
|
<NoteList
|
||||||
|
T={T}
|
||||||
|
isMobile={isMobile}
|
||||||
|
title={folderName}
|
||||||
|
notes={filteredNotes}
|
||||||
|
activeNote={activeNote}
|
||||||
|
isTrashView={isTrashView}
|
||||||
|
trashedCount={trashedNotes.length}
|
||||||
|
snippetFor={search.snippetFor}
|
||||||
|
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
onSelectNote={openNote}
|
||||||
|
onNoteContextMenu={noteContextMenu}
|
||||||
|
onCreateNote={createNote}
|
||||||
|
onEmptyTrash={emptyTrash}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showEditor && (
|
||||||
|
<EditorPane
|
||||||
|
T={T}
|
||||||
|
dark={dark}
|
||||||
|
isMobile={isMobile}
|
||||||
|
note={openNoteData}
|
||||||
|
isTrashView={isTrashView}
|
||||||
|
restoreEpoch={restoreEpoch}
|
||||||
|
backlinks={backlinks}
|
||||||
|
linkProps={linkProps}
|
||||||
|
onBack={closeNote}
|
||||||
|
onUpdate={(u) => data.updateNote(activeNote, u)}
|
||||||
|
onToggleStar={() => data.toggleStar(activeNote)}
|
||||||
|
onTrash={() => trashNote(activeNote)}
|
||||||
|
onRestore={() => restoreNote(activeNote)}
|
||||||
|
onPermanentDelete={() => permanentDeleteNote(activeNote)}
|
||||||
|
onOpenHistory={() => setHistoryFor(activeNote)}
|
||||||
|
onCreateNote={createNote}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNewNb && (
|
||||||
|
<CreateNotebookModal onClose={() => setShowNewNb(false)} onCreate={createNotebook} dark={dark} />
|
||||||
|
)}
|
||||||
|
{editingNb && (
|
||||||
|
<EditNotebookModal
|
||||||
|
notebook={editingNb}
|
||||||
|
onClose={() => setEditingNb(null)}
|
||||||
|
onSave={(u) => updateNotebook(editingNb.id, u)}
|
||||||
|
dark={dark}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showAccount && (
|
||||||
|
<AccountMenu
|
||||||
|
user={user}
|
||||||
|
onClose={() => setShowAccount(false)}
|
||||||
|
onLogout={() => {
|
||||||
|
setShowAccount(false);
|
||||||
|
onLogout();
|
||||||
|
}}
|
||||||
|
onUserUpdate={setUser}
|
||||||
|
dark={dark}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{historyFor && (
|
||||||
|
<HistoryModal
|
||||||
|
noteId={historyFor}
|
||||||
|
noteTitle={notes.find((n) => n.id === historyFor)?.title}
|
||||||
|
dark={dark}
|
||||||
|
onClose={() => setHistoryFor(null)}
|
||||||
|
onRestored={(content) => {
|
||||||
|
data.setNoteContent(historyFor, content);
|
||||||
|
setRestoreEpoch((e) => e + 1);
|
||||||
|
}}
|
||||||
|
renderDrawing={(strokes) => (
|
||||||
|
<DrawingLayer
|
||||||
|
strokes={strokes.strokes || []}
|
||||||
|
width={PREVIEW_DRAWING_W}
|
||||||
|
height={Math.round((PREVIEW_DRAWING_W * CANON_H) / CANON_W)}
|
||||||
|
zIndex={1}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{confirmDialog && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={confirmDialog.title}
|
||||||
|
message={confirmDialog.message}
|
||||||
|
confirmLabel={confirmDialog.confirmLabel}
|
||||||
|
onConfirm={confirmDialog.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
dark={dark}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{ctxMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={ctxMenu.x}
|
||||||
|
y={ctxMenu.y}
|
||||||
|
items={ctxMenu.items}
|
||||||
|
onClose={() => setCtxMenu(null)}
|
||||||
|
dark={dark}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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 = () => (
|
||||||
|
<svg {...strokeProps}>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<line x1="3" y1="9" x2="21" y2="9" />
|
||||||
|
<line x1="3" y1="15" x2="21" y2="15" />
|
||||||
|
<line x1="9" y1="3" x2="9" y2="21" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const LinkIcon = () => (
|
||||||
|
<svg {...strokeProps}>
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const QuoteIcon = () => (
|
||||||
|
<svg {...strokeProps}>
|
||||||
|
<path d="M6 17h3l2-4V7H5v6h3z" />
|
||||||
|
<path d="M16 17h3l2-4V7h-6v6h3z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const CodeIcon = () => (
|
||||||
|
<svg {...strokeProps}>
|
||||||
|
<polyline points="16 18 22 12 16 6" />
|
||||||
|
<polyline points="8 6 2 12 8 18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const AtIcon = () => (
|
||||||
|
<svg {...strokeProps}>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.9 7.9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = (
|
||||||
|
<div style={{ width: 1, height: 22, background: T.border, margin: "0 2px", flexShrink: 0 }} />
|
||||||
|
);
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
style={btn(editor.isActive("bold"))}
|
||||||
|
title="Bold (Ctrl+B)"
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
>
|
||||||
|
{icons.bold}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={btn(editor.isActive("italic"))}
|
||||||
|
title="Italic (Ctrl+I)"
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
>
|
||||||
|
{icons.italic}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={btn(editor.isActive("underline"))}
|
||||||
|
title="Underline (Ctrl+U)"
|
||||||
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||||
|
>
|
||||||
|
{icons.underline}
|
||||||
|
</button>
|
||||||
|
{divider}
|
||||||
|
<button
|
||||||
|
style={btn(editor.isActive("bulletList"))}
|
||||||
|
title="Bullet list"
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
>
|
||||||
|
{icons.list}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={btn(editor.isActive("orderedList"))}
|
||||||
|
title="Numbered list"
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
>
|
||||||
|
{icons.listOrd}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={btn(editor.isActive("taskList"))}
|
||||||
|
title="Task list"
|
||||||
|
onClick={() => editor.chain().focus().toggleTaskList().run()}
|
||||||
|
>
|
||||||
|
{icons.check}
|
||||||
|
</button>
|
||||||
|
{divider}
|
||||||
|
|
||||||
|
{/* Tables */}
|
||||||
|
<div style={{ position: "relative" }} data-table-menu>
|
||||||
|
<button style={btn(inTable || showTable)} title="Table" onClick={() => setShowTable((v) => !v)}>
|
||||||
|
<TableIcon />
|
||||||
|
</button>
|
||||||
|
{showTable && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 36,
|
||||||
|
left: 0,
|
||||||
|
minWidth: 190,
|
||||||
|
background: T.toolbar,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 6,
|
||||||
|
zIndex: 200,
|
||||||
|
boxShadow: "0 8px 20px rgba(0,0,0,.2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
style={menuItem}
|
||||||
|
onClick={() => {
|
||||||
|
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||||
|
setShowTable(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Insert 3 × 3 table
|
||||||
|
</button>
|
||||||
|
{inTable && (
|
||||||
|
<>
|
||||||
|
<div style={{ height: 1, background: T.border, margin: "5px 2px" }} />
|
||||||
|
<button style={menuItem} onClick={() => editor.chain().focus().addColumnBefore().run()}>
|
||||||
|
Add column left
|
||||||
|
</button>
|
||||||
|
<button style={menuItem} onClick={() => editor.chain().focus().addColumnAfter().run()}>
|
||||||
|
Add column right
|
||||||
|
</button>
|
||||||
|
<button style={menuItem} onClick={() => editor.chain().focus().addRowBefore().run()}>
|
||||||
|
Add row above
|
||||||
|
</button>
|
||||||
|
<button style={menuItem} onClick={() => editor.chain().focus().addRowAfter().run()}>
|
||||||
|
Add row below
|
||||||
|
</button>
|
||||||
|
<div style={{ height: 1, background: T.border, margin: "5px 2px" }} />
|
||||||
|
<button style={menuItem} onClick={() => editor.chain().focus().toggleHeaderRow().run()}>
|
||||||
|
Toggle header row
|
||||||
|
</button>
|
||||||
|
<button style={menuItem} onClick={() => editor.chain().focus().mergeOrSplit().run()}>
|
||||||
|
Merge / split cells
|
||||||
|
</button>
|
||||||
|
<div style={{ height: 1, background: T.border, margin: "5px 2px" }} />
|
||||||
|
<button
|
||||||
|
style={{ ...menuItem, color: T.danger || "#e5534b" }}
|
||||||
|
onClick={() => editor.chain().focus().deleteColumn().run()}
|
||||||
|
>
|
||||||
|
Delete column
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={{ ...menuItem, color: T.danger || "#e5534b" }}
|
||||||
|
onClick={() => editor.chain().focus().deleteRow().run()}
|
||||||
|
>
|
||||||
|
Delete row
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={{ ...menuItem, color: T.danger || "#e5534b" }}
|
||||||
|
onClick={() => {
|
||||||
|
editor.chain().focus().deleteTable().run();
|
||||||
|
setShowTable(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete table
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={btn(false)}
|
||||||
|
title="Link to another note (or type @)"
|
||||||
|
onClick={() => editor.chain().focus().insertContent("@").run()}
|
||||||
|
>
|
||||||
|
<AtIcon />
|
||||||
|
</button>
|
||||||
|
<button style={btn(editor.isActive("link"))} title="Web link" onClick={promptLink}>
|
||||||
|
<LinkIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!compact && (
|
||||||
|
<>
|
||||||
|
{divider}
|
||||||
|
<button
|
||||||
|
style={btn(editor.isActive("blockquote"))}
|
||||||
|
title="Quote"
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
>
|
||||||
|
<QuoteIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={btn(editor.isActive("codeBlock"))}
|
||||||
|
title="Code block"
|
||||||
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||||
|
>
|
||||||
|
<CodeIcon />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{divider}
|
||||||
|
<select
|
||||||
|
value={
|
||||||
|
editor.isActive("heading", { level: 1 })
|
||||||
|
? "h1"
|
||||||
|
: editor.isActive("heading", { level: 2 })
|
||||||
|
? "h2"
|
||||||
|
: editor.isActive("heading", { level: 3 })
|
||||||
|
? "h3"
|
||||||
|
: "p"
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (v === "p") editor.chain().focus().setParagraph().run();
|
||||||
|
else
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.toggleHeading({ level: Number(v[1]) })
|
||||||
|
.run();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "3px 6px",
|
||||||
|
background: T.toolbar,
|
||||||
|
color: T.text,
|
||||||
|
fontSize: 11,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="p">Body</option>
|
||||||
|
<option value="h1">H1</option>
|
||||||
|
<option value="h2">H2</option>
|
||||||
|
<option value="h3">H3</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{divider}
|
||||||
|
<button
|
||||||
|
style={{ ...btn(false), opacity: editor.can().undo() ? 1 : 0.35 }}
|
||||||
|
title="Undo"
|
||||||
|
onClick={() => editor.chain().focus().undo().run()}
|
||||||
|
>
|
||||||
|
{icons.undo}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={{ ...btn(false), opacity: editor.can().redo() ? 1 : 0.35 }}
|
||||||
|
title="Redo"
|
||||||
|
onClick={() => editor.chain().focus().redo().run()}
|
||||||
|
>
|
||||||
|
{icons.redo}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditorToolbar;
|
||||||
@@ -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 (
|
||||||
|
<div className="note-editor" data-theme={dark ? "dark" : "light"}>
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VersionPreview;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Attachment plumbing for the editor.
|
||||||
|
*
|
||||||
|
* An `<img>` 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<string>
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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: "<h2>Title</h2><p>Text with <b>bold</b> and <i>italic</i>.</p><ul><li>one</li></ul>",
|
||||||
|
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: "<p>body</p>",
|
||||||
|
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: "<script>alert(1)</script>", done: false }] })
|
||||||
|
);
|
||||||
|
expect(typesIn(doc)).not.toContain("script");
|
||||||
|
expect(docToText(doc)).toContain("<script>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a bare HTML string, which the very first version stored", () => {
|
||||||
|
expect(typesIn(parse("<p>plain</p>"))).toContain("paragraph");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to plain text rather than throwing on unparseable input", () => {
|
||||||
|
expect(() => parse("{ not json at all")).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("serializeNoteContent", () => {
|
||||||
|
it("round-trips through parse", () => {
|
||||||
|
const doc = { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "x" }] }] };
|
||||||
|
expect(parse(serializeNoteContent(doc))).toEqual(doc);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tags the stored format so future versions can tell them apart", () => {
|
||||||
|
expect(JSON.parse(serializeNoteContent({ type: "doc" })).v).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractNoteLinks", () => {
|
||||||
|
const docWith = (...ids: string[]) => ({
|
||||||
|
type: "doc",
|
||||||
|
content: ids.map((id) => ({
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "noteLink", attrs: { noteId: id, label: id } }],
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds every linked id", () => {
|
||||||
|
expect(extractNoteLinks(docWith("a", "b")).sort()).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates repeated links", () => {
|
||||||
|
expect(extractNoteLinks(docWith("a", "a", "a"))).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a link to the note itself", () => {
|
||||||
|
expect(extractNoteLinks(docWith("self", "other"), "self")).toEqual(["other"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds links nested inside tables", () => {
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tableRow",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tableCell",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "noteLink", attrs: { noteId: "deep", label: "d" } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(extractNoteLinks(doc)).toEqual(["deep"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for a document without links", () => {
|
||||||
|
expect(extractNoteLinks({ type: "doc", content: [{ type: "paragraph" }] })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("docToText", () => {
|
||||||
|
it("joins text across nested nodes", () => {
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "first" }] },
|
||||||
|
{
|
||||||
|
type: "bulletList",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "listItem",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: "second" }] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(docToText(doc)).toBe("first second");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes note link labels, so linked notes are searchable by name", () => {
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "noteLink", attrs: { label: "Recipes" } }] }],
|
||||||
|
};
|
||||||
|
expect(docToText(doc)).toContain("Recipes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects the length limit", () => {
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: "x".repeat(500) }] }],
|
||||||
|
};
|
||||||
|
expect(docToText(doc, 50).length).toBeLessThanOrEqual(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Note content format and migration.
|
||||||
|
*
|
||||||
|
* v1 (contentEditable era): JSON.stringify({ html, tasks }) — unstructured HTML
|
||||||
|
* from document.execCommand plus a separate checkbox list. Tables could not
|
||||||
|
* be represented reliably and links had no identity.
|
||||||
|
* v2 (current): JSON.stringify({ v: 2, doc }) where `doc` is a TipTap/ProseMirror
|
||||||
|
* document. Structured, so tables, task lists and note links are real nodes.
|
||||||
|
*
|
||||||
|
* v1 notes are converted on read and rewritten as v2 the next time they are
|
||||||
|
* saved. Nothing is migrated in bulk, so an old client reading a note it has
|
||||||
|
* not touched still sees its own format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { generateJSON } from "@tiptap/html";
|
||||||
|
import type { Extensions } from "@tiptap/core";
|
||||||
|
|
||||||
|
/** A TipTap/ProseMirror node. Deliberately loose: the schema is the authority. */
|
||||||
|
export interface DocNode {
|
||||||
|
type?: string;
|
||||||
|
text?: string;
|
||||||
|
attrs?: Record<string, any>;
|
||||||
|
content?: DocNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTENT_VERSION = 2;
|
||||||
|
|
||||||
|
export const emptyDoc = (): DocNode => ({ type: "doc", content: [{ type: "paragraph" }] });
|
||||||
|
|
||||||
|
const escapeHtml = (s: unknown): string =>
|
||||||
|
String(s ?? "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
|
||||||
|
/** Renders a v1 `tasks` array as the HTML TipTap's TaskList parses. */
|
||||||
|
function tasksToHtml(tasks: unknown): string {
|
||||||
|
if (!Array.isArray(tasks) || tasks.length === 0) return "";
|
||||||
|
const items = tasks
|
||||||
|
.map(
|
||||||
|
(t: { done?: boolean; text?: string }) =>
|
||||||
|
`<li data-type="taskItem" data-checked="${t.done ? "true" : "false"}"><p>${escapeHtml(t.text)}</p></li>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
return `<ul data-type="taskList">${items}</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns whatever is stored in `note.content` into a TipTap document.
|
||||||
|
* Never throws: a note that cannot be parsed opens empty rather than blocking
|
||||||
|
* the editor, and the original string is left untouched until the user edits.
|
||||||
|
*/
|
||||||
|
export function parseNoteContent(raw: unknown, extensions: Extensions): DocNode {
|
||||||
|
if (!raw) return emptyDoc();
|
||||||
|
|
||||||
|
let parsed: any = null;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
} else if (typeof raw === "object") {
|
||||||
|
parsed = raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v2 — already a TipTap document.
|
||||||
|
if (parsed && parsed.v === CONTENT_VERSION && parsed.doc) return parsed.doc;
|
||||||
|
|
||||||
|
// v1 — HTML plus a detached task list, or a bare HTML string.
|
||||||
|
const html =
|
||||||
|
typeof parsed?.html === "string"
|
||||||
|
? parsed.html
|
||||||
|
: typeof parsed === "string"
|
||||||
|
? parsed
|
||||||
|
: typeof raw === "string"
|
||||||
|
? raw
|
||||||
|
: "";
|
||||||
|
const combined = html + tasksToHtml(parsed?.tasks);
|
||||||
|
if (!combined.trim()) return emptyDoc();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return generateJSON(combined, extensions);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[content] could not migrate v1 note, opening as plain text:", err);
|
||||||
|
return {
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: stripHtml(combined) }] }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeNoteContent(doc: DocNode): string {
|
||||||
|
return JSON.stringify({ v: CONTENT_VERSION, doc });
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(html: string): string {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.innerHTML = html;
|
||||||
|
return el.textContent || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth-first walk over every node in a TipTap document. */
|
||||||
|
function walk(node: DocNode | undefined | null, visit: (n: DocNode) => void): void {
|
||||||
|
if (!node) return;
|
||||||
|
visit(node);
|
||||||
|
if (Array.isArray(node.content)) for (const child of node.content) walk(child, visit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ids of every note this document links to.
|
||||||
|
*
|
||||||
|
* The server stores content encrypted and cannot read it, so the client is what
|
||||||
|
* tells it which links exist. Deduplicated and self-links dropped.
|
||||||
|
*/
|
||||||
|
export function extractNoteLinks(doc: DocNode, selfId?: string): string[] {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
walk(doc, (n) => {
|
||||||
|
if (n.type === "noteLink" && n.attrs?.noteId && n.attrs.noteId !== selfId) ids.add(n.attrs.noteId);
|
||||||
|
});
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain text of a document — used for list previews and local search. */
|
||||||
|
export function docToText(doc: unknown, limit = 400): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let len = 0;
|
||||||
|
walk(doc as DocNode, (n) => {
|
||||||
|
if (len >= limit) return;
|
||||||
|
if (n.type === "text" && n.text) {
|
||||||
|
parts.push(n.text);
|
||||||
|
len += n.text.length;
|
||||||
|
} else if (n.type === "noteLink" && n.attrs?.label) {
|
||||||
|
parts.push(n.attrs.label);
|
||||||
|
len += n.attrs.label.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return parts.join(" ").replace(/\s+/g, " ").trim().slice(0, limit);
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
/* Editor content styling.
|
||||||
|
*
|
||||||
|
* The rest of the app styles inline, but ProseMirror renders its own DOM, so
|
||||||
|
* these nodes can only be reached from a stylesheet. Colours come from custom
|
||||||
|
* properties the editor container sets per theme. */
|
||||||
|
|
||||||
|
.note-editor {
|
||||||
|
--ne-text: #1f2328;
|
||||||
|
--ne-muted: #656d76;
|
||||||
|
--ne-faint: #afb8c1;
|
||||||
|
--ne-border: #d0d7de;
|
||||||
|
--ne-accent: #0969da;
|
||||||
|
--ne-surface: #ffffff;
|
||||||
|
--ne-subtle: #f6f8fa;
|
||||||
|
}
|
||||||
|
.note-editor[data-theme="dark"] {
|
||||||
|
--ne-text: #e6edf3;
|
||||||
|
--ne-muted: #8b949e;
|
||||||
|
--ne-faint: #484f58;
|
||||||
|
--ne-border: #30363d;
|
||||||
|
--ne-accent: #388bfd;
|
||||||
|
--ne-surface: #0d1117;
|
||||||
|
--ne-subtle: #161b22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-editor .ProseMirror {
|
||||||
|
outline: none;
|
||||||
|
color: var(--ne-text);
|
||||||
|
font-family: "Newsreader", Georgia, serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
padding: 20px 24px;
|
||||||
|
min-height: 100%;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror > * + * {
|
||||||
|
margin-top: 0.6em;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror h1 {
|
||||||
|
font-size: 1.7em;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 0.8em;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror h2 {
|
||||||
|
font-size: 1.4em;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 0.7em;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror h3 {
|
||||||
|
font-size: 1.15em;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 0.6em;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror ul,
|
||||||
|
.note-editor .ProseMirror ol {
|
||||||
|
padding-left: 1.4em;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror li p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror blockquote {
|
||||||
|
border-left: 3px solid var(--ne-border);
|
||||||
|
padding-left: 12px;
|
||||||
|
color: var(--ne-muted);
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror code {
|
||||||
|
background: var(--ne-subtle);
|
||||||
|
border: 1px solid var(--ne-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror pre {
|
||||||
|
background: var(--ne-subtle);
|
||||||
|
border: 1px solid var(--ne-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror pre code {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--ne-border);
|
||||||
|
margin: 1.2em 0;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror img.note-image {
|
||||||
|
background: var(--ne-subtle);
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
/* An attachment whose bytes could not be fetched — visible, not a blank gap */
|
||||||
|
.note-editor .ProseMirror img[data-missing="true"] {
|
||||||
|
border: 1px dashed var(--ne-border);
|
||||||
|
padding: 12px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror a {
|
||||||
|
color: var(--ne-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder on the first empty line */
|
||||||
|
.note-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: var(--ne-faint);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tables ── */
|
||||||
|
.note-editor .ProseMirror table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.8em 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror td,
|
||||||
|
.note-editor .ProseMirror th {
|
||||||
|
border: 1px solid var(--ne-border);
|
||||||
|
padding: 7px 10px;
|
||||||
|
vertical-align: top;
|
||||||
|
position: relative;
|
||||||
|
min-width: 1em;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror th {
|
||||||
|
background: var(--ne-subtle);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror .selectedCell::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: color-mix(in srgb, var(--ne-accent) 18%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* Drag handle between columns */
|
||||||
|
.note-editor .ProseMirror .column-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
top: 0;
|
||||||
|
bottom: -2px;
|
||||||
|
width: 4px;
|
||||||
|
background: var(--ne-accent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror.resize-cursor {
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Task lists ── */
|
||||||
|
.note-editor .ProseMirror ul[data-type="taskList"] {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror ul[data-type="taskList"] li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror ul[data-type="taskList"] li > label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-top: 0.35em;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror ul[data-type="taskList"] li > div {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror ul[data-type="taskList"] input[type="checkbox"] {
|
||||||
|
accent-color: var(--ne-accent);
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror ul[data-type="taskList"] li[data-checked="true"] > div {
|
||||||
|
color: var(--ne-muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Note links ── */
|
||||||
|
.note-editor .ProseMirror a.note-link,
|
||||||
|
.note-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--ne-accent) 14%, transparent);
|
||||||
|
color: var(--ne-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: "DM Sans", system-ui, sans-serif;
|
||||||
|
font-size: 0.9em;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.note-editor .ProseMirror a.note-link:hover {
|
||||||
|
background: color-mix(in srgb, var(--ne-accent) 26%, transparent);
|
||||||
|
}
|
||||||
|
/* A link whose target is gone — still readable, visibly broken */
|
||||||
|
.note-editor .ProseMirror a.note-link[data-missing="true"] {
|
||||||
|
background: color-mix(in srgb, #cf222e 14%, transparent);
|
||||||
|
color: #cf222e;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── @-mention popup ── */
|
||||||
|
.note-link-popup {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 3000;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--popup-border, #d0d7de);
|
||||||
|
background: var(--popup-bg, #ffffff);
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.22);
|
||||||
|
font-family: "DM Sans", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.note-link-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--popup-text, #1f2328);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.note-link-option.is-selected {
|
||||||
|
background: var(--popup-hover, #eaeef2);
|
||||||
|
}
|
||||||
|
.note-link-option .note-link-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.note-link-empty {
|
||||||
|
padding: 9px 11px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--popup-muted, #656d76);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/** The one place that decides which nodes and marks a note may contain. */
|
||||||
|
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import Underline from "@tiptap/extension-underline";
|
||||||
|
import Link from "@tiptap/extension-link";
|
||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
|
import Table from "@tiptap/extension-table";
|
||||||
|
import TableRow from "@tiptap/extension-table-row";
|
||||||
|
import TableHeader from "@tiptap/extension-table-header";
|
||||||
|
import TableCell from "@tiptap/extension-table-cell";
|
||||||
|
|
||||||
|
import { NoteLink } from "./noteLink.js";
|
||||||
|
import { AttachmentImage } from "./attachmentImage.js";
|
||||||
|
|
||||||
|
export function buildExtensions({ search = () => [], placeholder = "Start writing..." } = {}) {
|
||||||
|
return [
|
||||||
|
StarterKit.configure({
|
||||||
|
heading: { levels: [1, 2, 3] },
|
||||||
|
// History is on by default; the drawing layer keeps its own undo stack.
|
||||||
|
codeBlock: { HTMLAttributes: { class: "note-code-block" } },
|
||||||
|
}),
|
||||||
|
Underline,
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false, // the app decides what a click does
|
||||||
|
autolink: true,
|
||||||
|
protocols: ["http", "https", "mailto"],
|
||||||
|
HTMLAttributes: { rel: "noopener noreferrer nofollow", target: "_blank" },
|
||||||
|
}),
|
||||||
|
// allowBase64 stays on so notes written before attachments still render.
|
||||||
|
AttachmentImage.configure({ inline: false, allowBase64: true }),
|
||||||
|
Placeholder.configure({ placeholder }),
|
||||||
|
TaskList,
|
||||||
|
TaskItem.configure({ nested: true }),
|
||||||
|
Table.configure({ resizable: true, lastColumnResizable: false, allowTableNodeSelection: true }),
|
||||||
|
TableRow,
|
||||||
|
TableHeader,
|
||||||
|
TableCell,
|
||||||
|
NoteLink.configure({ search }),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A static extension list for parsing stored HTML outside of a live editor
|
||||||
|
* (`generateJSON`). It must accept everything `buildExtensions` produces, or
|
||||||
|
* migrating an old note would silently drop nodes.
|
||||||
|
*/
|
||||||
|
export const parsingExtensions = buildExtensions();
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* Note links — type `@` to reference another note or notebook.
|
||||||
|
*
|
||||||
|
* A link is an atomic inline node carrying the target's id, so it survives
|
||||||
|
* renames: the label is refreshed from the current title on render, and the id
|
||||||
|
* is what the backlink index is built from.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Node, mergeAttributes } from "@tiptap/core";
|
||||||
|
import Suggestion from "@tiptap/suggestion";
|
||||||
|
import { PluginKey } from "@tiptap/pm/state";
|
||||||
|
|
||||||
|
export const NoteLinkPluginKey = new PluginKey("noteLinkSuggestion");
|
||||||
|
|
||||||
|
export const NoteLink = Node.create({
|
||||||
|
name: "noteLink",
|
||||||
|
group: "inline",
|
||||||
|
inline: true,
|
||||||
|
atom: true,
|
||||||
|
selectable: true,
|
||||||
|
draggable: false,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
// (query) => [{ id, label, kind: 'note' | 'notebook' }]
|
||||||
|
search: () => [],
|
||||||
|
HTMLAttributes: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
noteId: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (el) => el.getAttribute("data-note-link"),
|
||||||
|
renderHTML: (a) => ({ "data-note-link": a.noteId }),
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
default: "",
|
||||||
|
parseHTML: (el) => el.getAttribute("data-label") || el.textContent,
|
||||||
|
renderHTML: (a) => ({ "data-label": a.label }),
|
||||||
|
},
|
||||||
|
kind: {
|
||||||
|
default: "note",
|
||||||
|
parseHTML: (el) => el.getAttribute("data-kind") || "note",
|
||||||
|
renderHTML: (a) => ({ "data-kind": a.kind }),
|
||||||
|
},
|
||||||
|
// Set when the target no longer exists, so a dead link reads as broken
|
||||||
|
// instead of silently going nowhere.
|
||||||
|
missing: {
|
||||||
|
default: false,
|
||||||
|
parseHTML: (el) => el.getAttribute("data-missing") === "true",
|
||||||
|
renderHTML: (a) => (a.missing ? { "data-missing": "true" } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: "a[data-note-link]" }, { tag: "span[data-note-link]" }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ node, HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"a",
|
||||||
|
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||||
|
class: "note-link",
|
||||||
|
// Real href would navigate away; the app intercepts the click instead.
|
||||||
|
href: "#",
|
||||||
|
title: node.attrs.label,
|
||||||
|
}),
|
||||||
|
`${node.attrs.kind === "notebook" ? "📓" : "📝"} ${node.attrs.label}`,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderText({ node }) {
|
||||||
|
return `@${node.attrs.label}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
Suggestion({
|
||||||
|
editor: this.editor,
|
||||||
|
char: "@",
|
||||||
|
pluginKey: NoteLinkPluginKey,
|
||||||
|
allowSpaces: true,
|
||||||
|
startOfLine: false,
|
||||||
|
items: ({ query }) => this.options.search(query),
|
||||||
|
command: ({ editor, range, props }) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContentAt(range, [
|
||||||
|
{
|
||||||
|
type: "noteLink",
|
||||||
|
attrs: { noteId: props.id, label: props.label, kind: props.kind || "note" },
|
||||||
|
},
|
||||||
|
{ type: "text", text: " " },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
render: renderSuggestionList,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small dropdown driven by the suggestion plugin.
|
||||||
|
*
|
||||||
|
* Deliberately plain DOM rather than a positioning library — it is one list in
|
||||||
|
* one place, and pulling in tippy/popper for it is not worth the bundle.
|
||||||
|
*/
|
||||||
|
function renderSuggestionList() {
|
||||||
|
let el = null;
|
||||||
|
let items = [];
|
||||||
|
let selected = 0;
|
||||||
|
let command = null;
|
||||||
|
|
||||||
|
const paint = () => {
|
||||||
|
if (!el) return;
|
||||||
|
if (items.length === 0) {
|
||||||
|
el.innerHTML = '<div class="note-link-empty">No matching note</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = items
|
||||||
|
.map(
|
||||||
|
(
|
||||||
|
it,
|
||||||
|
i
|
||||||
|
) => `<button type="button" class="note-link-option${i === selected ? " is-selected" : ""}" data-index="${i}">
|
||||||
|
<span class="note-link-kind">${it.kind === "notebook" ? "📓" : "📝"}</span>
|
||||||
|
<span class="note-link-label"></span>
|
||||||
|
</button>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
// Titles are user text — set them as text, never as markup.
|
||||||
|
el.querySelectorAll(".note-link-option").forEach((btn, i) => {
|
||||||
|
btn.querySelector(".note-link-label").textContent = items[i].label;
|
||||||
|
btn.addEventListener("mousedown", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
pick(i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pick = (i) => {
|
||||||
|
const item = items[i];
|
||||||
|
if (item && command) command({ id: item.id, label: item.label, kind: item.kind });
|
||||||
|
};
|
||||||
|
|
||||||
|
const place = (rect) => {
|
||||||
|
if (!el || !rect) return;
|
||||||
|
const r = rect();
|
||||||
|
if (!r) return;
|
||||||
|
const width = 260;
|
||||||
|
const left = Math.min(Math.max(8, r.left), window.innerWidth - width - 8);
|
||||||
|
// Flip above the caret when there is no room below.
|
||||||
|
const below = window.innerHeight - r.bottom;
|
||||||
|
el.style.width = `${width}px`;
|
||||||
|
el.style.left = `${left}px`;
|
||||||
|
if (below < 220) {
|
||||||
|
el.style.top = "auto";
|
||||||
|
el.style.bottom = `${window.innerHeight - r.top + 6}px`;
|
||||||
|
} else {
|
||||||
|
el.style.bottom = "auto";
|
||||||
|
el.style.top = `${r.bottom + 6}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: (props) => {
|
||||||
|
items = props.items || [];
|
||||||
|
selected = 0;
|
||||||
|
command = props.command;
|
||||||
|
el = document.createElement("div");
|
||||||
|
el.className = "note-link-popup";
|
||||||
|
document.body.appendChild(el);
|
||||||
|
paint();
|
||||||
|
place(props.clientRect);
|
||||||
|
},
|
||||||
|
onUpdate: (props) => {
|
||||||
|
items = props.items || [];
|
||||||
|
selected = Math.min(selected, Math.max(0, items.length - 1));
|
||||||
|
command = props.command;
|
||||||
|
paint();
|
||||||
|
place(props.clientRect);
|
||||||
|
},
|
||||||
|
onKeyDown: (props) => {
|
||||||
|
const key = props.event.key;
|
||||||
|
if (key === "Escape") {
|
||||||
|
props.event.preventDefault();
|
||||||
|
el?.remove();
|
||||||
|
el = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (items.length === 0) return false;
|
||||||
|
if (key === "ArrowDown") {
|
||||||
|
selected = (selected + 1) % items.length;
|
||||||
|
paint();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (key === "ArrowUp") {
|
||||||
|
selected = (selected - 1 + items.length) % items.length;
|
||||||
|
paint();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (key === "Enter" || key === "Tab") {
|
||||||
|
props.event.preventDefault();
|
||||||
|
pick(selected);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
onExit: () => {
|
||||||
|
el?.remove();
|
||||||
|
el = null;
|
||||||
|
items = [];
|
||||||
|
command = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useCallback } from "react";
|
||||||
|
import { useEditor, EditorContent } from "@tiptap/react";
|
||||||
|
|
||||||
|
import { buildExtensions } from "./extensions.js";
|
||||||
|
import { parseNoteContent, serializeNoteContent, extractNoteLinks } from "./content";
|
||||||
|
import { uploadImage, ACCEPTED_TYPES } from "./attachments.js";
|
||||||
|
import "./editor.css";
|
||||||
|
|
||||||
|
/* Marks transactions this module dispatches itself, so they are not mistaken
|
||||||
|
* for the user editing the note. */
|
||||||
|
const LABEL_REFRESH = "noteLinkLabelRefresh";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads images from a paste or drop and inserts them as attachments.
|
||||||
|
*
|
||||||
|
* Returns true only when images were actually taken over, so pasting text or a
|
||||||
|
* screenshot-free clipboard still behaves normally.
|
||||||
|
*/
|
||||||
|
function insertDroppedImages(view, transfer, at, onErrorRef) {
|
||||||
|
const files = [...(transfer?.files || [])].filter((f) => ACCEPTED_TYPES.includes(f.type));
|
||||||
|
if (files.length === 0) return false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const id = await uploadImage(file);
|
||||||
|
const node = view.state.schema.nodes.image.create({ attachmentId: id, alt: file.name || null });
|
||||||
|
const pos = at ?? view.state.selection.from;
|
||||||
|
view.dispatch(view.state.tr.insert(Math.min(pos, view.state.doc.content.size), node));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[attachment] upload failed:", err);
|
||||||
|
onErrorRef?.current?.(err.message || "Could not attach that image");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return true; // handled — do not also paste the raw file
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the note body editor.
|
||||||
|
*
|
||||||
|
* The instance is returned rather than kept inside a component because the
|
||||||
|
* toolbar lives elsewhere in the tree and needs the same editor to drive it.
|
||||||
|
*/
|
||||||
|
export function useNoteEditor({ noteId, content, editable = true, onChange, searchLinkTargets, onError }) {
|
||||||
|
// Held in refs so a changing callback identity never rebuilds the editor,
|
||||||
|
// which would drop the cursor and the undo history mid-typing.
|
||||||
|
const searchRef = useRef(searchLinkTargets);
|
||||||
|
searchRef.current = searchLinkTargets;
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
const onErrorRef = useRef(onError);
|
||||||
|
onErrorRef.current = onError;
|
||||||
|
|
||||||
|
const extensions = useMemo(
|
||||||
|
() => buildExtensions({ search: (query) => (searchRef.current ? searchRef.current(query) : []) }),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const editor = useEditor(
|
||||||
|
{
|
||||||
|
extensions,
|
||||||
|
content: parseNoteContent(content, extensions),
|
||||||
|
editable,
|
||||||
|
editorProps: {
|
||||||
|
attributes: { spellcheck: "true" },
|
||||||
|
handlePaste: (view, event) => insertDroppedImages(view, event.clipboardData, null, onErrorRef),
|
||||||
|
handleDrop: (view, event) => {
|
||||||
|
const at = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos ?? null;
|
||||||
|
return insertDroppedImages(view, event.dataTransfer, at, onErrorRef);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor: ed, transaction }) => {
|
||||||
|
// Refreshing a link label is not a user edit and must not mark the note dirty.
|
||||||
|
if (transaction?.getMeta(LABEL_REFRESH)) return;
|
||||||
|
const doc = ed.getJSON();
|
||||||
|
onChangeRef.current?.({
|
||||||
|
content: serializeNoteContent(doc),
|
||||||
|
links: extractNoteLinks(doc, noteId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Rebuilt per note so each one starts with its own document and history.
|
||||||
|
},
|
||||||
|
[noteId]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && editor.isEditable !== editable) editor.setEditable(editable);
|
||||||
|
}, [editor, editable]);
|
||||||
|
|
||||||
|
/* Adopt content that arrived from another device. Skipped while the editor
|
||||||
|
* has focus, so a sync push can never yank the document out from under the
|
||||||
|
* cursor, and skipped when nothing actually differs. */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || editor.isDestroyed || editor.isFocused) return;
|
||||||
|
const incoming = parseNoteContent(content, extensions);
|
||||||
|
if (JSON.stringify(incoming) === JSON.stringify(editor.getJSON())) return;
|
||||||
|
editor.commands.setContent(incoming, false);
|
||||||
|
}, [editor, content, extensions]);
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The editable page itself. */
|
||||||
|
export function EditorSurface({ editor, dark, minHeight, onOpenNote, resolveLinkLabel }) {
|
||||||
|
/* Note links carry the label they were created with, so renaming a note would
|
||||||
|
* leave every reference to it showing the old title.
|
||||||
|
*
|
||||||
|
* This has to go through a transaction. Writing the new label straight into
|
||||||
|
* `editor.view.dom` looks simpler and works on screen, but ProseMirror's
|
||||||
|
* mutation observer then re-reads the document from that DOM and drops the
|
||||||
|
* node — the link survived on screen and vanished on the next reload. */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || editor.isDestroyed || !resolveLinkLabel) return;
|
||||||
|
const edits = [];
|
||||||
|
editor.state.doc.descendants((node, pos) => {
|
||||||
|
if (node.type.name !== "noteLink") return;
|
||||||
|
const label = resolveLinkLabel(node.attrs.noteId);
|
||||||
|
const missing = label === null;
|
||||||
|
const nextLabel = missing ? node.attrs.label : label;
|
||||||
|
if (nextLabel !== node.attrs.label || missing !== !!node.attrs.missing) {
|
||||||
|
edits.push([pos, { ...node.attrs, label: nextLabel, missing }]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (edits.length === 0) return;
|
||||||
|
const tr = editor.state.tr;
|
||||||
|
for (const [pos, attrs] of edits) tr.setNodeMarkup(pos, undefined, attrs);
|
||||||
|
tr.setMeta(LABEL_REFRESH, true);
|
||||||
|
tr.setMeta("addToHistory", false); // undo should not step through renames
|
||||||
|
editor.view.dispatch(tr);
|
||||||
|
}, [editor, resolveLinkLabel]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e) => {
|
||||||
|
const noteLink = e.target.closest("a.note-link");
|
||||||
|
if (noteLink) {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = noteLink.getAttribute("data-note-link");
|
||||||
|
if (id && !noteLink.hasAttribute("data-missing")) {
|
||||||
|
onOpenNote?.(id, noteLink.getAttribute("data-kind") || "note");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const external = e.target.closest("a[href]");
|
||||||
|
if (external) {
|
||||||
|
const url = external.getAttribute("href");
|
||||||
|
if (url && /^(https?|mailto):/i.test(url)) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.open(url, "_blank", "noopener");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onOpenNote]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="note-editor"
|
||||||
|
data-theme={dark ? "dark" : "light"}
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{ minHeight }}
|
||||||
|
>
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { sizeCanvas, paintStrokeDoc, strokeDocOf } from "./strokes.js";
|
||||||
|
|
||||||
|
export function DrawingLayer({ strokes, width, height, zIndex }) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const c = ref.current;
|
||||||
|
if (!c) return;
|
||||||
|
const { bw, bh } = sizeCanvas(c, width, height);
|
||||||
|
paintStrokeDoc(c.getContext("2d"), strokeDocOf(strokes), bw, bh);
|
||||||
|
}, [strokes, width, height]);
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={ref}
|
||||||
|
style={{ position: "absolute", left: 0, top: 0, width: "100%", height, pointerEvents: "none", zIndex }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import * as sync from "../../syncEngine";
|
||||||
|
|
||||||
|
/* ═══════ DRAWING MODEL ═══════
|
||||||
|
* A drawing is a list of vector strokes in one canonical page space (CANON_W ×
|
||||||
|
* CANON_H), never a bitmap. Every screen scales that same page to whatever room
|
||||||
|
* it has, so a note looks identical on a phone and on a desktop — and stays
|
||||||
|
* sharp at any pixel density, because we rasterize at render time.
|
||||||
|
*/
|
||||||
|
export const CANON_W = 1200,
|
||||||
|
CANON_H = 1600;
|
||||||
|
export const PREVIEW_DRAWING_W = 260; // history preview thumbnail
|
||||||
|
export const STROKES_VERSION = 1;
|
||||||
|
|
||||||
|
// Widths are canonical units, so a "Medium" pen covers the same fraction of the
|
||||||
|
// page everywhere. At the 760px desktop page width, M lands at roughly 3.5 CSS px.
|
||||||
|
export const PEN_SIZES = [
|
||||||
|
{ label: "XS", w: 2 },
|
||||||
|
{ label: "S", w: 3.5 },
|
||||||
|
{ label: "M", w: 5.5 },
|
||||||
|
{ label: "L", w: 9 },
|
||||||
|
{ label: "XL", w: 14 },
|
||||||
|
{ label: "2XL", w: 22 },
|
||||||
|
{ label: "3XL", w: 34 },
|
||||||
|
];
|
||||||
|
export const DEFAULT_PEN_SIZE = 2; // index into PEN_SIZES → "M"
|
||||||
|
export const HIGHLIGHTER_FACTOR = 5;
|
||||||
|
export const ERASER_FACTOR = 4;
|
||||||
|
export const HIGHLIGHTER_ALPHA = "55";
|
||||||
|
// Minimum canonical distance between recorded points — keeps payloads small
|
||||||
|
// without visibly changing the curve.
|
||||||
|
export const MIN_POINT_DIST = 2.5;
|
||||||
|
|
||||||
|
// `t` tags a save so the editor can recognise its own drawing coming back from
|
||||||
|
// the server and ignore reads that were already in flight when it saved.
|
||||||
|
let saveSeq = 0;
|
||||||
|
export const newSaveToken = () => `${sync.DEVICE_ID}:${Date.now()}:${++saveSeq}`;
|
||||||
|
export const strokeDocOf = (strokes, t) => ({
|
||||||
|
v: STROKES_VERSION,
|
||||||
|
w: CANON_W,
|
||||||
|
h: CANON_H,
|
||||||
|
strokes,
|
||||||
|
...(t ? { t } : {}),
|
||||||
|
});
|
||||||
|
export const docStrokes = (doc) => (Array.isArray(doc?.strokes) ? doc.strokes : []);
|
||||||
|
export const sameStrokeDoc = (a, b) =>
|
||||||
|
a === b ||
|
||||||
|
(docStrokes(a).length === docStrokes(b).length && docStrokes(a).every((s, i) => s === docStrokes(b)[i]));
|
||||||
|
|
||||||
|
export function paintStroke(ctx, s) {
|
||||||
|
const p = s?.p;
|
||||||
|
if (!p || p.length < 2) return;
|
||||||
|
const eraser = s.t === "eraser";
|
||||||
|
ctx.globalCompositeOperation = eraser ? "destination-out" : "source-over";
|
||||||
|
const color = s.t === "highlighter" ? s.c + HIGHLIGHTER_ALPHA : s.c;
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = s.s;
|
||||||
|
|
||||||
|
if (p.length === 2) {
|
||||||
|
// a tap, not a drag — draw the nib itself
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p[0], p[1], Math.max(s.s / 2, 0.4), 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One path per stroke: a translucent highlighter stays evenly translucent
|
||||||
|
// instead of darkening at every joint.
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p[0], p[1]);
|
||||||
|
for (let i = 2; i < p.length - 2; i += 2) {
|
||||||
|
ctx.quadraticCurveTo(p[i], p[i + 1], (p[i] + p[i + 2]) / 2, (p[i + 1] + p[i + 3]) / 2);
|
||||||
|
}
|
||||||
|
ctx.lineTo(p[p.length - 2], p[p.length - 1]);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replays a whole drawing into a context of targetW × targetH device pixels.
|
||||||
|
export function paintStrokeDoc(ctx, doc, targetW, targetH) {
|
||||||
|
const w = doc?.w || CANON_W,
|
||||||
|
h = doc?.h || CANON_H;
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, targetW, targetH);
|
||||||
|
ctx.scale(targetW / w, targetH / h);
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
for (const s of docStrokes(doc)) paintStroke(ctx, s);
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const backingScale = () => Math.min(window.devicePixelRatio || 1, 3);
|
||||||
|
export function sizeCanvas(c, cssW, cssH) {
|
||||||
|
const dpr = backingScale();
|
||||||
|
const bw = Math.max(1, Math.round(cssW * dpr)),
|
||||||
|
bh = Math.max(1, Math.round(cssH * dpr));
|
||||||
|
if (c.width !== bw || c.height !== bh) {
|
||||||
|
c.width = bw;
|
||||||
|
c.height = bh;
|
||||||
|
}
|
||||||
|
return { bw, bh };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Read-only drawing layer — used wherever a saved drawing is shown but not edited. */
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { COVERS } from "../../ui/constants.jsx";
|
||||||
|
import { I } from "../../ui/icons.jsx";
|
||||||
|
import { NotebookCover } from "../../ui/NotebookCover.jsx";
|
||||||
|
import { fmtDate } from "../../ui/format";
|
||||||
|
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
|
||||||
|
|
||||||
|
const RECENT_COUNT = 6;
|
||||||
|
|
||||||
|
/** Landing view: every notebook as a cover, plus the notes touched most recently. */
|
||||||
|
export function Dashboard({
|
||||||
|
T,
|
||||||
|
isMobile,
|
||||||
|
notebooks,
|
||||||
|
activeNotes,
|
||||||
|
activeFolder,
|
||||||
|
onToggleSidebar,
|
||||||
|
onOpenNotebook,
|
||||||
|
onOpenNote,
|
||||||
|
onNewNotebook,
|
||||||
|
onNotebookContextMenu,
|
||||||
|
dnd,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: T.dashBg,
|
||||||
|
fontFamily: FONT_UI,
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: isMobile ? "20px 20px 16px" : "28px 36px 20px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<button
|
||||||
|
onClick={onToggleSidebar}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: T.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.menu}
|
||||||
|
</button>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: isMobile ? 20 : 24,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: T.text,
|
||||||
|
fontFamily: FONT_SERIF,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
My Notebooks
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: isMobile ? "0 16px 24px" : "0 36px 32px",
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: isMobile
|
||||||
|
? "repeat(auto-fill,minmax(140px,1fr))"
|
||||||
|
: "repeat(auto-fill,minmax(180px,1fr))",
|
||||||
|
gap: isMobile ? "20px 16px" : "32px 28px",
|
||||||
|
justifyItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{notebooks.map((nb) => (
|
||||||
|
<NotebookCover
|
||||||
|
key={nb.id}
|
||||||
|
notebook={nb}
|
||||||
|
noteCount={activeNotes.filter((n) => n.folderId === nb.id).length}
|
||||||
|
onClick={() => onOpenNotebook(nb.id)}
|
||||||
|
onContextMenu={(e) => onNotebookContextMenu(e, nb)}
|
||||||
|
isActive={activeFolder === nb.id}
|
||||||
|
isDragOver={dnd.overId === nb.id}
|
||||||
|
draggable={!isMobile}
|
||||||
|
onDragStart={(e) => dnd.onDragStart(e, nb.id)}
|
||||||
|
onDragEnd={dnd.onDragEnd}
|
||||||
|
onDragOver={(e) => dnd.onDragOver(e, nb.id)}
|
||||||
|
onDrop={(e) => dnd.onDrop(e, nb.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={onNewNotebook}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: isMobile ? 130 : 156,
|
||||||
|
height: isMobile ? 175 : 210,
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `2px dashed ${T.border}`,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 8,
|
||||||
|
color: T.faint,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: T.accentSoft,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: T.accent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.plus}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 500 }}>Add Notebook</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: isMobile ? "0 16px 24px" : "0 36px 36px" }}>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: T.text,
|
||||||
|
marginBottom: 14,
|
||||||
|
fontFamily: FONT_SERIF,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Recent Notes
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill,minmax(260px,1fr))",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeNotes.slice(0, RECENT_COUNT).map((note) => {
|
||||||
|
const nb = notebooks.find((b) => b.id === note.folderId);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={note.id}
|
||||||
|
onClick={() => onOpenNote(note)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
padding: "14px 16px",
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
background: T.card,
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6, width: "100%" }}
|
||||||
|
>
|
||||||
|
{nb && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 14,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: COVERS[nb.cover % COVERS.length].bg,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: T.text,
|
||||||
|
flex: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{note.title}
|
||||||
|
</span>
|
||||||
|
{note.starred && <span style={{ color: "#f0b429", fontSize: 11 }}>⭐</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 6 }}>
|
||||||
|
<span style={{ fontSize: 11, color: T.faint }}>{fmtDate(note.updatedAt)}</span>
|
||||||
|
{nb && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: T.faint,
|
||||||
|
background: T.accentSoft,
|
||||||
|
padding: "1px 6px",
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nb.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { I } from "../../ui/icons.jsx";
|
||||||
|
import { BackButton } from "../../ui/BackButton.jsx";
|
||||||
|
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
|
||||||
|
import { NoteEditor } from "./NoteEditor.jsx";
|
||||||
|
|
||||||
|
const toolbarButton = (T, color) => ({
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
color,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Shown on desktop when nothing is selected; on a phone the list fills the screen instead. */
|
||||||
|
function EmptyState({ T, isTrashView, onCreateNote }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: T.bg,
|
||||||
|
color: T.faint,
|
||||||
|
fontFamily: FONT_UI,
|
||||||
|
gap: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
borderRadius: 20,
|
||||||
|
background: T.accentSoft,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 500, color: T.muted }}>Select a note to edit</div>
|
||||||
|
{!isTrashView && (
|
||||||
|
<button
|
||||||
|
onClick={onCreateNote}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "10px 22px",
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "none",
|
||||||
|
background: T.accent,
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginTop: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.plus} New Note
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A trashed note can be read and restored, but not edited. */
|
||||||
|
function TrashedNote({ T, isMobile, note, onBack, onRestore, onPermanentDelete }) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", background: T.bg, fontFamily: FONT_UI }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderBottom: `1px solid ${T.borderLight}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMobile && <BackButton T={T} onClick={onBack} />}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: T.text,
|
||||||
|
fontFamily: FONT_SERIF,
|
||||||
|
flex: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{note.title}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onRestore}
|
||||||
|
style={{
|
||||||
|
padding: "6px 14px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "none",
|
||||||
|
background: T.accent,
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
<button onClick={onPermanentDelete} style={toolbarButton(T, T.danger)}>
|
||||||
|
{I.trash}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, padding: "20px 24px", color: T.muted, fontStyle: "italic", fontSize: 14 }}>
|
||||||
|
Trashed notes are read-only. Restore to edit.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The right-hand pane: title bar plus the rich-text editor.
|
||||||
|
*
|
||||||
|
* `restoreEpoch` is part of the editor's key so that restoring an old version
|
||||||
|
* remounts it on the restored body instead of trying to merge into the live
|
||||||
|
* document.
|
||||||
|
*/
|
||||||
|
export function EditorPane({
|
||||||
|
T,
|
||||||
|
dark,
|
||||||
|
isMobile,
|
||||||
|
note,
|
||||||
|
isTrashView,
|
||||||
|
restoreEpoch,
|
||||||
|
backlinks,
|
||||||
|
linkProps,
|
||||||
|
onBack,
|
||||||
|
onUpdate,
|
||||||
|
onToggleStar,
|
||||||
|
onTrash,
|
||||||
|
onRestore,
|
||||||
|
onPermanentDelete,
|
||||||
|
onOpenHistory,
|
||||||
|
onCreateNote,
|
||||||
|
}) {
|
||||||
|
if (!note)
|
||||||
|
return isMobile ? null : <EmptyState T={T} isTrashView={isTrashView} onCreateNote={onCreateNote} />;
|
||||||
|
|
||||||
|
if (isTrashView)
|
||||||
|
return (
|
||||||
|
<TrashedNote
|
||||||
|
T={T}
|
||||||
|
isMobile={isMobile}
|
||||||
|
note={note}
|
||||||
|
onBack={onBack}
|
||||||
|
onRestore={onRestore}
|
||||||
|
onPermanentDelete={onPermanentDelete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", background: T.bg, fontFamily: FONT_UI }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "8px 16px",
|
||||||
|
borderBottom: `1px solid ${T.borderLight}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMobile && <BackButton T={T} onClick={onBack} />}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={note.title}
|
||||||
|
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
outline: "none",
|
||||||
|
fontSize: isMobile ? 15 : 17,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: T.text,
|
||||||
|
fontFamily: FONT_SERIF,
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button onClick={onOpenHistory} title="Version history" style={toolbarButton(T, T.faint)}>
|
||||||
|
{I.history}
|
||||||
|
</button>
|
||||||
|
<button onClick={onToggleStar} style={toolbarButton(T, note.starred ? "#f0b429" : T.faint)}>
|
||||||
|
{note.starred ? I.starFill : I.star}
|
||||||
|
</button>
|
||||||
|
<button onClick={onTrash} style={toolbarButton(T, T.danger)}>
|
||||||
|
{I.trash}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||||
|
<NoteEditor
|
||||||
|
key={`${note.id}:${restoreEpoch}`}
|
||||||
|
note={note}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
dark={dark}
|
||||||
|
onOpenNote={linkProps.openLinkTarget}
|
||||||
|
searchLinkTargets={linkProps.searchLinkTargets}
|
||||||
|
resolveLinkLabel={linkProps.resolveLinkLabel}
|
||||||
|
backlinks={backlinks}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import * as sync from "../../syncEngine";
|
||||||
|
import { VersionPreview } from "../../editor/VersionPreview.jsx";
|
||||||
|
import { I } from "../../ui/icons.jsx";
|
||||||
|
|
||||||
|
/* ═══════ VERSION HISTORY ═══════ */
|
||||||
|
/* Past states of a note, newest first. The server keeps a snapshot of what a
|
||||||
|
* note looked like before each editing session, so an overwrite that is only
|
||||||
|
* noticed later can still be undone. */
|
||||||
|
export function HistoryModal({ noteId, noteTitle, dark, onClose, onRestored, renderDrawing }) {
|
||||||
|
const [versions, setVersions] = useState(null);
|
||||||
|
const [selectedId, setSelectedId] = useState(null);
|
||||||
|
const [detail, setDetail] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [restoring, setRestoring] = useState(false);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
|
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",
|
||||||
|
surface: dark ? "#0d1117" : "#f6f8fa",
|
||||||
|
}),
|
||||||
|
[dark]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
sync
|
||||||
|
.getNoteVersions(noteId)
|
||||||
|
.then((v) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setVersions(v);
|
||||||
|
if (v.length) setSelectedId(v[0].id);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setVersions([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [noteId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setErr("");
|
||||||
|
sync
|
||||||
|
.getNoteVersion(noteId, selectedId)
|
||||||
|
.then((d) => {
|
||||||
|
if (!cancelled) setDetail(d);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancelled) setErr(e.message || "Could not load this version");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [noteId, selectedId]);
|
||||||
|
|
||||||
|
const restore = async () => {
|
||||||
|
if (!selectedId) return;
|
||||||
|
setRestoring(true);
|
||||||
|
setErr("");
|
||||||
|
try {
|
||||||
|
const r = await sync.restoreNoteVersion(noteId, selectedId);
|
||||||
|
onRestored?.(r.content);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || "Restore failed");
|
||||||
|
}
|
||||||
|
setRestoring(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const when = (t) => {
|
||||||
|
const d = new Date(String(t).replace(" ", "T") + "Z");
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const REASONS = {
|
||||||
|
"large-deletion": "large deletion",
|
||||||
|
"before-restore": "before restore",
|
||||||
|
"drawing-cleared": "drawing cleared",
|
||||||
|
drawing: "drawing",
|
||||||
|
edit: "edit",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.55)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 2000,
|
||||||
|
backdropFilter: "blur(6px)",
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: T.bg,
|
||||||
|
borderRadius: 20,
|
||||||
|
width: 940,
|
||||||
|
maxWidth: "94vw",
|
||||||
|
height: 620,
|
||||||
|
maxHeight: "88vh",
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
boxShadow: "0 24px 48px rgba(0,0,0,0.3)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "16px 20px",
|
||||||
|
borderBottom: `1px solid ${T.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: T.text,
|
||||||
|
fontFamily: "'Newsreader',Georgia,serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Version history
|
||||||
|
</h3>
|
||||||
|
<div style={{ fontSize: 12, color: T.muted, marginTop: 2 }}>{noteTitle || "Untitled"}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: T.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.x}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
|
||||||
|
{/* Version list */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 260,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRight: `1px solid ${T.border}`,
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: 8,
|
||||||
|
background: T.surface,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{versions === null && <div style={{ padding: 14, fontSize: 12, color: T.muted }}>Loading…</div>}
|
||||||
|
{versions?.length === 0 && (
|
||||||
|
<div style={{ padding: 14, fontSize: 12, color: T.muted, lineHeight: 1.6 }}>
|
||||||
|
No earlier versions yet. One is kept whenever you come back to a note after a break, and
|
||||||
|
always before a large deletion.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{versions?.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v.id}
|
||||||
|
onClick={() => setSelectedId(v.id)}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "9px 11px",
|
||||||
|
marginBottom: 3,
|
||||||
|
borderRadius: 9,
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: selectedId === v.id ? T.accent + "22" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ fontSize: 12.5, fontWeight: 600, color: selectedId === v.id ? T.accent : T.text }}
|
||||||
|
>
|
||||||
|
{when(v.created_at)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: T.muted, marginTop: 2 }}>
|
||||||
|
{REASONS[v.reason] || v.reason}
|
||||||
|
{v.has_drawing && " · drawing"}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", minWidth: 0, position: "relative" }}>
|
||||||
|
{loading && <div style={{ padding: 20, fontSize: 12, color: T.muted }}>Loading version…</div>}
|
||||||
|
{!loading && detail && (
|
||||||
|
<>
|
||||||
|
{detail.drawingStrokes && renderDrawing && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: "14px 18px 0",
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
background: dark ? "#0d1117" : "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderDrawing(detail.drawingStrokes)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<VersionPreview content={detail.content} dark={dark} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!loading && !detail && !err && versions?.length > 0 && (
|
||||||
|
<div style={{ padding: 20, fontSize: 12, color: T.muted }}>Select a version.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
padding: "12px 20px",
|
||||||
|
borderTop: `1px solid ${T.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11.5, color: err ? T.danger : T.muted, flex: 1, minWidth: 0 }}>
|
||||||
|
{err || "Restoring keeps a snapshot of the current state first, so this is reversible."}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
padding: "8px 18px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
background: "transparent",
|
||||||
|
color: T.text,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={restore}
|
||||||
|
disabled={!selectedId || restoring || loading}
|
||||||
|
style={{
|
||||||
|
padding: "8px 18px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "none",
|
||||||
|
background: T.accent,
|
||||||
|
color: "#fff",
|
||||||
|
cursor: selectedId ? "pointer" : "default",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: !selectedId || restoring || loading ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{restoring ? "Restoring…" : "Restore this version"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useNoteEditor, EditorSurface } from "../../editor/useNoteEditor.jsx";
|
||||||
|
import { EditorToolbar } from "../../editor/EditorToolbar.jsx";
|
||||||
|
import { DrawingLayer } from "../drawing/DrawingLayer.jsx";
|
||||||
|
import {
|
||||||
|
CANON_W,
|
||||||
|
CANON_H,
|
||||||
|
PEN_SIZES,
|
||||||
|
DEFAULT_PEN_SIZE,
|
||||||
|
HIGHLIGHTER_FACTOR,
|
||||||
|
ERASER_FACTOR,
|
||||||
|
MIN_POINT_DIST,
|
||||||
|
newSaveToken,
|
||||||
|
strokeDocOf,
|
||||||
|
docStrokes,
|
||||||
|
paintStroke,
|
||||||
|
paintStrokeDoc,
|
||||||
|
sizeCanvas,
|
||||||
|
} from "../drawing/strokes.js";
|
||||||
|
import { COLORS } from "../../ui/constants.jsx";
|
||||||
|
import { I } from "../../ui/icons.jsx";
|
||||||
|
import { BacklinksPanel } from "../../ui/BacklinksPanel.jsx";
|
||||||
|
|
||||||
|
/* ═══════ NOTE EDITOR ═══════ */
|
||||||
|
export function NoteEditor({
|
||||||
|
note,
|
||||||
|
onUpdate,
|
||||||
|
dark,
|
||||||
|
onOpenNote,
|
||||||
|
searchLinkTargets,
|
||||||
|
resolveLinkLabel,
|
||||||
|
backlinks,
|
||||||
|
}) {
|
||||||
|
const [mode, setMode] = useState("text");
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const [tool, setTool] = useState("pen");
|
||||||
|
const [penColor, setPenColor] = useState(dark ? "#e6edf3" : "#1a1a2e");
|
||||||
|
const [penSizeIdx, setPenSizeIdx] = useState(DEFAULT_PEN_SIZE);
|
||||||
|
const [undoStack, setUndoStack] = useState([]);
|
||||||
|
const [redoStack, setRedoStack] = useState([]);
|
||||||
|
const [showColors, setShowColors] = useState(false);
|
||||||
|
const [showSizes, setShowSizes] = useState(false);
|
||||||
|
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 SAVE_DEBOUNCE_MS = 600;
|
||||||
|
const [stageSize, setStageSize] = useState({ w: CANON_W, h: CANON_H });
|
||||||
|
|
||||||
|
/* ── DRAWING STATE ──
|
||||||
|
* strokesRef is what the pointer handlers read and write synchronously;
|
||||||
|
* `strokes` mirrors it so React can repaint the read-only layer. */
|
||||||
|
const strokesRef = useRef([]);
|
||||||
|
const [strokes, setStrokes] = useState([]);
|
||||||
|
const liveRef = useRef(null); // stroke currently under the pointer
|
||||||
|
const drawingRef = useRef(false);
|
||||||
|
const committedRef = useRef(null); // offscreen canvas holding all finished strokes
|
||||||
|
const appliedDocRef = useRef(undefined);
|
||||||
|
const pendingTokenRef = useRef(null);
|
||||||
|
const hasStrokes = strokes.length > 0;
|
||||||
|
// Old notes may still carry a raster drawing. It stays visible underneath any
|
||||||
|
// new vector strokes so migrating a note never loses what was already drawn.
|
||||||
|
const legacyRaster = note.drawingData && !hideDrawing;
|
||||||
|
|
||||||
|
const commitStrokes = (next) => {
|
||||||
|
strokesRef.current = next;
|
||||||
|
setStrokes(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
|
/* The note body. Structured TipTap document — tables, task lists and note
|
||||||
|
* links are real nodes, not HTML that happened to survive execCommand. */
|
||||||
|
const [editorNotice, setEditorNotice] = useState("");
|
||||||
|
const textEditor = useNoteEditor({
|
||||||
|
noteId: note.id,
|
||||||
|
content: note.content,
|
||||||
|
editable: mode === "text",
|
||||||
|
searchLinkTargets,
|
||||||
|
onChange: ({ content, links }) => onUpdate({ content, links }),
|
||||||
|
onError: (msg) => {
|
||||||
|
setEditorNotice(msg);
|
||||||
|
setTimeout(() => setEditorNotice(""), 5000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHideDrawing(false);
|
||||||
|
const isLegacyRaster =
|
||||||
|
typeof note.drawingData === "string" && /^data:image\/(jpeg|jpg|webp)/i.test(note.drawingData);
|
||||||
|
if (!note.drawingData) {
|
||||||
|
setDrawingBlend({ mode: "normal", opacity: 1 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
try {
|
||||||
|
const c = document.createElement("canvas");
|
||||||
|
c.width = 32;
|
||||||
|
c.height = 32;
|
||||||
|
const cx = c.getContext("2d", { willReadFrequently: true });
|
||||||
|
if (!cx) throw new Error("ctx");
|
||||||
|
cx.drawImage(img, 0, 0, 32, 32);
|
||||||
|
const data = cx.getImageData(0, 0, 32, 32).data;
|
||||||
|
let solid = 0,
|
||||||
|
black = 0;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const a = data[i + 3];
|
||||||
|
if (a < 245) continue;
|
||||||
|
solid++;
|
||||||
|
if (data[i] < 16 && data[i + 1] < 16 && data[i + 2] < 16) black++;
|
||||||
|
}
|
||||||
|
const blackRatio = solid ? black / solid : 0;
|
||||||
|
if (blackRatio > 0.9) setDrawingBlend({ mode: "screen", opacity: 0.62 });
|
||||||
|
else if (isLegacyRaster) setDrawingBlend({ mode: "screen", opacity: 0.42 });
|
||||||
|
else setDrawingBlend({ mode: "normal", opacity: 1 });
|
||||||
|
} catch {
|
||||||
|
setDrawingBlend(isLegacyRaster ? { mode: "screen", opacity: 0.42 } : { mode: "normal", opacity: 1 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
if (!cancelled) setHideDrawing(true);
|
||||||
|
};
|
||||||
|
img.src = note.drawingData;
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [note.id, note.drawingData]);
|
||||||
|
|
||||||
|
// Keep one fixed-aspect drawing stage regardless of device dimensions.
|
||||||
|
useEffect(() => {
|
||||||
|
const fitStage = () => {
|
||||||
|
const host = stageRef.current;
|
||||||
|
if (!host) return;
|
||||||
|
const W = Math.max(1, host.clientWidth);
|
||||||
|
const mobile = window.innerWidth < 768;
|
||||||
|
setIsMobileViewport(mobile);
|
||||||
|
const sidePad = mobile ? 10 : 28;
|
||||||
|
const desktopMax = 760;
|
||||||
|
const minW = mobile ? 220 : 320;
|
||||||
|
const maxAllowed = Math.max(220, W - sidePad * 2);
|
||||||
|
const targetW = mobile ? maxAllowed : Math.min(desktopMax, maxAllowed);
|
||||||
|
const w = Math.floor(Math.max(minW, targetW));
|
||||||
|
const h = Math.floor((w * CANON_H) / CANON_W);
|
||||||
|
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 (!dirtyRef.current) return;
|
||||||
|
const token = newSaveToken();
|
||||||
|
const doc = strokeDocOf(strokesRef.current, token);
|
||||||
|
appliedDocRef.current = doc; // ignore the echo coming straight back through props
|
||||||
|
pendingTokenRef.current = token; // ...and any server read still in flight
|
||||||
|
dirtyRef.current = false;
|
||||||
|
onUpdate({ drawingStrokes: doc });
|
||||||
|
};
|
||||||
|
const scheduleDrawingSave = () => {
|
||||||
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = setTimeout(() => {
|
||||||
|
saveTimerRef.current = null;
|
||||||
|
saveDrawingNow();
|
||||||
|
}, SAVE_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── DRAWING: painting ── */
|
||||||
|
const renderFrame = () => {
|
||||||
|
const c = canvasRef.current;
|
||||||
|
if (!c) return;
|
||||||
|
const ctx = c.getContext("2d");
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, c.width, c.height);
|
||||||
|
if (committedRef.current) ctx.drawImage(committedRef.current, 0, 0);
|
||||||
|
const live = liveRef.current;
|
||||||
|
if (live) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.scale(c.width / CANON_W, c.height / CANON_H);
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
paintStroke(ctx, live);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Repaint the offscreen canvas from scratch, then show it. Cheap enough at
|
||||||
|
// stroke boundaries, and it keeps eraser strokes ordered correctly.
|
||||||
|
const repaintAll = () => {
|
||||||
|
const c = canvasRef.current;
|
||||||
|
if (!c) return;
|
||||||
|
const { bw, bh } = sizeCanvas(c, stageSize.w, stageSize.h);
|
||||||
|
let off = committedRef.current;
|
||||||
|
if (!off) {
|
||||||
|
off = document.createElement("canvas");
|
||||||
|
committedRef.current = off;
|
||||||
|
}
|
||||||
|
if (off.width !== bw || off.height !== bh) {
|
||||||
|
off.width = bw;
|
||||||
|
off.height = bh;
|
||||||
|
}
|
||||||
|
paintStrokeDoc(off.getContext("2d"), strokeDocOf(strokesRef.current), bw, bh);
|
||||||
|
renderFrame();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Adopt strokes arriving from props (note switch, or a sync push from another
|
||||||
|
* device) without throwing away strokes we have not saved yet. */
|
||||||
|
useEffect(() => {
|
||||||
|
const doc = note.drawingStrokes || null;
|
||||||
|
if (appliedDocRef.current === doc) return;
|
||||||
|
appliedDocRef.current = doc;
|
||||||
|
if (dirtyRef.current) return; // strokes in hand that are not saved yet
|
||||||
|
if (pendingTokenRef.current) {
|
||||||
|
// A save is on its way to the server. Until it comes back, any doc we get
|
||||||
|
// is a read that started before it — adopting it would drop fresh strokes.
|
||||||
|
if (doc?.t !== pendingTokenRef.current) return;
|
||||||
|
pendingTokenRef.current = null; // our own save echoed back; in sync again
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commitStrokes(docStrokes(doc));
|
||||||
|
setUndoStack([]);
|
||||||
|
setRedoStack([]);
|
||||||
|
repaintAll();
|
||||||
|
}, [note.id, note.drawingStrokes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "draw") repaintAll();
|
||||||
|
}, [mode, stageSize.w, stageSize.h]);
|
||||||
|
|
||||||
|
/* ── DRAWING: input ── */
|
||||||
|
const canonPos = (e) => {
|
||||||
|
const r = canvasRef.current.getBoundingClientRect();
|
||||||
|
const t = e.touches ? e.touches[0] : e;
|
||||||
|
return { x: (t.clientX - r.left) * (CANON_W / r.width), y: (t.clientY - r.top) * (CANON_H / r.height) };
|
||||||
|
};
|
||||||
|
const currentStrokeWidth = () => {
|
||||||
|
const base = PEN_SIZES[penSizeIdx].w;
|
||||||
|
if (tool === "highlighter") return base * HIGHLIGHTER_FACTOR;
|
||||||
|
if (tool === "eraser") return base * ERASER_FACTOR;
|
||||||
|
return base;
|
||||||
|
};
|
||||||
|
const r1 = (n) => Math.round(n * 10) / 10;
|
||||||
|
|
||||||
|
const startDraw = (e) => {
|
||||||
|
if (mode !== "draw") return;
|
||||||
|
e.preventDefault();
|
||||||
|
const p = canonPos(e);
|
||||||
|
liveRef.current = { t: tool, c: penColor, s: r1(currentStrokeWidth()), p: [r1(p.x), r1(p.y)] };
|
||||||
|
drawingRef.current = true;
|
||||||
|
renderFrame();
|
||||||
|
};
|
||||||
|
const doDraw = (e) => {
|
||||||
|
if (!drawingRef.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const p = canonPos(e),
|
||||||
|
live = liveRef.current,
|
||||||
|
n = live.p.length;
|
||||||
|
const dx = p.x - live.p[n - 2],
|
||||||
|
dy = p.y - live.p[n - 1];
|
||||||
|
if (dx * dx + dy * dy < MIN_POINT_DIST * MIN_POINT_DIST) return;
|
||||||
|
live.p.push(r1(p.x), r1(p.y));
|
||||||
|
renderFrame();
|
||||||
|
};
|
||||||
|
const stopDraw = (e) => {
|
||||||
|
if (!drawingRef.current) return;
|
||||||
|
e?.preventDefault();
|
||||||
|
drawingRef.current = false;
|
||||||
|
const live = liveRef.current;
|
||||||
|
liveRef.current = null;
|
||||||
|
if (!live) {
|
||||||
|
renderFrame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushUndo();
|
||||||
|
commitStrokes([...strokesRef.current, live]);
|
||||||
|
dirtyRef.current = true;
|
||||||
|
repaintAll();
|
||||||
|
scheduleDrawingSave();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── DRAWING: history ── */
|
||||||
|
const pushUndo = () => {
|
||||||
|
setUndoStack((p) => [...p.slice(-30), strokesRef.current]);
|
||||||
|
setRedoStack([]);
|
||||||
|
};
|
||||||
|
const applyHistory = (next) => {
|
||||||
|
commitStrokes(next);
|
||||||
|
dirtyRef.current = true;
|
||||||
|
repaintAll();
|
||||||
|
scheduleDrawingSave();
|
||||||
|
};
|
||||||
|
const undo = () => {
|
||||||
|
if (!undoStack.length) return;
|
||||||
|
const prev = undoStack[undoStack.length - 1];
|
||||||
|
setUndoStack((p) => p.slice(0, -1));
|
||||||
|
setRedoStack((p) => [...p, strokesRef.current]);
|
||||||
|
applyHistory(prev);
|
||||||
|
};
|
||||||
|
const redo = () => {
|
||||||
|
if (!redoStack.length) return;
|
||||||
|
const next = redoStack[redoStack.length - 1];
|
||||||
|
setRedoStack((p) => p.slice(0, -1));
|
||||||
|
setUndoStack((p) => [...p, strokesRef.current]);
|
||||||
|
applyHistory(next);
|
||||||
|
};
|
||||||
|
const clearCanvas = () => {
|
||||||
|
pushUndo();
|
||||||
|
applyHistory([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Save drawing on mode switch and debounced stroke-end updates. */
|
||||||
|
const switchMode = (m) => {
|
||||||
|
if (mode === "draw" && m !== "draw" && dirtyRef.current) {
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
saveDrawingNow();
|
||||||
|
}
|
||||||
|
setMode(m);
|
||||||
|
setShowColors(false);
|
||||||
|
setShowSizes(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
|
saveDrawingNow();
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ── TEXT ──
|
||||||
|
* Image paste, drag-to-resize and the execCommand toolbar used to live
|
||||||
|
* here. TipTap owns the document now, so formatting goes through editor
|
||||||
|
* commands rather than a deprecated browser API. */
|
||||||
|
|
||||||
|
/* ── RENDER ── */
|
||||||
|
const tb = (a) => ({
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: a ? T.active + "22" : "transparent",
|
||||||
|
color: a ? T.active : T.text,
|
||||||
|
transition: "all .12s",
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
const mb = (m, ic, lb) => (
|
||||||
|
<button
|
||||||
|
onClick={() => switchMode(m)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
border: `1.5px solid ${mode === m ? T.active : T.border}`,
|
||||||
|
background: mode === m ? T.active + "15" : "transparent",
|
||||||
|
color: mode === m ? T.active : T.muted,
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ic}
|
||||||
|
<span>{lb}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: "100%",
|
||||||
|
background: isMobileViewport
|
||||||
|
? T.bg
|
||||||
|
: dark
|
||||||
|
? "linear-gradient(180deg,#0f141b 0%,#0d1117 60%)"
|
||||||
|
: "linear-gradient(180deg,#f8fafd 0%,#eef3f8 70%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Main toolbar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "7px 14px",
|
||||||
|
borderBottom: `1px solid ${T.border}`,
|
||||||
|
background: T.toolbar,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
minHeight: 46,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mb("text", I.text, "Write")}
|
||||||
|
{mb("draw", I.draw, "Draw")}
|
||||||
|
<div style={{ width: 1, height: 22, background: T.border, margin: "0 2px", flexShrink: 0 }} />
|
||||||
|
{mode === "text" ? (
|
||||||
|
<EditorToolbar
|
||||||
|
editor={textEditor}
|
||||||
|
T={{ ...T, danger: "#e5534b" }}
|
||||||
|
icons={I}
|
||||||
|
compact={isMobileViewport}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button style={tb(tool === "pen")} onClick={() => setTool("pen")}>
|
||||||
|
{I.pen}
|
||||||
|
</button>
|
||||||
|
<button style={tb(tool === "highlighter")} onClick={() => setTool("highlighter")}>
|
||||||
|
{I.highlight}
|
||||||
|
</button>
|
||||||
|
<button style={tb(tool === "eraser")} onClick={() => setTool("eraser")}>
|
||||||
|
{I.eraser}
|
||||||
|
</button>
|
||||||
|
<div style={{ width: 1, height: 22, background: T.border, margin: "0 2px" }} />
|
||||||
|
<button style={tb()} onClick={undo}>
|
||||||
|
{I.undo}
|
||||||
|
</button>
|
||||||
|
<button style={tb()} onClick={redo}>
|
||||||
|
{I.redo}
|
||||||
|
</button>
|
||||||
|
<div style={{ width: 1, height: 22, background: T.border, margin: "0 2px" }} />
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<button
|
||||||
|
style={tb(showColors)}
|
||||||
|
onClick={() => {
|
||||||
|
setShowColors(!showColors);
|
||||||
|
setShowSizes(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: penColor,
|
||||||
|
border: `2px solid ${T.border}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{showColors && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 36,
|
||||||
|
left: 0,
|
||||||
|
background: T.toolbar,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 8,
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(5,1fr)",
|
||||||
|
gap: 4,
|
||||||
|
zIndex: 100,
|
||||||
|
boxShadow: "0 8px 20px rgba(0,0,0,.2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => {
|
||||||
|
setPenColor(c);
|
||||||
|
setShowColors(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: c,
|
||||||
|
border: penColor === c ? `3px solid ${T.active}` : `1px solid ${T.border}`,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<button
|
||||||
|
style={tb(showSizes)}
|
||||||
|
onClick={() => {
|
||||||
|
setShowSizes(!showSizes);
|
||||||
|
setShowColors(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: Math.min(4 + penSizeIdx * 2, 16),
|
||||||
|
height: Math.min(4 + penSizeIdx * 2, 16),
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: T.text,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{showSizes && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 36,
|
||||||
|
left: 0,
|
||||||
|
background: T.toolbar,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 8,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 4,
|
||||||
|
zIndex: 100,
|
||||||
|
boxShadow: "0 8px 20px rgba(0,0,0,.2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{PEN_SIZES.map((s, i) => (
|
||||||
|
<button
|
||||||
|
key={s.label}
|
||||||
|
onClick={() => {
|
||||||
|
setPenSizeIdx(i);
|
||||||
|
setShowSizes(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "3px 8px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: penSizeIdx === i ? T.active + "22" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: Math.min(4 + i * 3, 22),
|
||||||
|
height: Math.min(4 + i * 3, 22),
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: T.text,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 11, color: T.muted }}>{s.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<button style={{ ...tb(), color: "#e5534b" }} onClick={clearCanvas}>
|
||||||
|
{I.trash}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editorNotice && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "7px 14px",
|
||||||
|
background: "#e5534b18",
|
||||||
|
color: "#e5534b",
|
||||||
|
fontSize: 12,
|
||||||
|
borderBottom: `1px solid ${T.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editorNotice}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div ref={stageRef} style={{ flex: 1, position: "relative", overflow: "hidden" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
overflowY: "auto",
|
||||||
|
WebkitOverflowScrolling: "touch",
|
||||||
|
zIndex: 1,
|
||||||
|
padding: isMobileViewport ? "10px 0 18px" : "18px 0 28px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: stageSize.w,
|
||||||
|
minHeight: stageSize.h,
|
||||||
|
margin: isMobileViewport ? "0 auto" : "0 0 0 28px",
|
||||||
|
background: T.bg,
|
||||||
|
border: isMobileViewport ? "none" : `1px solid ${T.border}`,
|
||||||
|
borderRadius: isMobileViewport ? 0 : 14,
|
||||||
|
boxShadow: isMobileViewport ? "none" : "0 16px 36px rgba(0,0,0,0.14)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: stageSize.h,
|
||||||
|
width: "100%",
|
||||||
|
pointerEvents: mode === "text" ? "auto" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditorSurface
|
||||||
|
editor={textEditor}
|
||||||
|
dark={dark}
|
||||||
|
minHeight={stageSize.h}
|
||||||
|
onOpenNote={onOpenNote}
|
||||||
|
resolveLinkLabel={resolveLinkLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Legacy raster drawing from before vector strokes — kept underneath so nothing is lost */}
|
||||||
|
{legacyRaster && (
|
||||||
|
<img
|
||||||
|
src={note.drawingData}
|
||||||
|
alt=""
|
||||||
|
onError={() => setHideDrawing(true)}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: stageSize.h,
|
||||||
|
pointerEvents: "none",
|
||||||
|
objectFit: "fill",
|
||||||
|
zIndex: 2,
|
||||||
|
opacity: drawingBlend.opacity,
|
||||||
|
mixBlendMode: drawingBlend.mode,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Saved drawing — re-rendered from vectors, so identical on every screen */}
|
||||||
|
{hasStrokes && mode !== "draw" && (
|
||||||
|
<DrawingLayer strokes={strokes} width={stageSize.w} height={stageSize.h} zIndex={2} />
|
||||||
|
)}
|
||||||
|
{mode === "draw" && (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
onMouseDown={startDraw}
|
||||||
|
onMouseMove={doDraw}
|
||||||
|
onMouseUp={stopDraw}
|
||||||
|
onMouseLeave={stopDraw}
|
||||||
|
onTouchStart={startDraw}
|
||||||
|
onTouchMove={doDraw}
|
||||||
|
onTouchEnd={stopDraw}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: stageSize.h,
|
||||||
|
touchAction: "none",
|
||||||
|
cursor: "crosshair",
|
||||||
|
background: "transparent",
|
||||||
|
zIndex: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{mode === "text" && <BacklinksPanel backlinks={backlinks} onOpenNote={onOpenNote} dark={dark} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { I } from "../../ui/icons.jsx";
|
||||||
|
import { fmtDate } from "../../ui/format";
|
||||||
|
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
|
||||||
|
|
||||||
|
/** The middle column: every note in the selected folder, newest or best match first. */
|
||||||
|
export function NoteList({
|
||||||
|
T,
|
||||||
|
isMobile,
|
||||||
|
title,
|
||||||
|
notes,
|
||||||
|
activeNote,
|
||||||
|
isTrashView,
|
||||||
|
trashedCount,
|
||||||
|
snippetFor,
|
||||||
|
onToggleSidebar,
|
||||||
|
onSelectNote,
|
||||||
|
onNoteContextMenu,
|
||||||
|
onCreateNote,
|
||||||
|
onEmptyTrash,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: isMobile ? "100%" : 300,
|
||||||
|
minWidth: isMobile ? 0 : 300,
|
||||||
|
flex: isMobile ? 1 : undefined,
|
||||||
|
borderRight: isMobile ? "none" : `1px solid ${T.border}`,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: T.bg,
|
||||||
|
fontFamily: FONT_UI,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "14px 16px 10px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderBottom: `1px solid ${T.borderLight}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={onToggleSidebar}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: T.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.menu}
|
||||||
|
</button>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 600, color: T.text, fontFamily: FONT_SERIF }}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 4 }}>
|
||||||
|
{!isTrashView && (
|
||||||
|
<button
|
||||||
|
onClick={onCreateNote}
|
||||||
|
title="New Note"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "none",
|
||||||
|
background: T.accent,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#fff",
|
||||||
|
boxShadow: `0 2px 8px ${T.accent}44`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.plus}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isTrashView && trashedCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={onEmptyTrash}
|
||||||
|
title="Empty Trash"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "none",
|
||||||
|
background: T.danger,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Empty
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}>
|
||||||
|
{notes.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "100%",
|
||||||
|
color: T.faint,
|
||||||
|
gap: 10,
|
||||||
|
padding: 30,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 36 }}>{isTrashView ? "🗑️" : "📝"}</div>
|
||||||
|
<div style={{ fontSize: 13, textAlign: "center" }}>
|
||||||
|
{isTrashView ? "Trash is empty" : "No notes yet"}
|
||||||
|
</div>
|
||||||
|
{!isTrashView && (
|
||||||
|
<button
|
||||||
|
onClick={onCreateNote}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "10px 20px",
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "none",
|
||||||
|
background: T.accent,
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginTop: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.plus} New Note
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
notes.map((note) => {
|
||||||
|
const snippet = snippetFor(note.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={note.id}
|
||||||
|
onClick={() => onSelectNote(note.id)}
|
||||||
|
onContextMenu={(e) => onNoteContextMenu(e, note)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
width: "100%",
|
||||||
|
padding: "12px 14px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
|
||||||
|
background: activeNote === note.id ? T.accentSoft : "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
marginBottom: 2,
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 3, width: "100%" }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 13 }}>{note.trashed ? "🗑️" : "📝"}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: T.text,
|
||||||
|
flex: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{note.title}
|
||||||
|
</span>
|
||||||
|
{note.starred && !note.trashed && (
|
||||||
|
<span style={{ color: "#f0b429", fontSize: 11 }}>⭐</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{snippet && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: T.textSec,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
marginTop: 1,
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{snippet}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 10, color: T.faint, marginTop: 3 }}>{fmtDate(note.updatedAt)}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import type { MobileView } from "./useResponsive";
|
||||||
|
|
||||||
|
export interface NavState {
|
||||||
|
folder: string;
|
||||||
|
note: string | null;
|
||||||
|
view: MobileView;
|
||||||
|
}
|
||||||
|
|
||||||
|
const same = (a: NavState, b: NavState) => a.folder === b.folder && a.note === b.note && a.view === b.view;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the app's current location onto browser history, so the phone's back
|
||||||
|
* gesture steps back through notes and notebooks instead of leaving the app.
|
||||||
|
*
|
||||||
|
* `apply` is called when the user navigates back and should adopt the state it
|
||||||
|
* is handed.
|
||||||
|
*/
|
||||||
|
export function useNavigationHistory(
|
||||||
|
current: NavState,
|
||||||
|
isMobile: boolean,
|
||||||
|
apply: (state: NavState) => void
|
||||||
|
): void {
|
||||||
|
const applyRef = useRef(apply);
|
||||||
|
applyRef.current = apply;
|
||||||
|
const currentRef = useRef(current);
|
||||||
|
currentRef.current = current;
|
||||||
|
const mobileRef = useRef(isMobile);
|
||||||
|
mobileRef.current = isMobile;
|
||||||
|
|
||||||
|
/** What the topmost history entry represents. */
|
||||||
|
const pushed = useRef<NavState>({ folder: "dashboard", note: null, view: "list" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (same(pushed.current, current)) return;
|
||||||
|
pushed.current = { ...current };
|
||||||
|
try {
|
||||||
|
window.history.pushState(pushed.current, "");
|
||||||
|
} catch {
|
||||||
|
/* pushState is rate-limited in some browsers; losing an entry is not fatal */
|
||||||
|
}
|
||||||
|
}, [current.folder, current.note, current.view]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPop = (e: PopStateEvent) => {
|
||||||
|
const state = e.state as Partial<NavState> | null;
|
||||||
|
const cur = currentRef.current;
|
||||||
|
const view = mobileRef.current ? (state?.view ?? "list") : cur.view;
|
||||||
|
|
||||||
|
let next: NavState;
|
||||||
|
if (state) {
|
||||||
|
next = { folder: state.folder || "dashboard", note: state.note ?? null, view };
|
||||||
|
} else if (cur.note) {
|
||||||
|
// Stepping back past our own entries: close the note, then the notebook.
|
||||||
|
next = { folder: cur.folder, note: null, view: mobileRef.current ? "list" : cur.view };
|
||||||
|
} else if (cur.folder !== "dashboard") {
|
||||||
|
next = { folder: "dashboard", note: null, view: cur.view };
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record where history now stands *before* applying it. Without this the
|
||||||
|
// push effect sees a change it did not make and pushes a duplicate entry,
|
||||||
|
// which makes the back button bounce between two views forever.
|
||||||
|
pushed.current = next;
|
||||||
|
applyRef.current(next);
|
||||||
|
};
|
||||||
|
window.addEventListener("popstate", onPop);
|
||||||
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import * as sync from "../syncEngine";
|
||||||
|
import * as searchIndex from "../search";
|
||||||
|
import { sameStrokeDoc } from "../features/drawing/strokes.js";
|
||||||
|
import type { Backlink, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types";
|
||||||
|
|
||||||
|
/** How often the whole account is reconciled with the server. */
|
||||||
|
const FULL_SYNC_MS = 30_000;
|
||||||
|
/** How often the open note is re-read, so a second device's edits show up. */
|
||||||
|
const ACTIVE_NOTE_POLL_MS = 6_000;
|
||||||
|
/** After this long without a body arriving, show an empty editor rather than a spinner. */
|
||||||
|
const CONTENT_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
const EMPTY_CONTENT: NoteContent = { content: "", drawingData: null, drawingStrokes: null };
|
||||||
|
|
||||||
|
export interface NoteEdit {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
/** Ids of notes and notebooks the body links to; stored server-side for backlinks. */
|
||||||
|
links?: string[];
|
||||||
|
drawingStrokes?: StrokeDoc | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteData {
|
||||||
|
notes: NoteMeta[];
|
||||||
|
notebooks: Notebook[];
|
||||||
|
noteContents: Record<string, NoteContent>;
|
||||||
|
/** Notes linking to the currently open one. */
|
||||||
|
backlinks: Backlink[];
|
||||||
|
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
reset: () => void;
|
||||||
|
|
||||||
|
createNote: (folderId: string) => Promise<NoteMeta>;
|
||||||
|
updateNote: (id: string, edit: NoteEdit) => void;
|
||||||
|
setNoteContent: (id: string, content: NoteContent) => void;
|
||||||
|
toggleStar: (id: string) => Promise<void>;
|
||||||
|
trashNote: (id: string) => Promise<void>;
|
||||||
|
restoreNote: (id: string) => Promise<void>;
|
||||||
|
permanentDeleteNote: (id: string) => Promise<void>;
|
||||||
|
emptyTrash: () => Promise<void>;
|
||||||
|
moveNote: (id: string, folderId: string) => Promise<void>;
|
||||||
|
|
||||||
|
createNotebook: (fields: { name: string; cover: number }) => Promise<Notebook>;
|
||||||
|
updateNotebook: (id: string, updates: Partial<Notebook>) => Promise<void>;
|
||||||
|
deleteNotebook: (id: string) => Promise<void>;
|
||||||
|
reorderNotebooks: (dragId: string, targetId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns everything the app knows about notes and notebooks: the local copy, the
|
||||||
|
* mutations that change it, and the background work that keeps it current.
|
||||||
|
*
|
||||||
|
* Every mutation writes locally first and hands the change to the sync engine,
|
||||||
|
* so the UI stays responsive and offline edits are never lost. Confirmation
|
||||||
|
* prompts are deliberately *not* here — destructive intent is a question for
|
||||||
|
* the interface, not the store.
|
||||||
|
*/
|
||||||
|
export function useNoteData(user: User | null, activeNote: string | null): NoteData {
|
||||||
|
const [notes, setNotes] = useState<NoteMeta[]>([]);
|
||||||
|
const [notebooks, setNotebooks] = useState<Notebook[]>([]);
|
||||||
|
const [noteContents, setNoteContents] = useState<Record<string, NoteContent>>({});
|
||||||
|
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
|
||||||
|
|
||||||
|
const notesRef = useRef(notes);
|
||||||
|
notesRef.current = notes;
|
||||||
|
const notebooksRef = useRef(notebooks);
|
||||||
|
notebooksRef.current = notebooks;
|
||||||
|
const contentsRef = useRef(noteContents);
|
||||||
|
contentsRef.current = noteContents;
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setNotebooks(await sync.getNotebooks());
|
||||||
|
setNotes(await sync.getNotes());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setNotes([]);
|
||||||
|
setNotebooks([]);
|
||||||
|
setNoteContents({});
|
||||||
|
setBacklinks([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ── Background upkeep ── */
|
||||||
|
|
||||||
|
// The sync engine tells us when it has written to IndexedDB behind our back.
|
||||||
|
// Loaded bodies are kept as they are: replacing them here would make the
|
||||||
|
// editor flicker on every poll.
|
||||||
|
useEffect(() => sync.onDataChange(reload), [reload]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const i = setInterval(() => {
|
||||||
|
if (sync.isOnline()) void sync.fullSync().then(reload).catch(noop);
|
||||||
|
}, FULL_SYNC_MS);
|
||||||
|
return () => clearInterval(i);
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
/* ── The open note's body ── */
|
||||||
|
|
||||||
|
const hasContent = activeNote != null && noteContents[activeNote] !== undefined;
|
||||||
|
|
||||||
|
// Fetched on demand: on a cold start every body is missing, and loading them
|
||||||
|
// all up front would be slow and mostly wasted.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeNote || hasContent) return;
|
||||||
|
let cancelled = false;
|
||||||
|
sync
|
||||||
|
.loadNoteContent(activeNote)
|
||||||
|
.then((c) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setNoteContents((p) => ({ ...p, [activeNote]: c }));
|
||||||
|
const title = notesRef.current.find((n) => n.id === activeNote)?.title || "";
|
||||||
|
searchIndex.setEntry(activeNote, title, c.content);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setNoteContents((p) => ({ ...p, [activeNote]: EMPTY_CONTENT }));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [activeNote, hasContent]);
|
||||||
|
|
||||||
|
// Hard fallback: an unreachable server must not leave the editor stuck.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeNote || hasContent) return;
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setNoteContents((p) => (p[activeNote] ? p : { ...p, [activeNote]: EMPTY_CONTENT }));
|
||||||
|
}, CONTENT_TIMEOUT_MS);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [activeNote, hasContent]);
|
||||||
|
|
||||||
|
// Keep the open note fresh across devices.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || !activeNote) return;
|
||||||
|
const i = setInterval(() => {
|
||||||
|
if (!sync.isOnline()) return;
|
||||||
|
// Never adopt a response while this note has writes on the way out: it
|
||||||
|
// was requested before them and would undo what was just typed.
|
||||||
|
if (sync.hasPendingWrites(activeNote)) return;
|
||||||
|
sync
|
||||||
|
.loadNoteContent(activeNote, { forceRemote: true })
|
||||||
|
.then((c) => {
|
||||||
|
setNoteContents((p) => {
|
||||||
|
const prev = p[activeNote];
|
||||||
|
if (
|
||||||
|
prev &&
|
||||||
|
prev.content === c.content &&
|
||||||
|
prev.drawingData === c.drawingData &&
|
||||||
|
sameStrokeDoc(prev.drawingStrokes, c.drawingStrokes)
|
||||||
|
)
|
||||||
|
return p;
|
||||||
|
return { ...p, [activeNote]: c };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(noop);
|
||||||
|
}, ACTIVE_NOTE_POLL_MS);
|
||||||
|
return () => clearInterval(i);
|
||||||
|
}, [user, activeNote]);
|
||||||
|
|
||||||
|
const activeBody = activeNote ? noteContents[activeNote]?.content : undefined;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeNote || !user) {
|
||||||
|
setBacklinks([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
sync
|
||||||
|
.getBacklinks(activeNote)
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) setBacklinks(r);
|
||||||
|
})
|
||||||
|
.catch(noop);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [activeNote, user, activeBody]);
|
||||||
|
|
||||||
|
/* ── Notes ── */
|
||||||
|
|
||||||
|
const createNote = useCallback(async (folderId: string) => {
|
||||||
|
const note = await sync.createNote(folderId);
|
||||||
|
setNotes((p) => [note, ...p]);
|
||||||
|
setNoteContents((p) => ({ ...p, [note.id]: EMPTY_CONTENT }));
|
||||||
|
return note;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateNote = useCallback((id: string, edit: NoteEdit) => {
|
||||||
|
if (edit.title !== undefined) {
|
||||||
|
const title = edit.title;
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
setNotes((p) => p.map((n) => (n.id === id ? { ...n, title, updatedAt } : n)));
|
||||||
|
void sync.updateNoteTitle(id, title);
|
||||||
|
searchIndex.setEntry(id, title, contentsRef.current[id]?.content);
|
||||||
|
}
|
||||||
|
if (edit.content !== undefined) {
|
||||||
|
const content = edit.content;
|
||||||
|
setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), content } }));
|
||||||
|
void sync.updateNoteContent(id, content, edit.links);
|
||||||
|
searchIndex.setEntry(id, notesRef.current.find((n) => n.id === id)?.title || "", content);
|
||||||
|
}
|
||||||
|
if (edit.drawingStrokes !== undefined) {
|
||||||
|
const drawingStrokes = edit.drawingStrokes;
|
||||||
|
setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), drawingStrokes } }));
|
||||||
|
void sync.updateNoteDrawing(id, drawingStrokes);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Replaces a body wholesale — used when restoring an older version. */
|
||||||
|
const setNoteContent = useCallback((id: string, content: NoteContent) => {
|
||||||
|
setNoteContents((p) => ({ ...p, [id]: content }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleStar = useCallback(async (id: string) => {
|
||||||
|
const starred = await sync.toggleNoteStar(id);
|
||||||
|
setNotes((p) => p.map((n) => (n.id === id ? { ...n, starred } : n)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const trashNote = useCallback(async (id: string) => {
|
||||||
|
await sync.trashNote(id);
|
||||||
|
setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: true } : n)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const restoreNote = useCallback(async (id: string) => {
|
||||||
|
await sync.restoreNoteLocal(id);
|
||||||
|
setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: false } : n)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const permanentDeleteNote = useCallback(async (id: string) => {
|
||||||
|
await sync.permanentDeleteNoteLocal(id);
|
||||||
|
searchIndex.removeEntry(id);
|
||||||
|
setNotes((p) => p.filter((n) => n.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const emptyTrash = useCallback(async () => {
|
||||||
|
await sync.emptyTrashLocal();
|
||||||
|
for (const n of notesRef.current) if (n.trashed) searchIndex.removeEntry(n.id);
|
||||||
|
setNotes((p) => p.filter((n) => !n.trashed));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const moveNote = useCallback(async (id: string, folderId: string) => {
|
||||||
|
await sync.moveNote(id, folderId);
|
||||||
|
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId } : n)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ── Notebooks ── */
|
||||||
|
|
||||||
|
const createNotebook = useCallback(async (fields: { name: string; cover: number }) => {
|
||||||
|
const nb = await sync.createNotebook(fields);
|
||||||
|
setNotebooks((p) => [...p, nb]);
|
||||||
|
return nb;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateNotebook = useCallback(async (id: string, updates: Partial<Notebook>) => {
|
||||||
|
await sync.updateNotebook(id, updates);
|
||||||
|
setNotebooks((p) => p.map((nb) => (nb.id === id ? { ...nb, ...updates } : nb)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Deleting a notebook trashes its notes rather than destroying them. */
|
||||||
|
const deleteNotebook = useCallback(async (id: string) => {
|
||||||
|
await sync.deleteNotebookLocal(id);
|
||||||
|
setNotes((p) => p.map((n) => (n.folderId === id ? { ...n, trashed: true } : n)));
|
||||||
|
setNotebooks((p) => p.filter((nb) => nb.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reorderNotebooks = useCallback((dragId: string, targetId: string) => {
|
||||||
|
const list = [...notebooksRef.current];
|
||||||
|
const from = list.findIndex((n) => n.id === dragId);
|
||||||
|
const to = list.findIndex((n) => n.id === targetId);
|
||||||
|
if (from < 0 || to < 0 || from === to) return;
|
||||||
|
const [moved] = list.splice(from, 1);
|
||||||
|
if (!moved) return;
|
||||||
|
list.splice(to, 0, moved);
|
||||||
|
const reordered = list.map((n, i) => ({ ...n, sortOrder: i }));
|
||||||
|
setNotebooks(reordered);
|
||||||
|
void sync.reorderNotebooks(reordered.map((n) => n.id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
notes,
|
||||||
|
notebooks,
|
||||||
|
noteContents,
|
||||||
|
backlinks,
|
||||||
|
reload,
|
||||||
|
reset,
|
||||||
|
createNote,
|
||||||
|
updateNote,
|
||||||
|
setNoteContent,
|
||||||
|
toggleStar,
|
||||||
|
trashNote,
|
||||||
|
restoreNote,
|
||||||
|
permanentDeleteNote,
|
||||||
|
emptyTrash,
|
||||||
|
moveNote,
|
||||||
|
createNotebook,
|
||||||
|
updateNotebook,
|
||||||
|
deleteNotebook,
|
||||||
|
reorderNotebooks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import type { DragEvent } from "react";
|
||||||
|
|
||||||
|
export interface NotebookDnd {
|
||||||
|
/** Notebook currently under the pointer, for the drop indicator. */
|
||||||
|
overId: string | null;
|
||||||
|
onDragStart: (e: DragEvent, id: string) => void;
|
||||||
|
onDragOver: (e: DragEvent, id: string) => void;
|
||||||
|
onDrop: (e: DragEvent, id: string) => void;
|
||||||
|
onDragEnd: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag-to-reorder for the notebook list, shared by the sidebar and the
|
||||||
|
* dashboard so a notebook can be dragged in either place.
|
||||||
|
*/
|
||||||
|
export function useNotebookDnd(reorder: (dragId: string, targetId: string) => void): NotebookDnd {
|
||||||
|
const [overId, setOverId] = useState<string | null>(null);
|
||||||
|
// The dragged id is not rendered, so a ref avoids a re-render per dragover.
|
||||||
|
const dragId = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const onDragStart = useCallback((e: DragEvent, id: string) => {
|
||||||
|
dragId.current = id;
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDragOver = useCallback((e: DragEvent, id: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (dragId.current && dragId.current !== id) setOverId(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
dragId.current = null;
|
||||||
|
setOverId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDrop = useCallback(
|
||||||
|
(e: DragEvent, targetId: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const from = dragId.current;
|
||||||
|
if (from && from !== targetId) reorder(from, targetId);
|
||||||
|
clear();
|
||||||
|
},
|
||||||
|
[reorder, clear]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { overId, onDragStart, onDragOver, onDrop, onDragEnd: clear };
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/** Chrome's non-standard install event. Not in lib.dom, so it is declared here. */
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt(): Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PwaInstall {
|
||||||
|
/** Non-null only while the browser is willing to show an install prompt. */
|
||||||
|
canInstall: boolean;
|
||||||
|
promptInstall: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures the browser's install offer so the app can present it at a sensible
|
||||||
|
* moment instead of letting the browser choose one.
|
||||||
|
*/
|
||||||
|
export function usePwaInstall(): PwaInstall {
|
||||||
|
const [event, setEvent] = useState<BeforeInstallPromptEvent | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPrompt = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setEvent(e as BeforeInstallPromptEvent);
|
||||||
|
};
|
||||||
|
window.addEventListener("beforeinstallprompt", onPrompt);
|
||||||
|
return () => window.removeEventListener("beforeinstallprompt", onPrompt);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const promptInstall = async () => {
|
||||||
|
if (!event) return;
|
||||||
|
await event.prompt();
|
||||||
|
const { outcome } = await event.userChoice;
|
||||||
|
// The captured event is single-use; drop it either way so the button does
|
||||||
|
// not linger after a decision.
|
||||||
|
if (outcome === "accepted") setEvent(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { canInstall: event !== null, promptInstall };
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
export type MobileView = "list" | "editor";
|
||||||
|
|
||||||
|
export interface Responsive {
|
||||||
|
isMobile: boolean;
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
setSidebarOpen: (open: boolean) => void;
|
||||||
|
/** On a phone, list and editor share the screen; this says which one has it. */
|
||||||
|
mobileView: MobileView;
|
||||||
|
setMobileView: (view: MobileView) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks whether we are on a phone-sized screen and keeps the sidebar's
|
||||||
|
* open/closed state consistent with it: on a desktop the sidebar is always
|
||||||
|
* available, on a phone it is an overlay that starts closed.
|
||||||
|
*/
|
||||||
|
export function useResponsive(): Responsive {
|
||||||
|
const [isMobile, setIsMobile] = useState(() => window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth > MOBILE_BREAKPOINT);
|
||||||
|
const [mobileView, setMobileView] = useState<MobileView>("list");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = () => {
|
||||||
|
const mobile = window.innerWidth < MOBILE_BREAKPOINT;
|
||||||
|
setIsMobile(mobile);
|
||||||
|
// Growing past the breakpoint must not leave the sidebar hidden with no
|
||||||
|
// way to bring it back.
|
||||||
|
if (!mobile) setSidebarOpen(true);
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => window.removeEventListener("resize", onResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { isMobile, sidebarOpen, setSidebarOpen, mobileView, setMobileView };
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import * as sync from "../syncEngine";
|
||||||
|
import * as searchIndex from "../search";
|
||||||
|
import type { NoteMeta, SearchHit, User } from "../types";
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 120;
|
||||||
|
/** Refresh visible results every N notes while the backfill is still running. */
|
||||||
|
const PROGRESS_EVERY = 10;
|
||||||
|
|
||||||
|
export interface SearchState {
|
||||||
|
/** null means "not searching"; an empty array means "searched, found nothing". */
|
||||||
|
results: SearchHit[] | null;
|
||||||
|
/** Excerpt around the match, empty when the note is not a hit. */
|
||||||
|
snippetFor: (id: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-text search over the local copy of every note.
|
||||||
|
*
|
||||||
|
* The server stores bodies encrypted and cannot index them, so the index is
|
||||||
|
* built here. Bodies normally reach the client only when a note is opened, so
|
||||||
|
* the missing ones are backfilled in the background after login.
|
||||||
|
*/
|
||||||
|
export function useSearchIndex(user: User | null, notes: NoteMeta[], query: string): SearchState {
|
||||||
|
const [results, setResults] = useState<SearchHit[] | null>(null);
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
const running = useRef(false);
|
||||||
|
|
||||||
|
// Read by the backfill so it sees current titles without being torn down and
|
||||||
|
// restarted every time a note changes.
|
||||||
|
const notesRef = useRef(notes);
|
||||||
|
notesRef.current = notes;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Waits for the note list: starting before the first sync would find
|
||||||
|
// nothing to fetch and never run again.
|
||||||
|
if (!user || notes.length === 0 || running.current) return;
|
||||||
|
running.current = true;
|
||||||
|
let cancelled = false;
|
||||||
|
const signal = { aborted: false };
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
await searchIndex.rebuild();
|
||||||
|
if (!cancelled) setVersion((v) => v + 1);
|
||||||
|
await sync.prefetchAllContent({
|
||||||
|
signal,
|
||||||
|
onProgress: ({ id, content, fetched }) => {
|
||||||
|
const note = notesRef.current.find((n) => n.id === id);
|
||||||
|
searchIndex.setEntry(id, note?.title || "", content);
|
||||||
|
if (fetched % PROGRESS_EVERY === 0 && !cancelled) setVersion((v) => v + 1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!cancelled) setVersion((v) => v + 1);
|
||||||
|
running.current = false;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
signal.aborted = true;
|
||||||
|
};
|
||||||
|
}, [user, notes.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) {
|
||||||
|
setResults(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Debounced so typing does not re-scan the index on every keystroke.
|
||||||
|
const t = setTimeout(() => setResults(searchIndex.search(q)), DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [query, version]);
|
||||||
|
|
||||||
|
const snippets = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const r of results || []) if (r.snippet) m.set(r.id, r.snippet);
|
||||||
|
return m;
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
|
const snippetFor = useMemo(() => (id: string) => snippets.get(id) || "", [snippets]);
|
||||||
|
|
||||||
|
return { results, snippetFor };
|
||||||
|
}
|
||||||
+15
-12
@@ -1,14 +1,17 @@
|
|||||||
import React from 'react'
|
import React from "react";
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from "react-dom/client";
|
||||||
import NoteApp from './NoteApp.jsx'
|
import NoteApp from "./App.jsx";
|
||||||
|
|
||||||
// Hide splash screen
|
// Hide splash screen
|
||||||
const hideSplash = () => {
|
const hideSplash = () => {
|
||||||
const splash = document.getElementById('splash');
|
const splash = document.getElementById("splash");
|
||||||
if (splash) { splash.classList.add('hide'); setTimeout(() => splash.remove(), 300); }
|
if (splash) {
|
||||||
|
splash.classList.add("hide");
|
||||||
|
setTimeout(() => splash.remove(), 300);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<NoteApp />
|
<NoteApp />
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
@@ -17,17 +20,17 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
|||||||
setTimeout(hideSplash, 200);
|
setTimeout(hideSplash, 200);
|
||||||
|
|
||||||
// Register service worker
|
// Register service worker
|
||||||
if ('serviceWorker' in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
window.addEventListener('load', async () => {
|
window.addEventListener("load", async () => {
|
||||||
try {
|
try {
|
||||||
// Use the page's base URL as scope so it works behind any reverse proxy path
|
// Use the page's base URL as scope so it works behind any reverse proxy path
|
||||||
const swUrl = new URL('./sw.js', window.location.href).href;
|
const swUrl = new URL("./sw.js", window.location.href).href;
|
||||||
const scope = new URL('./', window.location.href).href;
|
const scope = new URL("./", window.location.href).href;
|
||||||
const reg = await navigator.serviceWorker.register(swUrl, { scope });
|
const reg = await navigator.serviceWorker.register(swUrl, { scope });
|
||||||
console.log('[SW] Registered, scope:', reg.scope);
|
console.log("[SW] Registered, scope:", reg.scope);
|
||||||
setInterval(() => reg.update(), 60 * 60 * 1000);
|
setInterval(() => reg.update(), 60 * 60 * 1000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[SW] Registration failed:', err);
|
console.warn("[SW] Registration failed:", err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
/**
|
|
||||||
* InkNote Offline Store — IndexedDB local-first storage
|
|
||||||
*
|
|
||||||
* All reads/writes go here first (instant, works offline).
|
|
||||||
* Sync engine flushes changes to server when online.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const DB_NAME = 'inknote';
|
|
||||||
const DB_VERSION = 2;
|
|
||||||
|
|
||||||
let db = null;
|
|
||||||
|
|
||||||
function open() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (db) return resolve(db);
|
|
||||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
||||||
|
|
||||||
req.onupgradeneeded = (e) => {
|
|
||||||
const d = e.target.result;
|
|
||||||
|
|
||||||
// Notebooks store
|
|
||||||
if (!d.objectStoreNames.contains('notebooks')) {
|
|
||||||
const nb = d.createObjectStore('notebooks', { keyPath: 'id' });
|
|
||||||
nb.createIndex('sortOrder', 'sortOrder');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notes metadata store
|
|
||||||
if (!d.objectStoreNames.contains('notes')) {
|
|
||||||
const n = d.createObjectStore('notes', { keyPath: 'id' });
|
|
||||||
n.createIndex('folderId', 'folderId');
|
|
||||||
n.createIndex('trashed', 'trashed');
|
|
||||||
n.createIndex('updatedAt', 'updatedAt');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note content store (separate for lazy loading)
|
|
||||||
if (!d.objectStoreNames.contains('noteContents')) {
|
|
||||||
d.createObjectStore('noteContents', { keyPath: 'id' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync queue — pending changes to push to server
|
|
||||||
if (!d.objectStoreNames.contains('syncQueue')) {
|
|
||||||
const sq = d.createObjectStore('syncQueue', { keyPath: 'queueId', autoIncrement: true });
|
|
||||||
sq.createIndex('entityId', 'entityId');
|
|
||||||
sq.createIndex('timestamp', 'timestamp');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Key-value meta store (token, user, last sync time, etc)
|
|
||||||
if (!d.objectStoreNames.contains('meta')) {
|
|
||||||
d.createObjectStore('meta', { keyPath: 'key' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
req.onsuccess = (e) => { db = e.target.result; resolve(db); };
|
|
||||||
req.onerror = (e) => reject(e.target.error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Generic helpers ───
|
|
||||||
async function tx(stores, mode, fn) {
|
|
||||||
const d = await open();
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const t = d.transaction(stores, mode);
|
|
||||||
const result = fn(t);
|
|
||||||
t.oncomplete = () => resolve(result._value);
|
|
||||||
t.onerror = (e) => reject(e.target.error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function reqToPromise(req) {
|
|
||||||
return new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = (e) => rej(e.target.error); });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Meta (key-value) ───
|
|
||||||
export async function getMeta(key) {
|
|
||||||
const d = await open();
|
|
||||||
const r = await reqToPromise(d.transaction('meta', 'readonly').objectStore('meta').get(key));
|
|
||||||
return r?.value ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setMeta(key, value) {
|
|
||||||
const d = await open();
|
|
||||||
await reqToPromise(d.transaction('meta', 'readwrite').objectStore('meta').put({ key, value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteMeta(key) {
|
|
||||||
const d = await open();
|
|
||||||
await reqToPromise(d.transaction('meta', 'readwrite').objectStore('meta').delete(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Notebooks ───
|
|
||||||
export async function getAllNotebooks() {
|
|
||||||
const d = await open();
|
|
||||||
const all = await reqToPromise(d.transaction('notebooks', 'readonly').objectStore('notebooks').getAll());
|
|
||||||
return all.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function putNotebook(nb) {
|
|
||||||
const d = await open();
|
|
||||||
await reqToPromise(d.transaction('notebooks', 'readwrite').objectStore('notebooks').put(nb));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function putNotebooks(list) {
|
|
||||||
const d = await open();
|
|
||||||
const t = d.transaction('notebooks', 'readwrite');
|
|
||||||
const s = t.objectStore('notebooks');
|
|
||||||
for (const nb of list) s.put(nb);
|
|
||||||
return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteNotebook(id) {
|
|
||||||
const d = await open();
|
|
||||||
await reqToPromise(d.transaction('notebooks', 'readwrite').objectStore('notebooks').delete(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearNotebooks() {
|
|
||||||
const d = await open();
|
|
||||||
await reqToPromise(d.transaction('notebooks', 'readwrite').objectStore('notebooks').clear());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Notes (metadata) ───
|
|
||||||
export async function getAllNotes() {
|
|
||||||
const d = await open();
|
|
||||||
return reqToPromise(d.transaction('notes', 'readonly').objectStore('notes').getAll());
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getNote(id) {
|
|
||||||
const d = await open();
|
|
||||||
return reqToPromise(d.transaction('notes', 'readonly').objectStore('notes').get(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function putNote(note) {
|
|
||||||
const d = await open();
|
|
||||||
await reqToPromise(d.transaction('notes', 'readwrite').objectStore('notes').put(note));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function putNotes(list) {
|
|
||||||
const d = await open();
|
|
||||||
const t = d.transaction('notes', 'readwrite');
|
|
||||||
const s = t.objectStore('notes');
|
|
||||||
for (const n of list) s.put(n);
|
|
||||||
return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteNote(id) {
|
|
||||||
const d = await open();
|
|
||||||
const t = d.transaction(['notes', 'noteContents'], 'readwrite');
|
|
||||||
t.objectStore('notes').delete(id);
|
|
||||||
t.objectStore('noteContents').delete(id);
|
|
||||||
return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearNotes() {
|
|
||||||
const d = await open();
|
|
||||||
const t = d.transaction(['notes'], 'readwrite');
|
|
||||||
t.objectStore('notes').clear();
|
|
||||||
return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Note content (lazy loaded) ───
|
|
||||||
export async function getNoteContent(id) {
|
|
||||||
const d = await open();
|
|
||||||
return reqToPromise(d.transaction('noteContents', 'readonly').objectStore('noteContents').get(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function putNoteContent(id, content, drawingData) {
|
|
||||||
const d = await open();
|
|
||||||
await reqToPromise(d.transaction('noteContents', 'readwrite').objectStore('noteContents').put({ id, content, drawingData }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Sync Queue ───
|
|
||||||
export async function enqueue(action) {
|
|
||||||
const d = await open();
|
|
||||||
const entry = { ...action, timestamp: Date.now() };
|
|
||||||
await reqToPromise(d.transaction('syncQueue', 'readwrite').objectStore('syncQueue').add(entry));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function peekQueue(limit = 50) {
|
|
||||||
const d = await open();
|
|
||||||
return reqToPromise(d.transaction('syncQueue', 'readonly').objectStore('syncQueue').getAll(null, limit));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function dequeue(queueId) {
|
|
||||||
const d = await open();
|
|
||||||
await reqToPromise(d.transaction('syncQueue', 'readwrite').objectStore('syncQueue').delete(queueId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearQueue() {
|
|
||||||
const d = await open();
|
|
||||||
await reqToPromise(d.transaction('syncQueue', 'readwrite').objectStore('syncQueue').clear());
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getQueueSize() {
|
|
||||||
const d = await open();
|
|
||||||
return reqToPromise(d.transaction('syncQueue', 'readonly').objectStore('syncQueue').count());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Full wipe (for logout) ───
|
|
||||||
export async function clearAll() {
|
|
||||||
const d = await open();
|
|
||||||
const stores = ['notebooks', 'notes', 'noteContents', 'syncQueue', 'meta'];
|
|
||||||
const t = d.transaction(stores, 'readwrite');
|
|
||||||
stores.forEach((s) => t.objectStore(s).clear());
|
|
||||||
return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; });
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
import * as db from "./offlineStore";
|
||||||
|
import type { NoteMeta, Notebook } from "./types";
|
||||||
|
|
||||||
|
const note = (id: string, over: Partial<NoteMeta> = {}): NoteMeta => ({
|
||||||
|
id,
|
||||||
|
title: `Note ${id}`,
|
||||||
|
folderId: "nb1",
|
||||||
|
starred: false,
|
||||||
|
trashed: false,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const notebook = (id: string, sortOrder = 0): Notebook => ({ id, name: `NB ${id}`, cover: 0, sortOrder });
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db.clearAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("notes", () => {
|
||||||
|
it("stores and reads back a note", async () => {
|
||||||
|
await db.putNote(note("a"));
|
||||||
|
expect(await db.getNote("a")).toMatchObject({ id: "a", title: "Note a" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for a note that does not exist", async () => {
|
||||||
|
expect(await db.getNote("nope")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a batch in one transaction", async () => {
|
||||||
|
await db.putNotes([note("a"), note("b"), note("c")]);
|
||||||
|
expect((await db.getAllNotes()).length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleting a note also drops its body, leaving nothing orphaned", async () => {
|
||||||
|
await db.putNote(note("a"));
|
||||||
|
await db.putNoteContent("a", "content", null);
|
||||||
|
await db.deleteNote("a");
|
||||||
|
expect(await db.getNote("a")).toBeUndefined();
|
||||||
|
expect(await db.getNoteContent("a")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("note content", () => {
|
||||||
|
it("keeps content, drawing and revision together", async () => {
|
||||||
|
const strokes = {
|
||||||
|
v: 1,
|
||||||
|
w: 1200,
|
||||||
|
h: 1600,
|
||||||
|
strokes: [{ t: "pen" as const, c: "#fff", s: 5, p: [0, 0, 1, 1] }],
|
||||||
|
};
|
||||||
|
await db.putNoteContent("a", "body", null, strokes, "2026-02-02T00:00:00.000Z");
|
||||||
|
const got = await db.getNoteContent("a");
|
||||||
|
expect(got?.content).toBe("body");
|
||||||
|
expect(got?.drawingStrokes?.strokes.length).toBe(1);
|
||||||
|
expect(got?.updatedAt).toBe("2026-02-02T00:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the drawing fields rather than leaving them undefined", async () => {
|
||||||
|
await db.putNoteContent("a", "body", null);
|
||||||
|
const got = await db.getNoteContent("a");
|
||||||
|
expect(got?.drawingStrokes).toBeNull();
|
||||||
|
expect(got?.updatedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overwrites an existing body", async () => {
|
||||||
|
await db.putNoteContent("a", "first", null);
|
||||||
|
await db.putNoteContent("a", "second", null);
|
||||||
|
expect((await db.getNoteContent("a"))?.content).toBe("second");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("notebooks", () => {
|
||||||
|
it("returns notebooks in sort order, not insertion order", async () => {
|
||||||
|
await db.putNotebooks([notebook("c", 2), notebook("a", 0), notebook("b", 1)]);
|
||||||
|
expect((await db.getAllNotebooks()).map((n) => n.id)).toEqual(["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sync queue", () => {
|
||||||
|
it("preserves insertion order", async () => {
|
||||||
|
await db.enqueue({ action: "first", data: {} });
|
||||||
|
await db.enqueue({ action: "second", data: {} });
|
||||||
|
expect((await db.peekQueue()).map((e) => e.action)).toEqual(["first", "second"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts what is waiting", async () => {
|
||||||
|
await db.enqueue({ action: "a", data: {} });
|
||||||
|
await db.enqueue({ action: "b", data: {} });
|
||||||
|
expect(await db.getQueueSize()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes only the dequeued entry", async () => {
|
||||||
|
await db.enqueue({ action: "keep", data: {} });
|
||||||
|
await db.enqueue({ action: "drop", data: {} });
|
||||||
|
const [, second] = await db.peekQueue();
|
||||||
|
await db.dequeue(second!.queueId);
|
||||||
|
expect((await db.peekQueue()).map((e) => e.action)).toEqual(["keep"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a failed attempt without losing the entry — the bug that silently ate offline edits", async () => {
|
||||||
|
await db.enqueue({ action: "updateNote", data: { id: "a" } });
|
||||||
|
const [entry] = await db.peekQueue();
|
||||||
|
await db.updateQueueEntry(entry!.queueId, { attempts: 1, lastError: "boom" });
|
||||||
|
|
||||||
|
const [updated] = await db.peekQueue();
|
||||||
|
expect(updated!.attempts).toBe(1);
|
||||||
|
expect(updated!.lastError).toBe("boom");
|
||||||
|
expect(updated!.action).toBe("updateNote");
|
||||||
|
expect(updated!.data).toEqual({ id: "a" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an update for an entry that is already gone", async () => {
|
||||||
|
await expect(db.updateQueueEntry(9999, { attempts: 1 })).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("attachments", () => {
|
||||||
|
/* jsdom's Blob is a stub without `arrayBuffer`, and fake-indexeddb's
|
||||||
|
* structured clone flattens it to `{}` — so real Blob fidelity cannot be
|
||||||
|
* asserted here. A byte array clones faithfully and still exercises the
|
||||||
|
* store's own logic; that images survive a round trip through a real browser
|
||||||
|
* is covered by the end-to-end suite instead. */
|
||||||
|
it("round-trips binary payloads", async () => {
|
||||||
|
const bytes = new Uint8Array([1, 2, 3, 250]);
|
||||||
|
await db.putAttachmentBlob("att1", bytes as unknown as Blob);
|
||||||
|
const got = (await db.getAttachmentBlob("att1")) as unknown as Uint8Array;
|
||||||
|
expect(got).not.toBeNull();
|
||||||
|
expect(Array.from(got)).toEqual([1, 2, 3, 250]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an attachment that was never cached", async () => {
|
||||||
|
expect(await db.getAttachmentBlob("missing")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("meta", () => {
|
||||||
|
it("stores arbitrary values", async () => {
|
||||||
|
await db.setMeta("user", { id: "u1", username: "marc" });
|
||||||
|
expect(await db.getMeta("user")).toEqual({ id: "u1", username: "marc" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an unset key", async () => {
|
||||||
|
expect(await db.getMeta("nothing")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a key", async () => {
|
||||||
|
await db.setMeta("token", "abc");
|
||||||
|
await db.deleteMeta("token");
|
||||||
|
expect(await db.getMeta("token")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearAll", () => {
|
||||||
|
it("wipes every store, as logout must", async () => {
|
||||||
|
await db.putNote(note("a"));
|
||||||
|
await db.putNoteContent("a", "body", null);
|
||||||
|
await db.putNotebook(notebook("nb1"));
|
||||||
|
await db.enqueue({ action: "x", data: {} });
|
||||||
|
await db.setMeta("token", "abc");
|
||||||
|
await db.putAttachmentBlob("att", new Blob(["x"]));
|
||||||
|
|
||||||
|
await db.clearAll();
|
||||||
|
|
||||||
|
expect(await db.getAllNotes()).toEqual([]);
|
||||||
|
expect(await db.getNoteContent("a")).toBeUndefined();
|
||||||
|
expect(await db.getAllNotebooks()).toEqual([]);
|
||||||
|
expect(await db.getQueueSize()).toBe(0);
|
||||||
|
expect(await db.getMeta("token")).toBeNull();
|
||||||
|
expect(await db.getAttachmentBlob("att")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* InkNote Offline Store — IndexedDB local-first storage.
|
||||||
|
*
|
||||||
|
* All reads and writes go here first, so the app works offline; the sync engine
|
||||||
|
* pushes queued changes to the server when a connection is available.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { NoteMeta, NoteContent, Notebook, StrokeDoc, QueueEntry } from "./types";
|
||||||
|
|
||||||
|
const DB_NAME = "inknote";
|
||||||
|
const DB_VERSION = 3;
|
||||||
|
|
||||||
|
type StoreName = "notebooks" | "notes" | "noteContents" | "syncQueue" | "meta" | "attachments";
|
||||||
|
|
||||||
|
let db: IDBDatabase | null = null;
|
||||||
|
|
||||||
|
function open(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (db) return resolve(db);
|
||||||
|
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
|
req.onupgradeneeded = (e) => {
|
||||||
|
const d = (e.target as IDBOpenDBRequest).result;
|
||||||
|
|
||||||
|
if (!d.objectStoreNames.contains("notebooks")) {
|
||||||
|
const nb = d.createObjectStore("notebooks", { keyPath: "id" });
|
||||||
|
nb.createIndex("sortOrder", "sortOrder");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!d.objectStoreNames.contains("notes")) {
|
||||||
|
const n = d.createObjectStore("notes", { keyPath: "id" });
|
||||||
|
n.createIndex("folderId", "folderId");
|
||||||
|
n.createIndex("trashed", "trashed");
|
||||||
|
n.createIndex("updatedAt", "updatedAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bodies live apart from metadata so the note list stays cheap to load.
|
||||||
|
if (!d.objectStoreNames.contains("noteContents")) {
|
||||||
|
d.createObjectStore("noteContents", { keyPath: "id" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!d.objectStoreNames.contains("syncQueue")) {
|
||||||
|
const sq = d.createObjectStore("syncQueue", { keyPath: "queueId", autoIncrement: true });
|
||||||
|
sq.createIndex("entityId", "entityId");
|
||||||
|
sq.createIndex("timestamp", "timestamp");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!d.objectStoreNames.contains("meta")) {
|
||||||
|
d.createObjectStore("meta", { keyPath: "key" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment bytes, so images render offline and download only once.
|
||||||
|
if (!d.objectStoreNames.contains("attachments")) {
|
||||||
|
d.createObjectStore("attachments", { keyPath: "id" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
req.onsuccess = (e) => {
|
||||||
|
db = (e.target as IDBOpenDBRequest).result;
|
||||||
|
resolve(db);
|
||||||
|
};
|
||||||
|
req.onerror = (e) => reject((e.target as IDBOpenDBRequest).error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reqToPromise<T>(req: IDBRequest<T>): Promise<T> {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
req.onsuccess = () => res(req.result);
|
||||||
|
req.onerror = () => rej(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves when the whole transaction commits, not just one request. */
|
||||||
|
function txDone(t: IDBTransaction): Promise<void> {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
t.oncomplete = () => res();
|
||||||
|
t.onerror = () => rej(t.error);
|
||||||
|
t.onabort = () => rej(t.error ?? new Error("Transaction aborted"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function store(name: StoreName, mode: IDBTransactionMode): Promise<IDBObjectStore> {
|
||||||
|
const d = await open();
|
||||||
|
return d.transaction(name, mode).objectStore(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Meta (key-value) ─── */
|
||||||
|
export async function getMeta<T = unknown>(key: string): Promise<T | null> {
|
||||||
|
const s = await store("meta", "readonly");
|
||||||
|
const r = await reqToPromise<{ key: string; value: T } | undefined>(s.get(key));
|
||||||
|
return r?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setMeta(key: string, value: unknown): Promise<void> {
|
||||||
|
const s = await store("meta", "readwrite");
|
||||||
|
await reqToPromise(s.put({ key, value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMeta(key: string): Promise<void> {
|
||||||
|
const s = await store("meta", "readwrite");
|
||||||
|
await reqToPromise(s.delete(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Notebooks ─── */
|
||||||
|
export async function getAllNotebooks(): Promise<Notebook[]> {
|
||||||
|
const s = await store("notebooks", "readonly");
|
||||||
|
const all = await reqToPromise<Notebook[]>(s.getAll());
|
||||||
|
return all.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putNotebook(nb: Notebook): Promise<void> {
|
||||||
|
const s = await store("notebooks", "readwrite");
|
||||||
|
await reqToPromise(s.put(nb));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putNotebooks(list: Notebook[]): Promise<void> {
|
||||||
|
const d = await open();
|
||||||
|
const t = d.transaction("notebooks", "readwrite");
|
||||||
|
const s = t.objectStore("notebooks");
|
||||||
|
for (const nb of list) s.put(nb);
|
||||||
|
return txDone(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNotebook(id: string): Promise<void> {
|
||||||
|
const s = await store("notebooks", "readwrite");
|
||||||
|
await reqToPromise(s.delete(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearNotebooks(): Promise<void> {
|
||||||
|
const s = await store("notebooks", "readwrite");
|
||||||
|
await reqToPromise(s.clear());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Notes (metadata) ─── */
|
||||||
|
export async function getAllNotes(): Promise<NoteMeta[]> {
|
||||||
|
const s = await store("notes", "readonly");
|
||||||
|
return reqToPromise<NoteMeta[]>(s.getAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNote(id: string): Promise<NoteMeta | undefined> {
|
||||||
|
const s = await store("notes", "readonly");
|
||||||
|
return reqToPromise<NoteMeta | undefined>(s.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putNote(note: NoteMeta): Promise<void> {
|
||||||
|
const s = await store("notes", "readwrite");
|
||||||
|
await reqToPromise(s.put(note));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putNotes(list: NoteMeta[]): Promise<void> {
|
||||||
|
const d = await open();
|
||||||
|
const t = d.transaction("notes", "readwrite");
|
||||||
|
const s = t.objectStore("notes");
|
||||||
|
for (const n of list) s.put(n);
|
||||||
|
return txDone(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNote(id: string): Promise<void> {
|
||||||
|
const d = await open();
|
||||||
|
const t = d.transaction(["notes", "noteContents"], "readwrite");
|
||||||
|
t.objectStore("notes").delete(id);
|
||||||
|
t.objectStore("noteContents").delete(id);
|
||||||
|
return txDone(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearNotes(): Promise<void> {
|
||||||
|
const s = await store("notes", "readwrite");
|
||||||
|
await reqToPromise(s.clear());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Note content (lazily loaded) ─── */
|
||||||
|
export async function getNoteContent(id: string): Promise<(NoteContent & { id: string }) | undefined> {
|
||||||
|
const s = await store("noteContents", "readonly");
|
||||||
|
return reqToPromise<(NoteContent & { id: string }) | undefined>(s.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `updatedAt` records which server revision this copy came from, so a background
|
||||||
|
* prefetch can tell a stale cached body from an up-to-date one.
|
||||||
|
*/
|
||||||
|
export async function putNoteContent(
|
||||||
|
id: string,
|
||||||
|
content: string,
|
||||||
|
drawingData: string | null,
|
||||||
|
drawingStrokes: StrokeDoc | null = null,
|
||||||
|
updatedAt: string | null = null
|
||||||
|
): Promise<void> {
|
||||||
|
const s = await store("noteContents", "readwrite");
|
||||||
|
await reqToPromise(s.put({ id, content, drawingData, drawingStrokes, updatedAt }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Attachments (binary cache) ─── */
|
||||||
|
export async function getAttachmentBlob(id: string): Promise<Blob | null> {
|
||||||
|
const s = await store("attachments", "readonly");
|
||||||
|
const r = await reqToPromise<{ id: string; blob: Blob } | undefined>(s.get(id));
|
||||||
|
return r?.blob ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putAttachmentBlob(id: string, blob: Blob): Promise<void> {
|
||||||
|
const s = await store("attachments", "readwrite");
|
||||||
|
await reqToPromise(s.put({ id, blob, cachedAt: Date.now() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Sync queue ─── */
|
||||||
|
export async function enqueue(action: { action: string; data: Record<string, unknown> }): Promise<void> {
|
||||||
|
const s = await store("syncQueue", "readwrite");
|
||||||
|
await reqToPromise(s.add({ ...action, timestamp: Date.now() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function peekQueue(limit = 50): Promise<QueueEntry[]> {
|
||||||
|
const s = await store("syncQueue", "readonly");
|
||||||
|
return reqToPromise<QueueEntry[]>(s.getAll(null, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dequeue(queueId: number): Promise<void> {
|
||||||
|
const s = await store("syncQueue", "readwrite");
|
||||||
|
await reqToPromise(s.delete(queueId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records a failed attempt so a retried entry can eventually be given up on. */
|
||||||
|
export async function updateQueueEntry(queueId: number, patch: Partial<QueueEntry>): Promise<void> {
|
||||||
|
const s = await store("syncQueue", "readwrite");
|
||||||
|
const entry = await reqToPromise<QueueEntry | undefined>(s.get(queueId));
|
||||||
|
if (!entry) return;
|
||||||
|
await reqToPromise(s.put({ ...entry, ...patch }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearQueue(): Promise<void> {
|
||||||
|
const s = await store("syncQueue", "readwrite");
|
||||||
|
await reqToPromise(s.clear());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQueueSize(): Promise<number> {
|
||||||
|
const s = await store("syncQueue", "readonly");
|
||||||
|
return reqToPromise<number>(s.count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Full wipe (on logout) ─── */
|
||||||
|
export async function clearAll(): Promise<void> {
|
||||||
|
const d = await open();
|
||||||
|
const stores: StoreName[] = ["notebooks", "notes", "noteContents", "syncQueue", "meta", "attachments"];
|
||||||
|
const t = d.transaction(stores, "readwrite");
|
||||||
|
for (const s of stores) t.objectStore(s).clear();
|
||||||
|
return txDone(t);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
import * as search from "./search";
|
||||||
|
import { serializeNoteContent } from "./editor/content";
|
||||||
|
|
||||||
|
const body = (text: string) =>
|
||||||
|
serializeNoteContent({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const seed = () => {
|
||||||
|
search.removeEntry("a");
|
||||||
|
search.removeEntry("b");
|
||||||
|
search.removeEntry("c");
|
||||||
|
search.setEntry("a", "Haushalt", body("Der Kühlschrank braucht eine neue Dichtung."));
|
||||||
|
search.setEntry("b", "Finanzen", body("Steuererklärung abgeben, Belege liegen im Ordner."));
|
||||||
|
search.setEntry("c", "Dichtung", body("Nur der Titel enthält das Wort."));
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(seed);
|
||||||
|
|
||||||
|
describe("search", () => {
|
||||||
|
it("returns null for an empty query, meaning 'no search'", () => {
|
||||||
|
expect(search.search("")).toBeNull();
|
||||||
|
expect(search.search(" ")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds a word that appears only in the body", () => {
|
||||||
|
const hits = search.search("Belege");
|
||||||
|
expect(hits?.map((h) => h.id)).toEqual(["b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks a title match above a body match", () => {
|
||||||
|
const hits = search.search("Dichtung");
|
||||||
|
expect(hits?.map((h) => h.id)).toEqual(["c", "a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires every term to match", () => {
|
||||||
|
expect(search.search("Belege Ordner")?.map((h) => h.id)).toEqual(["b"]);
|
||||||
|
expect(search.search("Belege Kühlschrank")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores case", () => {
|
||||||
|
expect(search.search("BELEGE")?.map((h) => h.id)).toEqual(["b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores diacritics, so an ASCII keyboard still finds umlauts", () => {
|
||||||
|
expect(search.search("kuhlschrank")?.map((h) => h.id)).toEqual(["a"]);
|
||||||
|
expect(search.search("Steuererklarung")?.map((h) => h.id)).toEqual(["b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a snippet around the match", () => {
|
||||||
|
const hit = search.search("Dichtung")?.find((h) => h.id === "a");
|
||||||
|
expect(hit?.snippet).toMatch(/Dichtung/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects the result limit", () => {
|
||||||
|
for (let i = 0; i < 30; i++) search.setEntry(`x${i}`, `Note ${i}`, body("common word"));
|
||||||
|
expect(search.search("common", { limit: 5 })?.length).toBe(5);
|
||||||
|
for (let i = 0; i < 30; i++) search.removeEntry(`x${i}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops matching a note once it is removed from the index", () => {
|
||||||
|
expect(search.search("Belege")?.length).toBe(1);
|
||||||
|
search.removeEntry("b");
|
||||||
|
expect(search.search("Belege")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reflects an updated body immediately", () => {
|
||||||
|
search.setEntry("a", "Haushalt", body("Völlig anderer Inhalt jetzt."));
|
||||||
|
expect(search.search("Kühlschrank")).toEqual([]);
|
||||||
|
expect(search.search("anderer")?.map((h) => h.id)).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("contentToText", () => {
|
||||||
|
it("reads a v2 document", () => {
|
||||||
|
expect(search.contentToText(body("hello world"))).toBe("hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a v1 body, HTML and task list alike", () => {
|
||||||
|
const v1 = JSON.stringify({
|
||||||
|
html: "<p>legacy <b>text</b></p>",
|
||||||
|
tasks: [{ id: "t", text: "buy milk", done: false }],
|
||||||
|
});
|
||||||
|
const text = search.contentToText(v1);
|
||||||
|
expect(text).toContain("legacy");
|
||||||
|
expect(text).toContain("buy milk");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not execute or leak markup from a v1 body", () => {
|
||||||
|
const text = search.contentToText(JSON.stringify({ html: "<p>safe</p><script>bad()</script>" }));
|
||||||
|
expect(text).toContain("safe");
|
||||||
|
expect(text).not.toContain("<script>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives content that is not valid JSON", () => {
|
||||||
|
expect(() => search.contentToText("<p>bare html</p>")).not.toThrow();
|
||||||
|
expect(search.contentToText("<p>bare html</p>")).toContain("bare html");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty string for empty input", () => {
|
||||||
|
expect(search.contentToText(null)).toBe("");
|
||||||
|
expect(search.contentToText(undefined)).toBe("");
|
||||||
|
expect(search.contentToText("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Local full-text search.
|
||||||
|
*
|
||||||
|
* The server stores note bodies encrypted and deliberately keeps no plaintext
|
||||||
|
* index — so searching has to happen on the client, over the copy already in
|
||||||
|
* IndexedDB. That also means search keeps working offline.
|
||||||
|
*
|
||||||
|
* The index is a plain in-memory map. For a personal note collection that is
|
||||||
|
* both fast enough and far simpler than a real inverted index: matching a few
|
||||||
|
* thousand strings costs well under a frame.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as db from "./offlineStore";
|
||||||
|
import { docToText } from "./editor/content";
|
||||||
|
import type { SearchHit } from "./types";
|
||||||
|
|
||||||
|
interface IndexEntry {
|
||||||
|
/** Normalised title, for matching. */
|
||||||
|
title: string;
|
||||||
|
/** Normalised body text, for matching. */
|
||||||
|
text: string;
|
||||||
|
/** Original body text, for building the snippet shown to the user. */
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _index = new Map<string, IndexEntry>();
|
||||||
|
|
||||||
|
/** Lowercases and strips diacritics, so `kuhlschrank` matches `Kühlschrank`. */
|
||||||
|
const normalize = (s: unknown): string =>
|
||||||
|
String(s ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/\p{Diacritic}/gu, "");
|
||||||
|
|
||||||
|
/** Plain text of a stored note body, whatever format it is in. */
|
||||||
|
export function contentToText(raw: string | null | undefined): string {
|
||||||
|
if (!raw) return "";
|
||||||
|
let parsed: unknown = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = parsed as { v?: number; doc?: unknown; html?: unknown; tasks?: unknown } | null;
|
||||||
|
if (obj && obj.v === 2 && obj.doc) return docToText(obj.doc, 20000);
|
||||||
|
|
||||||
|
// v1 — HTML plus a detached task list.
|
||||||
|
const html = typeof obj?.html === "string" ? obj.html : raw;
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.innerHTML = html;
|
||||||
|
const tasks = Array.isArray(obj?.tasks)
|
||||||
|
? (obj.tasks as Array<{ text?: string }>).map((t) => t.text ?? "").join(" ")
|
||||||
|
: "";
|
||||||
|
return `${el.textContent || ""} ${tasks}`.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setEntry(id: string, title: string, content?: string | null): void {
|
||||||
|
const raw = contentToText(content);
|
||||||
|
_index.set(id, { title: normalize(title), text: normalize(raw), raw });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeEntry(id: string): void {
|
||||||
|
_index.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function size(): number {
|
||||||
|
return _index.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the index from whatever is cached locally. */
|
||||||
|
export async function rebuild(): Promise<number> {
|
||||||
|
const notes = await db.getAllNotes();
|
||||||
|
_index.clear();
|
||||||
|
for (const n of notes) {
|
||||||
|
const c = await db.getNoteContent(n.id);
|
||||||
|
setEntry(n.id, n.title, c?.content);
|
||||||
|
}
|
||||||
|
return _index.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ranked matches for a query.
|
||||||
|
*
|
||||||
|
* All terms must appear (AND). Title matches outrank body matches so that
|
||||||
|
* searching for a note by name still puts that note first. Returns `null` for
|
||||||
|
* an empty query, which callers read as "no search, show the plain list".
|
||||||
|
*/
|
||||||
|
export function search(query: string, { limit = 200 }: { limit?: number } = {}): SearchHit[] | null {
|
||||||
|
const terms = normalize(query).split(/\s+/).filter(Boolean);
|
||||||
|
if (terms.length === 0) return null;
|
||||||
|
|
||||||
|
const hits: Array<{ id: string; score: number; entry: IndexEntry }> = [];
|
||||||
|
for (const [id, entry] of _index) {
|
||||||
|
let score = 0;
|
||||||
|
let matchedAll = true;
|
||||||
|
for (const term of terms) {
|
||||||
|
const inTitle = entry.title.includes(term);
|
||||||
|
const inText = entry.text.includes(term);
|
||||||
|
if (!inTitle && !inText) {
|
||||||
|
matchedAll = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (inTitle) score += entry.title.startsWith(term) ? 12 : 8;
|
||||||
|
if (inText) score += 1;
|
||||||
|
}
|
||||||
|
if (matchedAll) hits.push({ id, score, entry });
|
||||||
|
}
|
||||||
|
|
||||||
|
hits.sort((a, b) => b.score - a.score);
|
||||||
|
return hits.slice(0, limit).map(({ id, score, entry }) => ({
|
||||||
|
id,
|
||||||
|
score,
|
||||||
|
snippet: snippetFor(entry, terms),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A short excerpt around the first matching term, for the results list. */
|
||||||
|
function snippetFor(entry: IndexEntry, terms: string[], radius = 60): string {
|
||||||
|
const text = entry.raw;
|
||||||
|
if (!text) return "";
|
||||||
|
const haystack = normalize(text);
|
||||||
|
let at = -1;
|
||||||
|
for (const term of terms) {
|
||||||
|
const i = haystack.indexOf(term);
|
||||||
|
if (i !== -1 && (at === -1 || i < at)) at = i;
|
||||||
|
}
|
||||||
|
if (at === -1) return text.slice(0, radius * 2).trim();
|
||||||
|
|
||||||
|
const start = Math.max(0, at - radius);
|
||||||
|
const end = Math.min(text.length, at + radius);
|
||||||
|
return `${start > 0 ? "…" : ""}${text.slice(start, end).trim()}${end < text.length ? "…" : ""}`;
|
||||||
|
}
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
/**
|
|
||||||
* InkNote Sync Engine v2 — online-first, offline-fallback
|
|
||||||
*
|
|
||||||
* When ONLINE: Server call → cache to IndexedDB (server IDs everywhere)
|
|
||||||
* When OFFLINE: IndexedDB + queue → flush on reconnect
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as db from './offlineStore.js';
|
|
||||||
const REQUEST_TIMEOUT_MS = 12000;
|
|
||||||
|
|
||||||
const DEVICE_ID = (() => {
|
|
||||||
try {
|
|
||||||
let d = localStorage.getItem('inknote_device');
|
|
||||||
if (!d) { d = 'dev_' + Math.random().toString(36).slice(2, 10); localStorage.setItem('inknote_device', d); }
|
|
||||||
return d;
|
|
||||||
} catch { return 'dev_' + Math.random().toString(36).slice(2, 10); }
|
|
||||||
})();
|
|
||||||
|
|
||||||
/* ─── API helper ─── */
|
|
||||||
const api = {
|
|
||||||
base: '/api',
|
|
||||||
token: null,
|
|
||||||
async req(method, path, body) {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
||||||
const opts = { method, headers: { 'Content-Type': 'application/json', 'X-Device-Id': DEVICE_ID }, signal: controller.signal };
|
|
||||||
if (this.token) opts.headers['Authorization'] = 'Bearer ' + this.token;
|
|
||||||
if (body) opts.body = JSON.stringify(body);
|
|
||||||
try {
|
|
||||||
let r;
|
|
||||||
try {
|
|
||||||
r = await fetch(this.base + path, opts);
|
|
||||||
} catch (err) {
|
|
||||||
if (err?.name === 'AbortError') throw new Error('TIMEOUT');
|
|
||||||
throw new Error('NETWORK');
|
|
||||||
}
|
|
||||||
if (r.status === 401) { this.token = null; try { localStorage.removeItem('inknote_token'); } catch {} throw new Error('AUTH'); }
|
|
||||||
let data = {};
|
|
||||||
try { data = await r.json(); } catch {}
|
|
||||||
if (!r.ok) throw new Error(data.error || 'Request failed');
|
|
||||||
return data;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
get(p) { return this.req('GET', p); },
|
|
||||||
post(p, b) { return this.req('POST', p, b); },
|
|
||||||
put(p, b) { return this.req('PUT', p, b); },
|
|
||||||
del(p) { return this.req('DELETE', p); },
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ─── Online status ─── */
|
|
||||||
let _online = navigator.onLine;
|
|
||||||
window.addEventListener('online', () => { _online = true; notifyStatus('syncing'); flushQueue().then(() => fullSync()).catch(() => {}); });
|
|
||||||
window.addEventListener('offline', () => { _online = false; notifyStatus('offline'); });
|
|
||||||
export function isOnline() { return _online; }
|
|
||||||
|
|
||||||
/* ─── Status ─── */
|
|
||||||
let _syncStatus = 'synced';
|
|
||||||
let _statusCbs = [];
|
|
||||||
let _dataCbs = [];
|
|
||||||
function notifyStatus(s) { _syncStatus = s; _statusCbs.forEach(fn => fn(s)); }
|
|
||||||
export function onSyncStatus(fn) { _statusCbs.push(fn); fn(_syncStatus); return () => { _statusCbs = _statusCbs.filter(f => f !== fn); }; }
|
|
||||||
function notifyData(t, d) { _dataCbs.forEach(fn => fn(t, d)); }
|
|
||||||
export function onDataChange(fn) { _dataCbs.push(fn); return () => { _dataCbs = _dataCbs.filter(f => f !== fn); }; }
|
|
||||||
|
|
||||||
/* ─── Auth ─── */
|
|
||||||
export async function login(email, password) {
|
|
||||||
const data = await api.post('/auth/login', { email, password });
|
|
||||||
api.token = data.token;
|
|
||||||
await db.setMeta('token', data.token);
|
|
||||||
await db.setMeta('user', data.user);
|
|
||||||
try { localStorage.setItem('inknote_token', data.token); } catch {}
|
|
||||||
return data.user;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function register(email, password, displayName) {
|
|
||||||
const data = await api.post('/auth/register', { email, password, displayName });
|
|
||||||
api.token = data.token;
|
|
||||||
await db.setMeta('token', data.token);
|
|
||||||
await db.setMeta('user', data.user);
|
|
||||||
try { localStorage.setItem('inknote_token', data.token); } catch {}
|
|
||||||
return data.user;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function restoreSession() {
|
|
||||||
let token = await db.getMeta('token');
|
|
||||||
if (!token) { try { token = localStorage.getItem('inknote_token'); } catch {} }
|
|
||||||
if (!token) return null;
|
|
||||||
api.token = token;
|
|
||||||
if (_online) {
|
|
||||||
try { const u = await api.get('/auth/me'); await db.setMeta('user', u); return u; }
|
|
||||||
catch (e) { if (e.message === 'AUTH') { await logout(); return null; } }
|
|
||||||
}
|
|
||||||
return await db.getMeta('user') || {};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout() {
|
|
||||||
api.token = null;
|
|
||||||
try { localStorage.removeItem('inknote_token'); } catch {}
|
|
||||||
await db.clearAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Account ─── */
|
|
||||||
export async function updateProfile(displayName) {
|
|
||||||
const u = await api.put('/auth/profile', { displayName });
|
|
||||||
await db.setMeta('user', u);
|
|
||||||
return u;
|
|
||||||
}
|
|
||||||
export async function updateEmail(newEmail, password) {
|
|
||||||
const u = await api.put('/auth/email', { newEmail, password });
|
|
||||||
await db.setMeta('user', u);
|
|
||||||
return u;
|
|
||||||
}
|
|
||||||
export async function updatePassword(currentPassword, newPassword) {
|
|
||||||
await api.put('/auth/password', { currentPassword, newPassword });
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Full sync ─── */
|
|
||||||
export async function fullSync() {
|
|
||||||
if (!_online || !api.token) return;
|
|
||||||
notifyStatus('syncing');
|
|
||||||
try {
|
|
||||||
await flushQueue();
|
|
||||||
const serverNbs = await api.get('/notebooks');
|
|
||||||
const nbs = serverNbs.map(nb => ({ id: nb.id, name: nb.name, cover: nb.cover_index || 0, sortOrder: nb.sort_order || 0 }));
|
|
||||||
await db.clearNotebooks();
|
|
||||||
if (nbs.length > 0) await db.putNotebooks(nbs);
|
|
||||||
|
|
||||||
const serverNotes = await api.get('/notes');
|
|
||||||
const active = serverNotes.map(n => ({ id: n.id, title: n.title, folderId: n.notebook_id, starred: !!n.starred, trashed: false, createdAt: n.created_at, updatedAt: n.updated_at }));
|
|
||||||
let trashed = [];
|
|
||||||
try {
|
|
||||||
const st = await api.get('/notes/trash');
|
|
||||||
trashed = st.map(n => ({ id: n.id, title: n.title, folderId: n.notebook_id, starred: !!n.starred, trashed: true, createdAt: n.created_at, updatedAt: n.updated_at || n.deleted_at }));
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
await db.clearNotes();
|
|
||||||
const all = [...active, ...trashed];
|
|
||||||
if (all.length > 0) await db.putNotes(all);
|
|
||||||
notifyStatus('synced');
|
|
||||||
notifyData('fullSync', null);
|
|
||||||
} catch (e) {
|
|
||||||
if (e.message === 'AUTH') throw e;
|
|
||||||
notifyStatus('error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Queue ─── */
|
|
||||||
let _flushing = false;
|
|
||||||
async function flushQueue() {
|
|
||||||
if (_flushing || !_online || !api.token) return;
|
|
||||||
_flushing = true;
|
|
||||||
try {
|
|
||||||
const pending = await db.peekQueue(100);
|
|
||||||
for (const entry of pending) {
|
|
||||||
try { await processEntry(entry); } catch (e) {
|
|
||||||
if (e.message === 'AUTH') { _flushing = false; throw e; }
|
|
||||||
if (!_online) break;
|
|
||||||
console.warn('[sync] skip failed entry:', entry.action, e.message);
|
|
||||||
}
|
|
||||||
await db.dequeue(entry.queueId);
|
|
||||||
}
|
|
||||||
} catch (e) { if (e.message === 'AUTH') throw e; }
|
|
||||||
_flushing = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processEntry(entry) {
|
|
||||||
const d = entry.data;
|
|
||||||
switch (entry.action) {
|
|
||||||
case 'createNotebook': {
|
|
||||||
const r = await api.post('/notebooks', { name: d.name, coverIndex: d.cover });
|
|
||||||
if (r.id !== d.localId) {
|
|
||||||
await db.deleteNotebook(d.localId);
|
|
||||||
await db.putNotebook({ id: r.id, name: d.name, cover: d.cover, sortOrder: d.sortOrder || 0 });
|
|
||||||
const notes = await db.getAllNotes();
|
|
||||||
for (const n of notes) { if (n.folderId === d.localId) { n.folderId = r.id; await db.putNote(n); } }
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'updateNotebook': await api.put(`/notebooks/${d.id}`, { name: d.name, coverIndex: d.cover, sortOrder: d.sortOrder }); break;
|
|
||||||
case 'deleteNotebook': await api.del(`/notebooks/${d.id}`); break;
|
|
||||||
case 'createNote': {
|
|
||||||
const content = await db.getNoteContent(d.localId);
|
|
||||||
const r = await api.post('/notes', { title: d.title, notebookId: d.folderId, content: content?.content || '' });
|
|
||||||
if (r.id !== d.localId) {
|
|
||||||
const old = await db.getNote(d.localId);
|
|
||||||
const oldC = await db.getNoteContent(d.localId);
|
|
||||||
await db.deleteNote(d.localId);
|
|
||||||
if (old) { delete old._pendingCreate; old.id = r.id; await db.putNote(old); }
|
|
||||||
if (oldC) await db.putNoteContent(r.id, oldC.content, oldC.drawingData);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'updateNote': await api.put(`/notes/${d.id}`, d.updates); break;
|
|
||||||
case 'updateDrawing': await api.put(`/notes/${d.id}/drawing`, { drawingData: d.drawingData }); break;
|
|
||||||
case 'trashNote': await api.del(`/notes/${d.id}`); break;
|
|
||||||
case 'restoreNote': await api.put(`/notes/${d.id}/restore`, {}); break;
|
|
||||||
case 'permanentDelete': await api.del(`/notes/${d.id}/permanent`); break;
|
|
||||||
case 'emptyTrash': await api.del('/notes/trash/empty'); break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Debounce ─── */
|
|
||||||
const _timers = {};
|
|
||||||
function debounce(key, fn, ms = 800) { if (_timers[key]) clearTimeout(_timers[key]); _timers[key] = setTimeout(fn, ms); }
|
|
||||||
|
|
||||||
/* ════════ PUBLIC DATA API ════════ */
|
|
||||||
|
|
||||||
export async function getNotebooks() { return db.getAllNotebooks(); }
|
|
||||||
|
|
||||||
export async function createNotebook({ name, cover }) {
|
|
||||||
if (_online && api.token) {
|
|
||||||
try {
|
|
||||||
const r = await api.post('/notebooks', { name, coverIndex: cover });
|
|
||||||
const nb = { id: r.id, name, cover, sortOrder: (await db.getAllNotebooks()).length };
|
|
||||||
await db.putNotebook(nb); notifyData('notebook', nb); return nb;
|
|
||||||
} catch (e) { if (e.message === 'AUTH') throw e; }
|
|
||||||
}
|
|
||||||
const lid = 'nb_' + Math.random().toString(36).slice(2, 10);
|
|
||||||
const nb = { id: lid, name, cover, sortOrder: (await db.getAllNotebooks()).length };
|
|
||||||
await db.putNotebook(nb);
|
|
||||||
await db.enqueue({ action: 'createNotebook', data: { localId: lid, name, cover, sortOrder: nb.sortOrder } });
|
|
||||||
notifyData('notebook', nb); notifyStatus('pending'); return nb;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateNotebook(id, updates) {
|
|
||||||
const nbs = await db.getAllNotebooks(); const nb = nbs.find(n => n.id === id); if (!nb) return;
|
|
||||||
const upd = { ...nb, ...updates }; await db.putNotebook(upd); notifyData('notebook', upd);
|
|
||||||
if (_online && api.token) { try { await api.put(`/notebooks/${id}`, { name: updates.name, coverIndex: updates.cover, sortOrder: updates.sortOrder }); return; } catch (e) { if (e.message === 'AUTH') throw e; } }
|
|
||||||
await db.enqueue({ action: 'updateNotebook', data: { id, name: updates.name, cover: updates.cover, sortOrder: updates.sortOrder } }); notifyStatus('pending');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteNotebookLocal(id) {
|
|
||||||
const notes = await db.getAllNotes();
|
|
||||||
for (const n of notes) { if (n.folderId === id && !n.trashed) { n.trashed = true; await db.putNote(n); } }
|
|
||||||
await db.deleteNotebook(id); notifyData('notebook', { id, deleted: true });
|
|
||||||
if (_online && api.token) { try { await api.del(`/notebooks/${id}`); return; } catch (e) { if (e.message === 'AUTH') throw e; } }
|
|
||||||
await db.enqueue({ action: 'deleteNotebook', data: { id } }); notifyStatus('pending');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reorderNotebooks(orderedIds) {
|
|
||||||
const nbs = await db.getAllNotebooks();
|
|
||||||
for (let i = 0; i < orderedIds.length; i++) { const nb = nbs.find(n => n.id === orderedIds[i]); if (nb) { nb.sortOrder = i; await db.putNotebook(nb); } }
|
|
||||||
notifyData('notebooks', null);
|
|
||||||
if (_online && api.token) { for (let i = 0; i < orderedIds.length; i++) { api.put(`/notebooks/${orderedIds[i]}`, { sortOrder: i }).catch(() => {}); } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getNotes() { return db.getAllNotes(); }
|
|
||||||
|
|
||||||
export async function loadNoteContent(id, opts = {}) {
|
|
||||||
const forceRemote = !!opts.forceRemote;
|
|
||||||
|
|
||||||
// Local-first for instant editor open and resilience against slow/hung requests.
|
|
||||||
if (!forceRemote) {
|
|
||||||
const local = await db.getNoteContent(id);
|
|
||||||
if (local) {
|
|
||||||
if (_online && api.token) {
|
|
||||||
api.get(`/notes/${id}`).then(async (d) => {
|
|
||||||
const c = { content: d.content || '', drawingData: d.drawingData || null };
|
|
||||||
await db.putNoteContent(id, c.content, c.drawingData);
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
return { content: local.content || '', drawingData: local.drawingData || null };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No local cache (or force remote): fetch server, then fallback to cache.
|
|
||||||
if (_online && api.token) {
|
|
||||||
try {
|
|
||||||
const d = await api.get(`/notes/${id}`);
|
|
||||||
const c = { content: d.content || '', drawingData: d.drawingData || null };
|
|
||||||
await db.putNoteContent(id, c.content, c.drawingData);
|
|
||||||
return c;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
const local = await db.getNoteContent(id);
|
|
||||||
if (local) return { content: local.content || '', drawingData: local.drawingData || null };
|
|
||||||
return { content: '', drawingData: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createNote(folderId) {
|
|
||||||
const ts = new Date().toISOString();
|
|
||||||
if (_online && api.token) {
|
|
||||||
try {
|
|
||||||
const r = await api.post('/notes', { title: 'Untitled Note', notebookId: folderId, content: '' });
|
|
||||||
const note = { id: r.id, title: 'Untitled Note', folderId, starred: false, trashed: false, createdAt: ts, updatedAt: ts };
|
|
||||||
await db.putNote(note); await db.putNoteContent(r.id, '', null); notifyData('note', note); return note;
|
|
||||||
} catch (e) { if (e.message === 'AUTH') throw e; }
|
|
||||||
}
|
|
||||||
const lid = 'local_' + Math.random().toString(36).slice(2, 10);
|
|
||||||
const note = { id: lid, title: 'Untitled Note', folderId, starred: false, trashed: false, createdAt: ts, updatedAt: ts, _pendingCreate: true };
|
|
||||||
await db.putNote(note); await db.putNoteContent(lid, '', null);
|
|
||||||
await db.enqueue({ action: 'createNote', data: { localId: lid, title: 'Untitled Note', folderId } });
|
|
||||||
notifyData('note', note); notifyStatus('pending'); return note;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateNoteTitle(id, title) {
|
|
||||||
const note = await db.getNote(id); if (!note) return;
|
|
||||||
note.title = title; note.updatedAt = new Date().toISOString(); await db.putNote(note); notifyData('note', note);
|
|
||||||
debounce(`title_${id}`, async () => {
|
|
||||||
if (_online && api.token && !note._pendingCreate) { try { await api.put(`/notes/${id}`, { title }); notifyStatus('synced'); return; } catch {} }
|
|
||||||
await db.enqueue({ action: 'updateNote', data: { id, updates: { title } } }); notifyStatus('pending');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateNoteContent(id, content) {
|
|
||||||
const ex = await db.getNoteContent(id); await db.putNoteContent(id, content, ex?.drawingData || null);
|
|
||||||
const note = await db.getNote(id); if (note) { note.updatedAt = new Date().toISOString(); await db.putNote(note); }
|
|
||||||
notifyData('noteContent', { id });
|
|
||||||
debounce(`content_${id}`, async () => {
|
|
||||||
if (_online && api.token && !note?._pendingCreate) { try { await api.put(`/notes/${id}`, { content }); notifyStatus('synced'); return; } catch {} }
|
|
||||||
await db.enqueue({ action: 'updateNote', data: { id, updates: { content } } }); notifyStatus('pending');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateNoteDrawing(id, drawingData) {
|
|
||||||
const ex = await db.getNoteContent(id); await db.putNoteContent(id, ex?.content || '', drawingData);
|
|
||||||
notifyData('noteContent', { id });
|
|
||||||
debounce(`drawing_${id}`, async () => {
|
|
||||||
const note = await db.getNote(id);
|
|
||||||
if (_online && api.token && !note?._pendingCreate) { try { await api.put(`/notes/${id}/drawing`, { drawingData }); notifyStatus('synced'); return; } catch {} }
|
|
||||||
await db.enqueue({ action: 'updateDrawing', data: { id, drawingData } }); notifyStatus('pending');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function toggleNoteStar(id) {
|
|
||||||
const note = await db.getNote(id); if (!note) return false;
|
|
||||||
note.starred = !note.starred; await db.putNote(note); notifyData('note', note);
|
|
||||||
if (_online && api.token && !note._pendingCreate) { api.put(`/notes/${id}`, { starred: note.starred }).catch(() => {}); }
|
|
||||||
else { await db.enqueue({ action: 'updateNote', data: { id, updates: { starred: note.starred } } }); notifyStatus('pending'); }
|
|
||||||
return note.starred;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function moveNote(id, fid) {
|
|
||||||
const note = await db.getNote(id); if (!note) return;
|
|
||||||
note.folderId = fid; note.updatedAt = new Date().toISOString(); await db.putNote(note); notifyData('note', note);
|
|
||||||
if (_online && api.token && !note._pendingCreate) { api.put(`/notes/${id}`, { notebookId: fid }).catch(() => {}); }
|
|
||||||
else { await db.enqueue({ action: 'updateNote', data: { id, updates: { notebookId: fid } } }); notifyStatus('pending'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function trashNote(id) {
|
|
||||||
const note = await db.getNote(id); if (!note) return;
|
|
||||||
note.trashed = true; note.updatedAt = new Date().toISOString(); await db.putNote(note); notifyData('note', note);
|
|
||||||
if (_online && api.token && !note._pendingCreate) { try { await api.del(`/notes/${id}`); } catch {} }
|
|
||||||
else { await db.enqueue({ action: 'trashNote', data: { id } }); notifyStatus('pending'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function restoreNoteLocal(id) {
|
|
||||||
const note = await db.getNote(id); if (!note) return;
|
|
||||||
note.trashed = false; note.updatedAt = new Date().toISOString(); await db.putNote(note); notifyData('note', note);
|
|
||||||
if (_online && api.token) { try { await api.put(`/notes/${id}/restore`, {}); } catch {} }
|
|
||||||
else { await db.enqueue({ action: 'restoreNote', data: { id } }); notifyStatus('pending'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function permanentDeleteNoteLocal(id) {
|
|
||||||
await db.deleteNote(id); notifyData('note', { id, deleted: true });
|
|
||||||
if (_online && api.token) { try { await api.del(`/notes/${id}/permanent`); } catch {} }
|
|
||||||
else { await db.enqueue({ action: 'permanentDelete', data: { id } }); notifyStatus('pending'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function emptyTrashLocal() {
|
|
||||||
const all = await db.getAllNotes(); for (const n of all) { if (n.trashed) await db.deleteNote(n.id); }
|
|
||||||
notifyData('notes', null);
|
|
||||||
if (_online && api.token) { try { await api.del('/notes/trash/empty'); } catch {} }
|
|
||||||
else { await db.enqueue({ action: 'emptyTrash', data: {} }); notifyStatus('pending'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DEVICE_ID };
|
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
import * as db from "./offlineStore";
|
||||||
|
import * as sync from "./syncEngine";
|
||||||
|
import type { NoteMeta, Notebook } from "./types";
|
||||||
|
|
||||||
|
/* A stub transport. `req()` only ever touches status, ok and json(), so a plain
|
||||||
|
* object is enough and keeps the tests free of undici/jsdom differences. */
|
||||||
|
type Reply = { status: number; body?: unknown };
|
||||||
|
const reply = ({ status, body = {} }: Reply) => ({
|
||||||
|
status,
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
json: async () => body,
|
||||||
|
});
|
||||||
|
|
||||||
|
let calls: Array<{ method: string; url: string; body: unknown }> = [];
|
||||||
|
let handler: (method: string, url: string, body: unknown) => Reply | Promise<Reply>;
|
||||||
|
|
||||||
|
function installFetch() {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (url: string, opts: RequestInit = {}) => {
|
||||||
|
const method = opts.method || "GET";
|
||||||
|
const body = opts.body ? JSON.parse(opts.body as string) : undefined;
|
||||||
|
calls.push({ method, url, body });
|
||||||
|
return reply(await handler(method, url, body));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const succeed = () => {
|
||||||
|
handler = () => ({ status: 200, body: { id: "server-id" } });
|
||||||
|
};
|
||||||
|
const failWith = (status: number, body: unknown = { error: "nope" }) => {
|
||||||
|
handler = () => ({ status, body });
|
||||||
|
};
|
||||||
|
const failTransport = () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => {
|
||||||
|
throw new TypeError("Failed to fetch");
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const note = (id: string, over: Partial<NoteMeta> = {}): NoteMeta => ({
|
||||||
|
id,
|
||||||
|
title: `Note ${id}`,
|
||||||
|
folderId: "nb1",
|
||||||
|
starred: false,
|
||||||
|
trashed: false,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const notebook = (id: string, over: Partial<Notebook> = {}): Notebook => ({
|
||||||
|
id,
|
||||||
|
name: `NB ${id}`,
|
||||||
|
cover: 0,
|
||||||
|
sortOrder: 0,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db.clearAll();
|
||||||
|
calls = [];
|
||||||
|
succeed();
|
||||||
|
installFetch();
|
||||||
|
sync.__testing.setToken("test-token");
|
||||||
|
sync.__testing.setOnline(true);
|
||||||
|
sync.__testing.resetPendingWrites();
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restored here as well as in each test: a timed-out test never runs its own
|
||||||
|
// cleanup, and leaked fake timers would hang every hook after it.
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Fakes only setTimeout, so IndexedDB keeps working while the debounce does not. */
|
||||||
|
const withFakeDebounce = async (fn: () => Promise<void>) => {
|
||||||
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("flushQueue", () => {
|
||||||
|
it("removes an entry once the server accepted it", async () => {
|
||||||
|
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
|
||||||
|
await sync.__testing.flushQueue();
|
||||||
|
expect(await db.getQueueSize()).toBe(0);
|
||||||
|
expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual(["DELETE /api/notes/n1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a failed entry queued and counts the attempt — losing it silently ate offline edits", async () => {
|
||||||
|
failWith(500);
|
||||||
|
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
|
||||||
|
await sync.__testing.flushQueue();
|
||||||
|
|
||||||
|
const [entry] = await db.peekQueue();
|
||||||
|
expect(entry).toBeDefined();
|
||||||
|
expect(entry!.attempts).toBe(1);
|
||||||
|
expect(entry!.action).toBe("trashNote");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries across flushes and gives up only after repeated failures", async () => {
|
||||||
|
failWith(500);
|
||||||
|
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
|
||||||
|
|
||||||
|
for (let i = 1; i <= 4; i++) {
|
||||||
|
await sync.__testing.flushQueue();
|
||||||
|
const [entry] = await db.peekQueue();
|
||||||
|
expect(entry!.attempts).toBe(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fifth failure hits the cap; the entry is dropped so it cannot wedge the queue.
|
||||||
|
await sync.__testing.flushQueue();
|
||||||
|
expect(await db.getQueueSize()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops at the first transport failure and keeps everything for later", async () => {
|
||||||
|
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
|
||||||
|
await db.enqueue({ action: "trashNote", data: { id: "n2" } });
|
||||||
|
failTransport();
|
||||||
|
|
||||||
|
await sync.__testing.flushQueue();
|
||||||
|
|
||||||
|
expect(await db.getQueueSize()).toBe(2);
|
||||||
|
const entries = await db.peekQueue();
|
||||||
|
// Nothing is counted as a failed attempt: being offline is not the entry's fault.
|
||||||
|
expect(entries.every((e) => e.attempts === undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates an auth failure and leaves the queue untouched", async () => {
|
||||||
|
failWith(401);
|
||||||
|
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
|
||||||
|
|
||||||
|
await expect(sync.__testing.flushQueue()).rejects.toThrow("AUTH");
|
||||||
|
expect(await db.getQueueSize()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("processes entries in the order they were queued", async () => {
|
||||||
|
await db.enqueue({ action: "trashNote", data: { id: "first" } });
|
||||||
|
await db.enqueue({ action: "trashNote", data: { id: "second" } });
|
||||||
|
await sync.__testing.flushQueue();
|
||||||
|
expect(calls.map((c) => c.url)).toEqual(["/api/notes/first", "/api/notes/second"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing while signed out", async () => {
|
||||||
|
sync.__testing.setToken(null);
|
||||||
|
await db.enqueue({ action: "trashNote", data: { id: "n1" } });
|
||||||
|
await sync.__testing.flushQueue();
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
expect(await db.getQueueSize()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repoints notes at the server id when a queued notebook is finally created", async () => {
|
||||||
|
await db.putNotebook(notebook("nb_local", { _pendingCreate: true }));
|
||||||
|
await db.putNote(note("n1", { folderId: "nb_local" }));
|
||||||
|
await db.enqueue({
|
||||||
|
action: "createNotebook",
|
||||||
|
data: { localId: "nb_local", name: "Later", cover: 0, sortOrder: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.__testing.flushQueue();
|
||||||
|
|
||||||
|
expect((await db.getNote("n1"))?.folderId).toBe("server-id");
|
||||||
|
expect((await db.getAllNotebooks()).map((n) => n.id)).toEqual(["server-id"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fullSync", () => {
|
||||||
|
const serverState = (notes: unknown[], notebooks: unknown[]) => {
|
||||||
|
handler = (_method, url) => {
|
||||||
|
if (url.endsWith("/notebooks")) return { status: 200, body: notebooks };
|
||||||
|
if (url.endsWith("/notes/trash")) return { status: 200, body: [] };
|
||||||
|
if (url.endsWith("/notes")) return { status: 200, body: notes };
|
||||||
|
return { status: 200, body: {} };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it("mirrors the server's notes and notebooks", async () => {
|
||||||
|
serverState(
|
||||||
|
[{ id: "s1", title: "Server note", notebook_id: "snb", created_at: "t", updated_at: "t" }],
|
||||||
|
[{ id: "snb", name: "Server book", cover_index: 2, sort_order: 1 }]
|
||||||
|
);
|
||||||
|
await sync.fullSync();
|
||||||
|
|
||||||
|
expect((await db.getAllNotes()).map((n) => n.id)).toEqual(["s1"]);
|
||||||
|
const [nb] = await db.getAllNotebooks();
|
||||||
|
expect(nb).toMatchObject({ id: "snb", name: "Server book", cover: 2, sortOrder: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps notes created offline that the server has never seen", async () => {
|
||||||
|
await db.putNote(note("local_1", { _pendingCreate: true }));
|
||||||
|
await db.putNote(note("stale_1")); // synced earlier, since deleted elsewhere
|
||||||
|
serverState([], []);
|
||||||
|
|
||||||
|
await sync.fullSync();
|
||||||
|
|
||||||
|
const ids = (await db.getAllNotes()).map((n) => n.id);
|
||||||
|
expect(ids).toContain("local_1");
|
||||||
|
expect(ids).not.toContain("stale_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps notebooks created offline", async () => {
|
||||||
|
await db.putNotebook(notebook("nb_local", { _pendingCreate: true }));
|
||||||
|
serverState([], []);
|
||||||
|
|
||||||
|
await sync.fullSync();
|
||||||
|
|
||||||
|
expect((await db.getAllNotebooks()).map((n) => n.id)).toContain("nb_local");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the local-only flag once the server reports the same id", async () => {
|
||||||
|
await db.putNote(note("s1", { _pendingCreate: true }));
|
||||||
|
serverState(
|
||||||
|
[{ id: "s1", title: "Now on server", notebook_id: null, created_at: "t", updated_at: "t" }],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
await sync.fullSync();
|
||||||
|
|
||||||
|
const [n] = await db.getAllNotes();
|
||||||
|
expect(n!._pendingCreate).toBeUndefined();
|
||||||
|
expect(n!.title).toBe("Now on server");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the local mirror intact when a server read fails midway", async () => {
|
||||||
|
await db.putNote(note("keep"));
|
||||||
|
await db.putNotebook(notebook("keep_nb"));
|
||||||
|
handler = (_method, url) => {
|
||||||
|
if (url.endsWith("/notebooks")) return { status: 200, body: [] };
|
||||||
|
return { status: 500, body: { error: "boom" } }; // the notes read fails
|
||||||
|
};
|
||||||
|
|
||||||
|
await sync.fullSync();
|
||||||
|
|
||||||
|
expect((await db.getAllNotes()).map((n) => n.id)).toEqual(["keep"]);
|
||||||
|
expect((await db.getAllNotebooks()).map((n) => n.id)).toEqual(["keep_nb"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing while signed out", async () => {
|
||||||
|
sync.__testing.setToken(null);
|
||||||
|
await sync.fullSync();
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasPendingWrites", () => {
|
||||||
|
it("is false without an id", () => {
|
||||||
|
expect(sync.hasPendingWrites(null)).toBe(false);
|
||||||
|
expect(sync.hasPendingWrites(undefined)).toBe(false);
|
||||||
|
expect(sync.hasPendingWrites("")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for a note nobody is writing", () => {
|
||||||
|
expect(sync.hasPendingWrites("n1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a write that is in flight — the guard that stops stale reads winning", () => {
|
||||||
|
sync.__testing.beginWrite("n1");
|
||||||
|
expect(sync.hasPendingWrites("n1")).toBe(true);
|
||||||
|
expect(sync.hasPendingWrites("n2")).toBe(false);
|
||||||
|
sync.__testing.endWrite("n1");
|
||||||
|
expect(sync.hasPendingWrites("n1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays true until the last of several overlapping writes finishes", () => {
|
||||||
|
sync.__testing.beginWrite("n1");
|
||||||
|
sync.__testing.beginWrite("n1");
|
||||||
|
sync.__testing.endWrite("n1");
|
||||||
|
expect(sync.hasPendingWrites("n1")).toBe(true);
|
||||||
|
sync.__testing.endWrite("n1");
|
||||||
|
expect(sync.hasPendingWrites("n1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers the debounce window before a save even starts", async () => {
|
||||||
|
await db.putNote(note("n1"));
|
||||||
|
await withFakeDebounce(async () => {
|
||||||
|
await sync.updateNoteContent("n1", "typing…");
|
||||||
|
// The request has not been sent yet, but the note must already count as busy.
|
||||||
|
expect(sync.hasPendingWrites("n1")).toBe(true);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
});
|
||||||
|
expect(sync.hasPendingWrites("n1")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateNoteContent", () => {
|
||||||
|
it("writes locally before anything reaches the network", async () => {
|
||||||
|
await db.putNote(note("n1"));
|
||||||
|
await sync.updateNoteContent("n1", "body text");
|
||||||
|
expect((await db.getNoteContent("n1"))?.content).toBe("body text");
|
||||||
|
expect(calls.length).toBe(0); // still inside the debounce window
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends links along with the body, since the server cannot read it", async () => {
|
||||||
|
await db.putNote(note("n1"));
|
||||||
|
await withFakeDebounce(async () => {
|
||||||
|
await sync.updateNoteContent("n1", "body", ["target-1"]);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
});
|
||||||
|
const put = calls.find((c) => c.method === "PUT");
|
||||||
|
expect(put?.body).toMatchObject({ content: "body", links: ["target-1"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the links field when the caller passes none", async () => {
|
||||||
|
await db.putNote(note("n1"));
|
||||||
|
await withFakeDebounce(async () => {
|
||||||
|
await sync.updateNoteContent("n1", "body");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
});
|
||||||
|
const put = calls.find((c) => c.method === "PUT");
|
||||||
|
expect(put?.body).toEqual({ content: "body" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queues the change when the request fails", async () => {
|
||||||
|
failWith(500);
|
||||||
|
await db.putNote(note("n1"));
|
||||||
|
await withFakeDebounce(async () => {
|
||||||
|
await sync.updateNoteContent("n1", "body");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
});
|
||||||
|
const [entry] = await db.peekQueue();
|
||||||
|
expect(entry?.action).toBe("updateNote");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadNoteContent", () => {
|
||||||
|
it("returns the cached body without waiting for the server", async () => {
|
||||||
|
await db.putNoteContent("n1", "cached", null);
|
||||||
|
const c = await sync.loadNoteContent("n1");
|
||||||
|
expect(c.content).toBe("cached");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not refresh from the server while a write is in flight", async () => {
|
||||||
|
await db.putNoteContent("n1", "cached", null);
|
||||||
|
sync.__testing.beginWrite("n1");
|
||||||
|
await sync.loadNoteContent("n1");
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
sync.__testing.endWrite("n1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the cache when the server read fails", async () => {
|
||||||
|
await db.putNoteContent("n1", "cached", null);
|
||||||
|
failWith(500);
|
||||||
|
const c = await sync.loadNoteContent("n1", { forceRemote: true });
|
||||||
|
expect(c.content).toBe("cached");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty shape for a note with nothing cached and no connection", async () => {
|
||||||
|
sync.__testing.setOnline(false);
|
||||||
|
const c = await sync.loadNoteContent("missing");
|
||||||
|
expect(c).toEqual({ content: "", drawingData: null, drawingStrokes: null, updatedAt: null });
|
||||||
|
sync.__testing.setOnline(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("offline behaviour", () => {
|
||||||
|
beforeEach(() => sync.__testing.setOnline(false));
|
||||||
|
afterEach(() => sync.__testing.setOnline(true));
|
||||||
|
|
||||||
|
it("creates a note locally and queues it", async () => {
|
||||||
|
const n = await sync.createNote("nb1");
|
||||||
|
expect(n._pendingCreate).toBe(true);
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
const [entry] = await db.peekQueue();
|
||||||
|
expect(entry?.action).toBe("createNote");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a notebook locally, flagged so fullSync will not drop it", async () => {
|
||||||
|
const nb = await sync.createNotebook({ name: "Later", cover: 1 });
|
||||||
|
expect(nb._pendingCreate).toBe(true);
|
||||||
|
expect((await db.getAllNotebooks())[0]?._pendingCreate).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queues a star toggle instead of losing it", async () => {
|
||||||
|
await db.putNote(note("n1"));
|
||||||
|
expect(await sync.toggleNoteStar("n1")).toBe(true);
|
||||||
|
const [entry] = await db.peekQueue();
|
||||||
|
expect(entry?.action).toBe("updateNote");
|
||||||
|
expect((await db.getNote("n1"))?.starred).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
|||||||
|
// IndexedDB is not part of jsdom; this gives the store layer a real one to
|
||||||
|
// exercise, so the tests cover actual transactions rather than a hand-rolled mock.
|
||||||
|
import "fake-indexeddb/auto";
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* The shapes that travel between the server, IndexedDB and the UI.
|
||||||
|
*
|
||||||
|
* Most bugs in this app have been shape bugs: a field that is sometimes absent,
|
||||||
|
* a body cached in the wrong format, a stale object adopted over a fresh one.
|
||||||
|
* These types exist to make that class of mistake fail at build time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Drawing ── */
|
||||||
|
|
||||||
|
export type StrokeTool = "pen" | "highlighter" | "eraser";
|
||||||
|
|
||||||
|
export interface Stroke {
|
||||||
|
/** Tool the stroke was drawn with. */
|
||||||
|
t: StrokeTool;
|
||||||
|
/** Colour, `#rrggbb`. */
|
||||||
|
c: string;
|
||||||
|
/** Width in canonical page units, not pixels. */
|
||||||
|
s: number;
|
||||||
|
/** Flat `[x0, y0, x1, y1, …]` in canonical page space. */
|
||||||
|
p: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrokeDoc {
|
||||||
|
v: number;
|
||||||
|
/** Canonical page width the points are expressed in. */
|
||||||
|
w: number;
|
||||||
|
/** Canonical page height. */
|
||||||
|
h: number;
|
||||||
|
strokes: Stroke[];
|
||||||
|
/** Save token — lets the editor recognise its own write coming back. */
|
||||||
|
t?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Notes ── */
|
||||||
|
|
||||||
|
/** Note metadata, as held in the notes list. */
|
||||||
|
export interface NoteMeta {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
folderId: string | null;
|
||||||
|
starred: boolean;
|
||||||
|
trashed: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
/** Set while a note created offline has not reached the server yet. */
|
||||||
|
_pendingCreate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lazily loaded body of a note. */
|
||||||
|
export interface NoteContent {
|
||||||
|
/** Serialised document: `{"v":2,"doc":…}`, or a v1 `{html,tasks}` string. */
|
||||||
|
content: string;
|
||||||
|
/** Legacy raster drawing as a data URL, for notes predating vector strokes. */
|
||||||
|
drawingData: string | null;
|
||||||
|
drawingStrokes: StrokeDoc | null;
|
||||||
|
/** Server revision this copy came from, used to spot a stale cache. */
|
||||||
|
updatedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notebook {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cover: number;
|
||||||
|
sortOrder: number;
|
||||||
|
_pendingCreate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Version history ── */
|
||||||
|
|
||||||
|
export type VersionReason = "edit" | "large-deletion" | "drawing" | "drawing-cleared" | "before-restore";
|
||||||
|
|
||||||
|
export interface NoteVersionSummary {
|
||||||
|
id: number;
|
||||||
|
title: string | null;
|
||||||
|
device_id: string | null;
|
||||||
|
reason: VersionReason | string;
|
||||||
|
created_at: string;
|
||||||
|
content_size: number | null;
|
||||||
|
has_drawing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteVersion {
|
||||||
|
id: number;
|
||||||
|
title: string | null;
|
||||||
|
created_at: string;
|
||||||
|
device_id: string | null;
|
||||||
|
reason: string;
|
||||||
|
content: string;
|
||||||
|
drawingStrokes: StrokeDoc | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Links ── */
|
||||||
|
|
||||||
|
export interface Backlink {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
notebook_id: string | null;
|
||||||
|
notebook_name: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LinkKind = "note" | "notebook";
|
||||||
|
|
||||||
|
export interface LinkTarget {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: LinkKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sync ── */
|
||||||
|
|
||||||
|
export type SyncStatus = "synced" | "syncing" | "pending" | "offline" | "error";
|
||||||
|
|
||||||
|
/** A change waiting to be pushed to the server. */
|
||||||
|
export interface QueueEntry {
|
||||||
|
queueId: number;
|
||||||
|
action: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
timestamp: number;
|
||||||
|
attempts?: number;
|
||||||
|
lastError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Attachments ── */
|
||||||
|
|
||||||
|
export interface AttachmentInfo {
|
||||||
|
id: string;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
deduplicated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Search ── */
|
||||||
|
|
||||||
|
export interface SearchHit {
|
||||||
|
id: string;
|
||||||
|
score: number;
|
||||||
|
/** Excerpt around the first matching term. */
|
||||||
|
snippet: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import * as sync from "../syncEngine";
|
||||||
|
import { I } from "./icons.jsx";
|
||||||
|
|
||||||
|
/* ═══════ ACCOUNT MENU ═══════ */
|
||||||
|
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, dark }) {
|
||||||
|
const [tab, setTab] = useState("profile"); // profile | username | password
|
||||||
|
const [displayName, setDisplayName] = useState(user?.displayName || "");
|
||||||
|
const [newUsername, setNewUsername] = useState(user?.username || "");
|
||||||
|
const [usernamePass, setUsernamePass] = 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 saveUsername = async () => {
|
||||||
|
if (!newUsername || !usernamePass) return;
|
||||||
|
clear();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const u = await sync.updateUsername(newUsername.trim(), usernamePass);
|
||||||
|
onUserUpdate(u);
|
||||||
|
setUsernamePass("");
|
||||||
|
setMsg("Username updated");
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message);
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
const savePassword = async () => {
|
||||||
|
if (!curPass || !newPass) return;
|
||||||
|
if (newPass !== newPass2) {
|
||||||
|
setErr("Passwords don't match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clear();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await sync.updatePassword(curPass, newPass);
|
||||||
|
setCurPass("");
|
||||||
|
setNewPass("");
|
||||||
|
setNewPass2("");
|
||||||
|
setMsg("Password changed");
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message);
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.55)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 2000,
|
||||||
|
backdropFilter: "blur(6px)",
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: T.bg,
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 0,
|
||||||
|
width: 440,
|
||||||
|
maxWidth: "92vw",
|
||||||
|
boxShadow: "0 24px 48px rgba(0,0,0,0.3)",
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "18px 22px",
|
||||||
|
borderBottom: `1px solid ${T.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: T.text,
|
||||||
|
fontFamily: "'Newsreader',Georgia,serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Account
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: T.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.x}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", gap: 4, padding: "12px 22px 0", borderBottom: `1px solid ${T.border}` }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTab("profile");
|
||||||
|
clear();
|
||||||
|
}}
|
||||||
|
style={tbtn(tab === "profile")}
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTab("username");
|
||||||
|
clear();
|
||||||
|
}}
|
||||||
|
style={tbtn(tab === "username")}
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTab("password");
|
||||||
|
clear();
|
||||||
|
}}
|
||||||
|
style={tbtn(tab === "password")}
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: "18px 22px 22px" }}>
|
||||||
|
{msg && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: T.success,
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 10,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: T.success + "15",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{err && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: T.danger,
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 10,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: T.danger + "12",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "profile" && (
|
||||||
|
<>
|
||||||
|
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
|
||||||
|
Display Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
style={inp}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") saveProfile();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={saveProfile}
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "none",
|
||||||
|
background: T.accent,
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: saving ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "username" && (
|
||||||
|
<>
|
||||||
|
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
|
||||||
|
New Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newUsername}
|
||||||
|
onChange={(e) => setNewUsername(e.target.value)}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
style={inp}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 11, color: T.muted, marginTop: -6, marginBottom: 10 }}>
|
||||||
|
3–32 characters · letters, digits, . _ -
|
||||||
|
</div>
|
||||||
|
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={usernamePass}
|
||||||
|
onChange={(e) => setUsernamePass(e.target.value)}
|
||||||
|
style={inp}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") saveUsername();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={saveUsername}
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "none",
|
||||||
|
background: T.accent,
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: saving ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Update Username"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "password" && (
|
||||||
|
<>
|
||||||
|
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
|
||||||
|
Current Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={curPass}
|
||||||
|
onChange={(e) => setCurPass(e.target.value)}
|
||||||
|
style={inp}
|
||||||
|
/>
|
||||||
|
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPass}
|
||||||
|
onChange={(e) => setNewPass(e.target.value)}
|
||||||
|
style={inp}
|
||||||
|
/>
|
||||||
|
<label style={{ fontSize: 12, color: T.muted, marginBottom: 4, display: "block" }}>
|
||||||
|
Confirm New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPass2}
|
||||||
|
onChange={(e) => setNewPass2(e.target.value)}
|
||||||
|
style={inp}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") savePassword();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={savePassword}
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "none",
|
||||||
|
background: T.accent,
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: saving ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Change Password"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: "0 22px 18px" }}>
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${T.danger}33`,
|
||||||
|
background: T.danger + "08",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: T.danger,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.logout} Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import * as sync from "../syncEngine";
|
||||||
|
|
||||||
|
/* ═══════ AUTH SCREEN ═══════ */
|
||||||
|
export function AuthScreen({ onLogin, dark }) {
|
||||||
|
const [isLogin, setIsLogin] = useState(true);
|
||||||
|
const [username, setUsername] = 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 () => {
|
||||||
|
const u = username.trim();
|
||||||
|
if (!u || !pass) {
|
||||||
|
setErr("Username and password required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isLogin && !/^[a-zA-Z0-9._-]{3,32}$/.test(u)) {
|
||||||
|
setErr("Username must be 3-32 chars: letters, digits, . _ -");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setErr("");
|
||||||
|
try {
|
||||||
|
const user = isLogin ? await sync.login(u, pass) : await sync.register(u, pass, name || undefined);
|
||||||
|
onLogin(user);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message === "AUTH" ? "Invalid credentials" : e.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
minHeight: "100vh",
|
||||||
|
background: T.bg,
|
||||||
|
fontFamily: "'DM Sans',sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: T.card,
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 40,
|
||||||
|
width: 400,
|
||||||
|
maxWidth: "90vw",
|
||||||
|
boxShadow: "0 20px 60px rgba(0,0,0,0.15)",
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 28 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 14,
|
||||||
|
background: `linear-gradient(135deg,${T.accent},#a855f7)`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 22,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: T.text,
|
||||||
|
fontFamily: "'Newsreader',Georgia,serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
InkNote
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: T.muted }}>
|
||||||
|
{isLogin ? "Sign in to sync your notes" : "Create your account"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isLogin && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Display name"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
background: T.bg,
|
||||||
|
color: T.text,
|
||||||
|
fontSize: 14,
|
||||||
|
outline: "none",
|
||||||
|
marginBottom: 12,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Username"
|
||||||
|
autoFocus
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="username"
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") submit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={pass}
|
||||||
|
onChange={(e) => setPass(e.target.value)}
|
||||||
|
placeholder="Password"
|
||||||
|
autoComplete={isLogin ? "current-password" : "new-password"}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderRadius: 12,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
background: T.bg,
|
||||||
|
color: T.text,
|
||||||
|
fontSize: 14,
|
||||||
|
outline: "none",
|
||||||
|
marginBottom: 16,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") submit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{err && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: T.danger,
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: T.danger + "12",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "12px",
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "none",
|
||||||
|
background: T.accent,
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: 16,
|
||||||
|
opacity: loading ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "..." : isLogin ? "Sign In" : "Create Account"}
|
||||||
|
</button>
|
||||||
|
<div style={{ textAlign: "center", fontSize: 13, color: T.muted }}>
|
||||||
|
{isLogin ? "Don't have an account? " : "Already have an account? "}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsLogin(!isLogin);
|
||||||
|
setErr("");
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: T.accent,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLogin ? "Sign Up" : "Sign In"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { I } from "./icons.jsx";
|
||||||
|
|
||||||
|
/** Phone-only return arrow; desktop keeps list and editor side by side. */
|
||||||
|
export function BackButton({ T, label = "Back", onClick }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: T.accent,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.back}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
/* ═══════ BACKLINKS ═══════ */
|
||||||
|
/* Notes that point at the one currently open. The reverse direction is not part
|
||||||
|
* of the note itself, so it can only come from the server's link index.
|
||||||
|
*
|
||||||
|
* Sits as a bar under the editor rather than after the page: a page is a fixed
|
||||||
|
* 4:3 sheet, so anything placed below it starts a full screen out of sight. */
|
||||||
|
export function BacklinksPanel({ backlinks, onOpenNote, dark }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
if (!backlinks || backlinks.length === 0) return null;
|
||||||
|
const T = {
|
||||||
|
border: dark ? "#30363d" : "#d0d7de",
|
||||||
|
muted: dark ? "#8b949e" : "#656d76",
|
||||||
|
surface: dark ? "#161b22" : "#f6f8fa",
|
||||||
|
accent: dark ? "#388bfd" : "#0969da",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderTop: `1px solid ${T.border}`,
|
||||||
|
background: T.surface,
|
||||||
|
flexShrink: 0,
|
||||||
|
maxHeight: "38vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
width: "100%",
|
||||||
|
padding: "9px 16px",
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: T.muted,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
textAlign: "left",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
transform: open ? "rotate(90deg)" : "none",
|
||||||
|
transition: "transform .15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▸
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Linked from {backlinks.length} {backlinks.length === 1 ? "note" : "notes"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 2,
|
||||||
|
padding: "0 10px 10px",
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{backlinks.map((b) => (
|
||||||
|
<button
|
||||||
|
key={b.id}
|
||||||
|
onClick={() => onOpenNote?.(b.id)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
gap: 8,
|
||||||
|
width: "100%",
|
||||||
|
padding: "7px 9px",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: T.accent,
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{b.title || "Untitled"}
|
||||||
|
</span>
|
||||||
|
{b.notebook_name && (
|
||||||
|
<span style={{ fontSize: 11, color: T.muted, flexShrink: 0 }}>in {b.notebook_name}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
/* ═══════ Shared Components ═══════ */
|
||||||
|
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onCancel, dark }) {
|
||||||
|
const T = useMemo(
|
||||||
|
() => ({
|
||||||
|
bg: dark ? "#161b22" : "#fff",
|
||||||
|
border: dark ? "#30363d" : "#d0d7de",
|
||||||
|
text: dark ? "#e6edf3" : "#1f2328",
|
||||||
|
muted: dark ? "#8b949e" : "#656d76",
|
||||||
|
danger: dark ? "#f85149" : "#cf222e",
|
||||||
|
}),
|
||||||
|
[dark]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.5)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 2000,
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
}}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: T.bg,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 26,
|
||||||
|
width: 380,
|
||||||
|
maxWidth: "90vw",
|
||||||
|
boxShadow: "0 20px 40px rgba(0,0,0,0.3)",
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: T.text,
|
||||||
|
marginBottom: 8,
|
||||||
|
fontFamily: "'Newsreader',Georgia,serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: T.muted,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
marginBottom: 22,
|
||||||
|
fontFamily: "'DM Sans',sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
style={{
|
||||||
|
padding: "8px 20px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
background: "transparent",
|
||||||
|
color: T.text,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
style={{
|
||||||
|
padding: "8px 20px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "none",
|
||||||
|
background: T.danger,
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export function ContextMenu({ x, y, items, onClose, dark }) {
|
||||||
|
const T = useMemo(
|
||||||
|
() => ({
|
||||||
|
bg: dark ? "#1c2128" : "#fff",
|
||||||
|
border: dark ? "#30363d" : "#d0d7de",
|
||||||
|
text: dark ? "#e6edf3" : "#1f2328",
|
||||||
|
hover: dark ? "#262c36" : "#f3f4f6",
|
||||||
|
danger: dark ? "#f85149" : "#cf222e",
|
||||||
|
}),
|
||||||
|
[dark]
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const h = () => onClose();
|
||||||
|
window.addEventListener("click", h);
|
||||||
|
window.addEventListener("contextmenu", h);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("click", h);
|
||||||
|
window.removeEventListener("contextmenu", h);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
left: Math.min(x, window.innerWidth - 220),
|
||||||
|
top: Math.min(y, window.innerHeight - 200),
|
||||||
|
zIndex: 3000,
|
||||||
|
background: T.bg,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: "0 8px 24px rgba(0,0,0,0.25)",
|
||||||
|
padding: 4,
|
||||||
|
minWidth: 190,
|
||||||
|
fontFamily: "'DM Sans',sans-serif",
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{items.map((item, i) =>
|
||||||
|
item.divider ? (
|
||||||
|
<div key={i} style={{ height: 1, background: T.border, margin: "4px 6px" }} />
|
||||||
|
) : item.submenu ? (
|
||||||
|
<SubMenu key={i} item={item} T={T} onClose={onClose} />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => {
|
||||||
|
item.action();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
background: "transparent",
|
||||||
|
color: item.danger ? T.danger : T.text,
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = T.hover)}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
||||||
|
>
|
||||||
|
{item.icon && <span style={{ display: "flex", opacity: 0.7 }}>{item.icon}</span>}
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubMenu({ item, T, onClose }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ position: "relative" }}
|
||||||
|
onMouseEnter={() => setOpen(true)}
|
||||||
|
onMouseLeave={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: T.text,
|
||||||
|
cursor: "pointer",
|
||||||
|
background: open ? T.hover : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon && <span style={{ display: "flex", opacity: 0.7 }}>{item.icon}</span>}
|
||||||
|
{item.label}
|
||||||
|
<span style={{ marginLeft: "auto", fontSize: 10, opacity: 0.5 }}>▸</span>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "100%",
|
||||||
|
top: 0,
|
||||||
|
background: T.bg,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
boxShadow: "0 8px 20px rgba(0,0,0,0.2)",
|
||||||
|
padding: 4,
|
||||||
|
minWidth: 160,
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.submenu.map((sub, j) => (
|
||||||
|
<button
|
||||||
|
key={j}
|
||||||
|
onClick={() => {
|
||||||
|
sub.action();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
width: "100%",
|
||||||
|
padding: "7px 12px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
background: "transparent",
|
||||||
|
color: T.text,
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = T.hover)}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
||||||
|
>
|
||||||
|
{sub.colorDot && (
|
||||||
|
<div
|
||||||
|
style={{ width: 12, height: 8, borderRadius: 2, background: sub.colorDot, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{sub.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { COVERS } from "./constants.jsx";
|
||||||
|
|
||||||
|
/* ═══════ NOTEBOOK COVER ═══════ */
|
||||||
|
export function NotebookCover({
|
||||||
|
notebook,
|
||||||
|
noteCount,
|
||||||
|
onClick,
|
||||||
|
onContextMenu,
|
||||||
|
isActive,
|
||||||
|
isDragOver,
|
||||||
|
draggable,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
}) {
|
||||||
|
const cover = COVERS[notebook.cover % COVERS.length];
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
draggable={draggable}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "transform .22s cubic-bezier(.4,0,.2,1),opacity .2s",
|
||||||
|
transform: hovered ? "translateY(-6px) scale(1.03)" : isDragOver ? "scale(1.06)" : "none",
|
||||||
|
opacity: isDragOver ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: "relative", width: 156, height: 210, perspective: 800 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: -5,
|
||||||
|
left: 10,
|
||||||
|
right: 10,
|
||||||
|
height: 14,
|
||||||
|
background: "rgba(0,0,0,0.25)",
|
||||||
|
borderRadius: "50%",
|
||||||
|
filter: "blur(8px)",
|
||||||
|
opacity: hovered ? 0.5 : 0.3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 2,
|
||||||
|
bottom: 2,
|
||||||
|
width: 18,
|
||||||
|
background: cover.spine,
|
||||||
|
borderRadius: "4px 0 0 4px",
|
||||||
|
boxShadow: "inset -2px 0 4px rgba(0,0,0,0.3)",
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[12, 18].map((t) => (
|
||||||
|
<div
|
||||||
|
key={t}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: t,
|
||||||
|
left: 3,
|
||||||
|
right: 3,
|
||||||
|
height: 1,
|
||||||
|
background: "rgba(255,255,255,0.1)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{[12, 18].map((t) => (
|
||||||
|
<div
|
||||||
|
key={"b" + t}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: t,
|
||||||
|
left: 3,
|
||||||
|
right: 3,
|
||||||
|
height: 1,
|
||||||
|
background: "rgba(255,255,255,0.1)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 2,
|
||||||
|
top: 8,
|
||||||
|
bottom: 8,
|
||||||
|
width: 5,
|
||||||
|
background: "linear-gradient(90deg,#e8e0d4,#f5f0e8,#e8e0d4)",
|
||||||
|
borderRadius: "0 2px 2px 0",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 14,
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: cover.bg,
|
||||||
|
borderRadius: "2px 8px 8px 2px",
|
||||||
|
boxShadow: isActive
|
||||||
|
? `0 4px 20px rgba(0,0,0,0.3),0 0 0 3px ${cover.accent}88`
|
||||||
|
: "0 4px 16px rgba(0,0,0,0.2)",
|
||||||
|
overflow: "hidden",
|
||||||
|
zIndex: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background:
|
||||||
|
"repeating-linear-gradient(45deg,transparent,transparent 30px,rgba(255,255,255,0.015) 30px,rgba(255,255,255,0.015) 60px)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 3,
|
||||||
|
background: cover.accent,
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 28,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: `1.5px solid ${cover.accent}44`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ width: 30, height: 30, borderRadius: "50%", border: `1px solid ${cover.accent}33` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 90,
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "6px 4px",
|
||||||
|
borderTop: `1px solid ${cover.accent}33`,
|
||||||
|
borderBottom: `1px solid ${cover.accent}33`,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: notebook.name.length > 12 ? 12 : 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: "'Newsreader',Georgia,serif",
|
||||||
|
textShadow: "0 1px 4px rgba(0,0,0,0.4)",
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
overflow: "hidden",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{notebook.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 3 }}>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{ width: 2.5, height: 2.5, borderRadius: "50%", background: cover.accent + "55" }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 3,
|
||||||
|
background: cover.accent,
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: "center", maxWidth: 156 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{notebook.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, opacity: 0.5, marginTop: 1 }}>
|
||||||
|
{noteCount} {noteCount === 1 ? "note" : "notes"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { COVERS } from "./constants.jsx";
|
||||||
|
|
||||||
|
export function EditNotebookModal({ notebook, onClose, onSave, dark }) {
|
||||||
|
const [name, setName] = useState(notebook.name);
|
||||||
|
const [sel, setSel] = useState(notebook.cover);
|
||||||
|
const T = useMemo(
|
||||||
|
() => ({
|
||||||
|
bg: dark ? "#161b22" : "#fff",
|
||||||
|
border: dark ? "#30363d" : "#d0d7de",
|
||||||
|
text: dark ? "#e6edf3" : "#1f2328",
|
||||||
|
muted: dark ? "#8b949e" : "#656d76",
|
||||||
|
active: dark ? "#388bfd" : "#0969da",
|
||||||
|
}),
|
||||||
|
[dark]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.55)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 1000,
|
||||||
|
backdropFilter: "blur(6px)",
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: T.bg,
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 30,
|
||||||
|
width: 460,
|
||||||
|
maxWidth: "92vw",
|
||||||
|
boxShadow: "0 24px 48px rgba(0,0,0,0.3)",
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: "0 0 22px",
|
||||||
|
color: T.text,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: "'Newsreader',Georgia,serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit Notebook
|
||||||
|
</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px 14px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
background: dark ? "#0d1117" : "#f6f8fa",
|
||||||
|
color: T.text,
|
||||||
|
fontSize: 14,
|
||||||
|
outline: "none",
|
||||||
|
marginBottom: 16,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && name.trim()) onSave({ name: name.trim(), cover: sel });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ marginBottom: 22 }}>
|
||||||
|
<div style={{ fontSize: 12, color: T.muted, marginBottom: 8 }}>Cover style</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(5,1fr)", gap: 8 }}>
|
||||||
|
{COVERS.map((c, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setSel(i)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
aspectRatio: "3/4",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: c.bg,
|
||||||
|
border: sel === i ? `3px solid ${T.active}` : "2px solid transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 4, background: c.spine }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
padding: "8px 20px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
background: "transparent",
|
||||||
|
color: T.text,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (name.trim()) onSave({ name: name.trim(), cover: sel });
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 20px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "none",
|
||||||
|
background: T.active,
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateNotebookModal({ onClose, onCreate, dark }) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [sel, setSel] = useState(0);
|
||||||
|
const T = useMemo(
|
||||||
|
() => ({
|
||||||
|
bg: dark ? "#161b22" : "#fff",
|
||||||
|
border: dark ? "#30363d" : "#d0d7de",
|
||||||
|
text: dark ? "#e6edf3" : "#1f2328",
|
||||||
|
muted: dark ? "#8b949e" : "#656d76",
|
||||||
|
active: dark ? "#388bfd" : "#0969da",
|
||||||
|
}),
|
||||||
|
[dark]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.55)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 1000,
|
||||||
|
backdropFilter: "blur(6px)",
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: T.bg,
|
||||||
|
borderRadius: 20,
|
||||||
|
padding: 30,
|
||||||
|
width: 460,
|
||||||
|
maxWidth: "92vw",
|
||||||
|
boxShadow: "0 24px 48px rgba(0,0,0,0.3)",
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
margin: "0 0 22px",
|
||||||
|
color: T.text,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: "'Newsreader',Georgia,serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New Notebook
|
||||||
|
</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Notebook name..."
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px 14px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
background: dark ? "#0d1117" : "#f6f8fa",
|
||||||
|
color: T.text,
|
||||||
|
fontSize: 14,
|
||||||
|
outline: "none",
|
||||||
|
marginBottom: 16,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && name.trim()) onCreate({ name: name.trim(), cover: sel });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ marginBottom: 22 }}>
|
||||||
|
<div style={{ fontSize: 12, color: T.muted, marginBottom: 8 }}>Choose cover style</div>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(5,1fr)", gap: 8 }}>
|
||||||
|
{COVERS.map((c, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setSel(i)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
aspectRatio: "3/4",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: c.bg,
|
||||||
|
border: sel === i ? `3px solid ${T.active}` : "2px solid transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ position: "absolute", left: 0, top: 0, bottom: 0, width: 4, background: c.spine }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
padding: "8px 20px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${T.border}`,
|
||||||
|
background: "transparent",
|
||||||
|
color: T.text,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (name.trim()) onCreate({ name: name.trim(), cover: sel });
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 20px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "none",
|
||||||
|
background: T.active,
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import { COVERS, DEFAULT_FOLDERS } from "./constants.jsx";
|
||||||
|
import { I } from "./icons.jsx";
|
||||||
|
import { FONT_UI } from "./theme";
|
||||||
|
|
||||||
|
const SYNC_LABELS = {
|
||||||
|
synced: "All synced",
|
||||||
|
syncing: "Syncing...",
|
||||||
|
offline: "Offline mode",
|
||||||
|
pending: "Changes pending",
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncColor = (status, T) => {
|
||||||
|
if (status === "synced") return T.success;
|
||||||
|
if (status === "syncing" || status === "pending") return T.warning;
|
||||||
|
if (status === "offline") return T.muted;
|
||||||
|
return T.danger;
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconButton = (T) => ({
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: T.muted,
|
||||||
|
});
|
||||||
|
|
||||||
|
const folderButton = (T, active) => ({
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: "left",
|
||||||
|
background: active ? T.sidebarActive : "transparent",
|
||||||
|
color: active ? T.accentText : T.textSec,
|
||||||
|
});
|
||||||
|
|
||||||
|
const countBadge = (T, color) => ({
|
||||||
|
marginLeft: "auto",
|
||||||
|
fontSize: 11,
|
||||||
|
color,
|
||||||
|
background: color === T.danger ? T.danger + "15" : T.accentSoft,
|
||||||
|
padding: "2px 7px",
|
||||||
|
borderRadius: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation rail: search, the built-in views, and the notebook list.
|
||||||
|
*
|
||||||
|
* On a phone it becomes a full-screen overlay; `onClose` is what the backdrop
|
||||||
|
* and every navigation action call so it does not stay in the way.
|
||||||
|
*/
|
||||||
|
export function Sidebar({
|
||||||
|
T,
|
||||||
|
dark,
|
||||||
|
onToggleDark,
|
||||||
|
isMobile,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
activeFolder,
|
||||||
|
onSelectFolder,
|
||||||
|
notebooks,
|
||||||
|
activeNotes,
|
||||||
|
trashedCount,
|
||||||
|
onNewNotebook,
|
||||||
|
onOpenAccount,
|
||||||
|
onNotebookContextMenu,
|
||||||
|
dnd,
|
||||||
|
canInstall,
|
||||||
|
onInstall,
|
||||||
|
syncStatus,
|
||||||
|
online,
|
||||||
|
}) {
|
||||||
|
const notesIn = (nbId) => activeNotes.filter((n) => n.folderId === nbId).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: isMobile ? "fixed" : "relative",
|
||||||
|
zIndex: isMobile ? 900 : 1,
|
||||||
|
width: isMobile ? (open ? "100vw" : 0) : open ? 260 : 0,
|
||||||
|
minWidth: isMobile ? 0 : open ? 260 : 0,
|
||||||
|
height: isMobile ? "100vh" : "auto",
|
||||||
|
background: T.sidebar,
|
||||||
|
borderRight: isMobile ? "none" : `1px solid ${T.border}`,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
transition: "all .25s cubic-bezier(.4,0,.2,1)",
|
||||||
|
fontFamily: FONT_UI,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMobile && open && (
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ position: "absolute", inset: 0, background: "rgba(0,0,0,0.4)", zIndex: -1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: isMobile ? "85vw" : 260,
|
||||||
|
maxWidth: 320,
|
||||||
|
height: "100%",
|
||||||
|
background: T.sidebar,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "20px 18px 12px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: `linear-gradient(135deg,${T.accent},#a855f7)`,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</div>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 17, color: T.text, letterSpacing: "-0.02em" }}>
|
||||||
|
InkNote
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 4 }}>
|
||||||
|
<button onClick={onToggleDark} style={iconButton(T)}>
|
||||||
|
{dark ? I.sun : I.moon}
|
||||||
|
</button>
|
||||||
|
<button onClick={onOpenAccount} title="Account" style={iconButton(T)}>
|
||||||
|
{I.user}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: "0 14px 12px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 10,
|
||||||
|
background: T.inputBg,
|
||||||
|
border: `1px solid ${T.borderLight}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: T.faint }}>{I.search}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
placeholder="Search notes..."
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
outline: "none",
|
||||||
|
flex: 1,
|
||||||
|
color: T.text,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: FONT_UI,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: "0 10px" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectFolder("dashboard")}
|
||||||
|
style={{ ...folderButton(T, activeFolder === "dashboard"), padding: "9px 12px", fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 15 }}>📚</span>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: "6px 10px 0" }}>
|
||||||
|
{DEFAULT_FOLDERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
onClick={() => onSelectFolder(f.id)}
|
||||||
|
style={folderButton(T, activeFolder === f.id)}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 14 }}>{f.icon}</span>
|
||||||
|
<span>{f.name}</span>
|
||||||
|
{f.id === "all" && <span style={countBadge(T, T.faint)}>{activeNotes.length}</span>}
|
||||||
|
{f.id === "trash" && trashedCount > 0 && (
|
||||||
|
<span style={countBadge(T, T.danger)}>{trashedCount}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: "14px 10px 4px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0 12px",
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: T.faint,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Notebooks
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onNewNotebook}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "none",
|
||||||
|
background: T.accentSoft,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: T.accent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.plus}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{notebooks.map((nb) => (
|
||||||
|
<div
|
||||||
|
key={nb.id}
|
||||||
|
draggable={!isMobile}
|
||||||
|
onDragStart={(e) => dnd.onDragStart(e, nb.id)}
|
||||||
|
onDragOver={(e) => dnd.onDragOver(e, nb.id)}
|
||||||
|
onDrop={(e) => dnd.onDrop(e, nb.id)}
|
||||||
|
onDragEnd={dnd.onDragEnd}
|
||||||
|
onContextMenu={(e) => onNotebookContextMenu(e, nb)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: 10,
|
||||||
|
transition: "all .15s",
|
||||||
|
background:
|
||||||
|
dnd.overId === nb.id
|
||||||
|
? T.accentSoft
|
||||||
|
: activeFolder === nb.id
|
||||||
|
? T.sidebarActive
|
||||||
|
: "transparent",
|
||||||
|
borderTop: dnd.overId === nb.id ? `2px solid ${T.accent}` : "2px solid transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isMobile && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "0 2px 0 6px",
|
||||||
|
color: T.faint,
|
||||||
|
cursor: "grab",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.grip}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectFolder(nb.id)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
flex: 1,
|
||||||
|
padding: "7px 10px 7px " + (isMobile ? "10px" : "4px"),
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: "left",
|
||||||
|
background: "transparent",
|
||||||
|
color: activeFolder === nb.id ? T.accentText : T.textSec,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 20,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 3,
|
||||||
|
background: COVERS[nb.cover % COVERS.length].bg,
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{nb.name}</span>
|
||||||
|
<span style={{ marginLeft: "auto", fontSize: 11, color: T.faint }}>{notesIn(nb.id)}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "auto", padding: "12px 14px", borderTop: `1px solid ${T.borderLight}` }}>
|
||||||
|
{canInstall && (
|
||||||
|
<button
|
||||||
|
onClick={onInstall}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${T.accent}44`,
|
||||||
|
background: T.accentSoft,
|
||||||
|
cursor: "pointer",
|
||||||
|
color: T.accent,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{I.download}
|
||||||
|
Install App
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6, color: T.muted, fontSize: 12 }}>
|
||||||
|
<span style={{ color: syncColor(syncStatus, T) }}>●</span>
|
||||||
|
<span>{SYNC_LABELS[syncStatus] || "Sync error"}</span>
|
||||||
|
{!online && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
padding: "2px 6px",
|
||||||
|
borderRadius: 6,
|
||||||
|
background: T.faint + "22",
|
||||||
|
color: T.faint,
|
||||||
|
marginLeft: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
offline
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/** Shared visual constants: palette, notebook covers, sidebar folders. */
|
||||||
|
|
||||||
|
export const COLORS = [
|
||||||
|
"#0d1117",
|
||||||
|
"#e94560",
|
||||||
|
"#0f3460",
|
||||||
|
"#533483",
|
||||||
|
"#e07c24",
|
||||||
|
"#21bf73",
|
||||||
|
"#fd5e53",
|
||||||
|
"#3282b8",
|
||||||
|
"#ff6b6b",
|
||||||
|
"#4ecdc4",
|
||||||
|
"#f7dc6f",
|
||||||
|
"#bb8fce",
|
||||||
|
"#85c1e9",
|
||||||
|
"#f8c471",
|
||||||
|
"#82e0aa",
|
||||||
|
"#f1948a",
|
||||||
|
"#aeb6bf",
|
||||||
|
"#ffffff",
|
||||||
|
"#f39c12",
|
||||||
|
"#000000",
|
||||||
|
];
|
||||||
|
|
||||||
|
export 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" },
|
||||||
|
];
|
||||||
|
export const DEFAULT_FOLDERS = [
|
||||||
|
{ id: "all", name: "All Notes", icon: "📋" },
|
||||||
|
{ id: "starred", name: "Starred", icon: "⭐" },
|
||||||
|
{ id: "trash", name: "Trash", icon: "🗑️" },
|
||||||
|
];
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** Timestamp shown on note cards and list rows. */
|
||||||
|
export const fmtDate = (d: string): string =>
|
||||||
|
new Date(d).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
/** The app's icon set. Inline SVG so they inherit `currentColor`. */
|
||||||
|
|
||||||
|
export const I = {
|
||||||
|
plus: (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
search: (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
bold: (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
|
||||||
|
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
italic: (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<line x1="19" y1="4" x2="10" y2="4" />
|
||||||
|
<line x1="14" y1="20" x2="5" y2="20" />
|
||||||
|
<line x1="15" y1="4" x2="9" y2="20" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
underline: (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3" />
|
||||||
|
<line x1="4" y1="21" x2="20" y2="21" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
list: (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<line x1="8" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="8" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="8" y1="18" x2="21" y2="18" />
|
||||||
|
<circle cx="4" cy="6" r="1" fill="currentColor" />
|
||||||
|
<circle cx="4" cy="12" r="1" fill="currentColor" />
|
||||||
|
<circle cx="4" cy="18" r="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
listOrd: (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<line x1="10" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="10" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="10" y1="18" x2="21" y2="18" />
|
||||||
|
<text x="2" y="8" fontSize="8" fill="currentColor" stroke="none" fontFamily="sans-serif">
|
||||||
|
1
|
||||||
|
</text>
|
||||||
|
<text x="2" y="14" fontSize="8" fill="currentColor" stroke="none" fontFamily="sans-serif">
|
||||||
|
2
|
||||||
|
</text>
|
||||||
|
<text x="2" y="20" fontSize="8" fill="currentColor" stroke="none" fontFamily="sans-serif">
|
||||||
|
3
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
check: (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="3" />
|
||||||
|
<path d="M9 12l2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
trash: (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
star: (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
starFill: (
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
pen: (
|
||||||
|
<svg
|
||||||
|
width="17"
|
||||||
|
height="17"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<path d="M12 19l7-7 3 3-7 7-3-3z" />
|
||||||
|
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z" />
|
||||||
|
<path d="M2 2l7.586 7.586" />
|
||||||
|
<circle cx="11" cy="11" r="2" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
eraser: (
|
||||||
|
<svg
|
||||||
|
width="17"
|
||||||
|
height="17"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<path d="M20 20H7L3 16l9-9 8 8-4 4" />
|
||||||
|
<path d="M6 11l8 8" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
highlight: (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<path d="M15.5 2.5L22 9l-6 6-7-7 6.5-5.5z" />
|
||||||
|
<path d="M9 8l-5 5 4 4 5-5" />
|
||||||
|
<path d="M4 13l-2 7 7-2" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
undo: (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<polyline points="1 4 1 10 7 10" />
|
||||||
|
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
redo: (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<polyline points="23 4 23 10 17 10" />
|
||||||
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
sun: (
|
||||||
|
<svg
|
||||||
|
width="17"
|
||||||
|
height="17"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="5" />
|
||||||
|
<line x1="12" y1="1" x2="12" y2="3" />
|
||||||
|
<line x1="12" y1="21" x2="12" y2="23" />
|
||||||
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||||
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||||
|
<line x1="1" y1="12" x2="3" y2="12" />
|
||||||
|
<line x1="21" y1="12" x2="23" y2="12" />
|
||||||
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||||
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
moon: (
|
||||||
|
<svg
|
||||||
|
width="17"
|
||||||
|
height="17"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
menu: (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
text: (
|
||||||
|
<svg
|
||||||
|
width="17"
|
||||||
|
height="17"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<polyline points="4 7 4 4 20 4 20 7" />
|
||||||
|
<line x1="9" y1="20" x2="15" y2="20" />
|
||||||
|
<line x1="12" y1="4" x2="12" y2="20" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
draw: (
|
||||||
|
<svg
|
||||||
|
width="17"
|
||||||
|
height="17"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
edit: (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
move: (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" />
|
||||||
|
<polyline points="10 17 15 12 10 7" />
|
||||||
|
<line x1="15" y1="12" x2="3" y2="12" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
restore: (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<polyline points="1 4 1 10 7 10" />
|
||||||
|
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
grip: (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<circle cx="9" cy="5" r="2" />
|
||||||
|
<circle cx="15" cy="5" r="2" />
|
||||||
|
<circle cx="9" cy="12" r="2" />
|
||||||
|
<circle cx="15" cy="12" r="2" />
|
||||||
|
<circle cx="9" cy="19" r="2" />
|
||||||
|
<circle cx="15" cy="19" r="2" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
logout: (
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<polyline points="16 17 21 12 16 7" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
user: (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
x: (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
history: (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 3v5h5" />
|
||||||
|
<path d="M3.05 13A9 9 0 1 0 6 5.3L3 8" />
|
||||||
|
<path d="M12 7v5l4 2" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
save: (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||||
|
<polyline points="17 21 17 13 7 13 7 21" />
|
||||||
|
<polyline points="7 3 7 8 15 8" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
download: (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
back: (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<polyline points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* The application palette.
|
||||||
|
*
|
||||||
|
* Colours live here rather than inline so that a change to, say, the danger red
|
||||||
|
* happens in one place. Modal components still carry small local palettes of
|
||||||
|
* their own — their key names mean different things (a modal's `bg` is a raised
|
||||||
|
* surface, the app's `bg` is the page behind it), so they are deliberately not
|
||||||
|
* folded in here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Theme {
|
||||||
|
bg: string;
|
||||||
|
sidebar: string;
|
||||||
|
sidebarActive: string;
|
||||||
|
card: string;
|
||||||
|
toolbar: string;
|
||||||
|
border: string;
|
||||||
|
borderLight: string;
|
||||||
|
text: string;
|
||||||
|
textSec: string;
|
||||||
|
muted: string;
|
||||||
|
faint: string;
|
||||||
|
accent: string;
|
||||||
|
accentSoft: string;
|
||||||
|
accentText: string;
|
||||||
|
danger: string;
|
||||||
|
success: string;
|
||||||
|
warning: string;
|
||||||
|
inputBg: string;
|
||||||
|
dashBg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DARK: Theme = {
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIGHT: Theme = {
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const makeTheme = (dark: boolean): Theme => (dark ? DARK : LIGHT);
|
||||||
|
|
||||||
|
export const FONT_UI = "'DM Sans',sans-serif";
|
||||||
|
export const FONT_SERIF = "'Newsreader',Georgia,serif";
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": false,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"verbatimModuleSyntax": false,
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
+11
-1
@@ -1,10 +1,20 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// In Docker the backend is reachable under its service name, locally under localhost.
|
||||||
|
const API_TARGET = process.env.VITE_API_TARGET || 'http://localhost:4000'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 3000,
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
// Needed for file-watching to work through a bind mount inside Docker.
|
||||||
|
watch: { usePolling: true, interval: 300 },
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: API_TARGET, changeOrigin: true },
|
||||||
|
'/ws': { target: API_TARGET, changeOrigin: true, ws: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
include: ["src/**/*.test.{js,ts}"],
|
||||||
|
setupFiles: ["src/test/setup.js"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -7,7 +7,12 @@ A cross-platform note-taking application with rich text editing, freehand drawin
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Core
|
### Core
|
||||||
- **Rich Text Notes** — Bold, italic, underline, headings, bullet/numbered lists, checkboxes
|
- **Rich Text Notes** — Bold, italic, underline, headings, bullet/numbered lists, task lists, quotes, code blocks
|
||||||
|
- **Tables** — Insert, resize columns, add/remove rows and columns, merge cells, toggle header row
|
||||||
|
- **Note Links** — Type `@` to link to another note or notebook; every note shows what links back to it
|
||||||
|
- **Version History** — Past states of every note, restorable; restoring is itself reversible
|
||||||
|
- **Full-Text Search** — Searches note bodies, not just titles, and works offline
|
||||||
|
- **Image Attachments** — Pasted images are stored as deduplicated blobs, not inlined into the note
|
||||||
- **Freehand Drawing** — Pen, highlighter, eraser with adjustable thickness and color palette (20 colors, 7 pen sizes)
|
- **Freehand Drawing** — Pen, highlighter, eraser with adjustable thickness and color palette (20 colors, 7 pen sizes)
|
||||||
- **Undo/Redo** — Full history stack for drawing operations
|
- **Undo/Redo** — Full history stack for drawing operations
|
||||||
- **PDF Import & Annotation** — Import PDFs, overlay drawing/highlighting, persistent annotations
|
- **PDF Import & Annotation** — Import PDFs, overlay drawing/highlighting, persistent annotations
|
||||||
@@ -20,7 +25,8 @@ A cross-platform note-taking application with rich text editing, freehand drawin
|
|||||||
- **Conflict-Aware Sync** — Vector clock–based conflict detection; notifies user of merge conflicts
|
- **Conflict-Aware Sync** — Vector clock–based conflict detection; notifies user of merge conflicts
|
||||||
- **WebSocket Real-Time Push** — Changes appear on other devices instantly
|
- **WebSocket Real-Time Push** — Changes appear on other devices instantly
|
||||||
- **Encrypted at Rest** — AES-256-CBC encryption for all note content on the server
|
- **Encrypted at Rest** — AES-256-CBC encryption for all note content on the server
|
||||||
- **JWT Authentication** — Secure user accounts with bcrypt password hashing
|
- **JWT Authentication** — Username + password accounts with bcrypt password hashing
|
||||||
|
- **Vector Drawings** — Strokes are stored as vectors in one canonical page space, so a drawing looks identical on every screen and stays sharp at any pixel density
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
- **Self-Hosted Backend** — Node.js + Express + SQLite (or PostgreSQL)
|
- **Self-Hosted Backend** — Node.js + Express + SQLite (or PostgreSQL)
|
||||||
@@ -33,43 +39,75 @@ A cross-platform note-taking application with rich text editing, freehand drawin
|
|||||||
|
|
||||||
```
|
```
|
||||||
inknote/
|
inknote/
|
||||||
├── note-app.jsx # Full React UI (artifact / web)
|
├── docker-compose.yml # Deploy from the Gitea registry
|
||||||
├── docker-compose.yml # Container orchestration
|
├── docker-compose.dev.yml # Build from source for local work
|
||||||
|
├── build.sh # Build & push images to Gitea
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── server.js # Express API + WebSocket server
|
│ ├── server.js # Express API + WebSocket server
|
||||||
│ ├── sync-client.js # Client-side sync engine library
|
│ ├── sync-client.js # Client-side sync engine library
|
||||||
│ ├── package.json # Node dependencies
|
│ ├── package.json # Node dependencies
|
||||||
│ ├── Dockerfile # Server container
|
│ ├── Dockerfile # Server container
|
||||||
│ └── .env.example # Environment variable template
|
│ └── .env.example # Environment variable template
|
||||||
└── README.md # This file
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── App.jsx # Root: where the user is, which dialog is open
|
||||||
|
│ │ ├── types.ts # Shapes shared by server, IndexedDB and UI
|
||||||
|
│ │ ├── syncEngine.ts # Offline-first sync + API client
|
||||||
|
│ │ ├── offlineStore.ts # IndexedDB wrapper
|
||||||
|
│ │ ├── search.ts # Local full-text index
|
||||||
|
│ │ ├── hooks/ # Note data, search, layout, history, drag
|
||||||
|
│ │ ├── ui/ # Sidebar, dialogs, icons, palette
|
||||||
|
│ │ ├── features/notes/ # List, editor pane, version history
|
||||||
|
│ │ ├── features/notebooks/ # Dashboard
|
||||||
|
│ │ ├── features/drawing/ # Canonical stroke model + render layer
|
||||||
|
│ │ └── editor/ # TipTap schema, toolbar, note links, attachments
|
||||||
|
│ ├── public/sw.js # PWA service worker
|
||||||
|
│ ├── nginx.conf # Static serving + API/WS proxy
|
||||||
|
│ └── dockerfile # Web container
|
||||||
|
└── readme.md # This file
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### 1. Run the UI (Web / Artifact)
|
### Where the data lives
|
||||||
|
|
||||||
The `note-app.jsx` file is a self-contained React component. It runs directly as a Claude artifact, or you can embed it in any React project:
|
Both compose files use **bind mounts**, not named volumes, so everything sits in
|
||||||
|
a plain directory on the Docker host and is picked up by whatever backs that
|
||||||
|
host up:
|
||||||
|
|
||||||
```jsx
|
```
|
||||||
import NoteApp from './note-app';
|
$INKNOTE_DATA_DIR/
|
||||||
|
├── db/ # inknote.db — notes, drawings, version history
|
||||||
function App() {
|
└── uploads/ # legacy raster drawings, PDFs
|
||||||
return <NoteApp />;
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Run the Backend
|
Default is `./data` for development and `/srv/inknote` in production; override
|
||||||
|
with `INKNOTE_DATA_DIR`. The container runs as uid/gid `1000` so the files stay
|
||||||
|
owned by a normal user — set `INKNOTE_UID` / `INKNOTE_GID` if yours differ, and
|
||||||
|
create the directory with matching ownership before the first start.
|
||||||
|
|
||||||
|
> Back up the `.env` holding `ENCRYPTION_KEY` too. Note content is encrypted at
|
||||||
|
> rest, so a copy of the database without that key cannot be read.
|
||||||
|
|
||||||
|
### 1. Local development (Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
# Generate secrets once
|
||||||
cp .env.example .env # Edit secrets!
|
printf 'JWT_SECRET=%s\nENCRYPTION_KEY=%s\n' "$(openssl rand -base64 32)" "$(openssl rand -hex 32)" > .env
|
||||||
npm install
|
|
||||||
npm start # → http://localhost:4000
|
# Build and run backend + nginx-served frontend
|
||||||
|
docker compose -f docker-compose.dev.yml up -d --build # → http://localhost:3000
|
||||||
|
|
||||||
|
# Same, plus a Vite dev server with hot reload
|
||||||
|
docker compose -f docker-compose.dev.yml --profile dev up -d --build # → http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Docker Deployment
|
`docker-compose.dev.yml` builds from source and uses its own volumes, so it never
|
||||||
|
touches production data.
|
||||||
|
|
||||||
|
### 2. Docker Deployment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate a secure encryption key
|
# Generate a secure encryption key
|
||||||
@@ -89,8 +127,15 @@ The server runs on port `4000`, the web UI on port `3000`.
|
|||||||
|
|
||||||
| Method | Endpoint | Body | Response |
|
| Method | Endpoint | Body | Response |
|
||||||
|--------|-----------------------|-------------------------------|---------------------|
|
|--------|-----------------------|-------------------------------|---------------------|
|
||||||
| POST | `/api/auth/register` | `{email, password, displayName}` | `{token, user}` |
|
| POST | `/api/auth/register` | `{username, password, displayName}` | `{token, user}` |
|
||||||
| POST | `/api/auth/login` | `{email, password}` | `{token, user}` |
|
| POST | `/api/auth/login` | `{username, password}` | `{token, user}` |
|
||||||
|
| GET | `/api/auth/me` | — | `{id, username, displayName}` |
|
||||||
|
| PUT | `/api/auth/username` | `{newUsername, password}` | updated user |
|
||||||
|
| PUT | `/api/auth/password` | `{currentPassword, newPassword}` | `{success}` |
|
||||||
|
|
||||||
|
Usernames are 3–32 characters of letters, digits, `.`, `_` or `-`, and match case-insensitively.
|
||||||
|
Servers created before this carried e-mail logins; on first start they migrate automatically,
|
||||||
|
deriving each username from the local part of the address (logged to the console).
|
||||||
|
|
||||||
### Notebooks
|
### Notebooks
|
||||||
|
|
||||||
@@ -108,9 +153,11 @@ The server runs on port `4000`, the web UI on port `3000`.
|
|||||||
| GET | `/api/notes` | List all notes (metadata) |
|
| GET | `/api/notes` | List all notes (metadata) |
|
||||||
| GET | `/api/notes/:id` | Get note with decrypted content |
|
| GET | `/api/notes/:id` | Get note with decrypted content |
|
||||||
| POST | `/api/notes` | Create note |
|
| POST | `/api/notes` | Create note |
|
||||||
| PUT | `/api/notes/:id` | Update note (conflict-aware) |
|
| PUT | `/api/notes/:id` | Update note (`{content, links}`) |
|
||||||
|
| GET | `/api/notes/:id/backlinks`| Notes linking to this one |
|
||||||
|
| GET | `/api/links` | Every link, for backlink counts |
|
||||||
| DELETE | `/api/notes/:id` | Soft-delete note |
|
| DELETE | `/api/notes/:id` | Soft-delete note |
|
||||||
| PUT | `/api/notes/:id/drawing` | Save drawing data (base64) |
|
| PUT | `/api/notes/:id/drawing` | Save vector strokes |
|
||||||
| POST | `/api/notes/pdf` | Upload PDF (multipart/form) |
|
| POST | `/api/notes/pdf` | Upload PDF (multipart/form) |
|
||||||
|
|
||||||
### Sync
|
### Sync
|
||||||
@@ -138,7 +185,7 @@ import { SyncEngine } from './sync-client';
|
|||||||
const sync = new SyncEngine('https://your-server.com', 'device-abc');
|
const sync = new SyncEngine('https://your-server.com', 'device-abc');
|
||||||
|
|
||||||
// Authenticate
|
// Authenticate
|
||||||
await sync.authenticate('user@example.com', 'password');
|
await sync.authenticate('username', 'password');
|
||||||
|
|
||||||
// Listen for events
|
// Listen for events
|
||||||
sync.on('status', (status) => console.log('Sync:', status));
|
sync.on('status', (status) => console.log('Sync:', status));
|
||||||
@@ -159,11 +206,196 @@ await sync.createNote({ type: 'text', title: 'Hello', content: '<p>World</p>' })
|
|||||||
await sync.updateNote('note-id', { title: 'Updated' });
|
await sync.updateNote('note-id', { title: 'Updated' });
|
||||||
await sync.deleteNote('note-id');
|
await sync.deleteNote('note-id');
|
||||||
await sync.uploadPDF(fileBlob);
|
await sync.uploadPDF(fileBlob);
|
||||||
await sync.saveDrawing('note-id', canvasDataUrl);
|
await sync.saveDrawing('note-id', strokeDoc);
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Working on the frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test # Vitest — store, search and content migration
|
||||||
|
npm run typecheck # tsc --noEmit
|
||||||
|
npm run format # Prettier
|
||||||
|
npm run build # production bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
TypeScript is enabled with `allowJs`, so `.js` and `.ts` coexist and modules can
|
||||||
|
be converted one at a time. The whole data layer is typed — `types`,
|
||||||
|
`offlineStore`, `search`, `editor/content` and `syncEngine` — because that is
|
||||||
|
where the bugs have been: every serious one so far has been a shape bug, or a
|
||||||
|
failure distinguished by comparing an error's `.message` to a magic string.
|
||||||
|
Failures now carry a typed `SyncError.code`; `.message` still holds the same
|
||||||
|
string, so the remaining JavaScript callers are unaffected.
|
||||||
|
|
||||||
|
The 81 unit tests concentrate on the same places, and several exist because the
|
||||||
|
bug they cover actually happened:
|
||||||
|
|
||||||
|
- a queue entry that fails must stay queued, with an attempt counter
|
||||||
|
- `fullSync` must not drop notes created offline
|
||||||
|
- `hasPendingWrites` must cover the debounce window, not just requests in flight
|
||||||
|
- v1 note bodies must migrate without losing task state or smuggling in markup
|
||||||
|
|
||||||
|
### How the frontend is arranged
|
||||||
|
|
||||||
|
`App.jsx` holds only *where the user is* — the open notebook, the open note, the
|
||||||
|
search box, whichever dialog is up — and hands each of the three panes what it
|
||||||
|
needs. Everything else lives behind a hook:
|
||||||
|
|
||||||
|
| Hook | Owns |
|
||||||
|
| --- | --- |
|
||||||
|
| `useNoteData` | notes, notebooks, bodies, every mutation, background sync |
|
||||||
|
| `useSearchIndex` | the local index and the background backfill that fills it |
|
||||||
|
| `useResponsive` | phone vs desktop, sidebar and which pane a phone shows |
|
||||||
|
| `useNavigationHistory` | mapping the current location onto browser history |
|
||||||
|
| `useNotebookDnd` | drag-to-reorder, shared by sidebar and dashboard |
|
||||||
|
| `usePwaInstall` | the browser's install offer |
|
||||||
|
|
||||||
|
`useNoteData` deliberately has no confirmation prompts in it: whether to ask
|
||||||
|
before deleting something is a question for the interface, not the store. The
|
||||||
|
panes (`Sidebar`, `Dashboard`, `NoteList`, `EditorPane`) take props and hold no
|
||||||
|
state of their own, so they can be read on their own.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Note Content
|
||||||
|
|
||||||
|
The body of a note is a **TipTap/ProseMirror document** stored as JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "v": 2, "doc": { "type": "doc", "content": [ … ] } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Tables, task lists and note links are real nodes rather than HTML that happened
|
||||||
|
to survive `document.execCommand`. Notes written before this were
|
||||||
|
`{ html, tasks }`; they are converted to a document the first time they are
|
||||||
|
opened and rewritten in the new format on the next save, so nothing needs
|
||||||
|
migrating up front.
|
||||||
|
|
||||||
|
### Note links and backlinks
|
||||||
|
|
||||||
|
Typing `@` opens a search over notes and notebooks and inserts a `noteLink`
|
||||||
|
node holding the target's id — so a link survives the target being renamed, and
|
||||||
|
the label is refreshed from the current title whenever the note is opened.
|
||||||
|
|
||||||
|
The server cannot read encrypted note bodies, so it cannot discover links
|
||||||
|
itself. The client sends the ids it references alongside the content, and the
|
||||||
|
server maintains a `note_links` index from them. That index is what powers
|
||||||
|
`GET /api/notes/:id/backlinks` and the "Linked from N notes" bar under the
|
||||||
|
editor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Full-Text Search
|
||||||
|
|
||||||
|
Search runs entirely on the client. The server stores note bodies encrypted and
|
||||||
|
deliberately keeps no plaintext index, so it cannot search them — and a
|
||||||
|
server-side index would undo the point of encrypting at rest.
|
||||||
|
|
||||||
|
Bodies are normally fetched only when a note is opened, which would leave a
|
||||||
|
fresh device with nothing to search. After login a background pass backfills the
|
||||||
|
missing ones into IndexedDB, slowly enough to stay out of the way, indexing each
|
||||||
|
as it lands. Searching therefore also works offline.
|
||||||
|
|
||||||
|
Matching is AND across terms, case- and diacritic-insensitive (`kuhlschrank`
|
||||||
|
finds *Kühlschrank*), with title matches ranked above body matches. Results show
|
||||||
|
a snippet around the first match.
|
||||||
|
|
||||||
|
> Bodies of notes edited on another device are refreshed when that note is
|
||||||
|
> opened, so search results for untouched notes can lag behind until then.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Attachments
|
||||||
|
|
||||||
|
Images used to be inlined into the note body as base64 data URIs — a few
|
||||||
|
screenshots turned one note into megabytes that were re-encrypted and re-uploaded
|
||||||
|
on every keystroke. They are now separate blobs; the body only keeps an id.
|
||||||
|
|
||||||
|
- Stored on disk under the SHA-256 of their bytes, so the same image pasted into
|
||||||
|
ten notes is kept once
|
||||||
|
- Uploads are type-checked against the actual bytes, not the declared MIME type
|
||||||
|
- Downloads require the owner's token, so `<img>` cannot fetch them directly —
|
||||||
|
the client fetches with its token, caches the blob in IndexedDB and renders a
|
||||||
|
blob URL, which also makes images work offline
|
||||||
|
- Files with no database row are swept up daily, one hour after last write
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|-------------------------|------------------------------------|
|
||||||
|
| POST | `/api/attachments` | Upload a base64 data URL |
|
||||||
|
| GET | `/api/attachments/:id` | Fetch the bytes (owner only) |
|
||||||
|
|
||||||
|
> Removing an image from a note does not yet delete its blob: the body is
|
||||||
|
> encrypted, so the server cannot tell what is still referenced. Unused blobs
|
||||||
|
> cost disk space, nothing else.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
|
||||||
|
Every write snapshots the state it is about to replace into `note_versions`,
|
||||||
|
encrypted the same way the note itself is.
|
||||||
|
|
||||||
|
Saves arrive about once a second while typing, so snapshots **coalesce**: within
|
||||||
|
a 5-minute window the existing newest version already represents "before this
|
||||||
|
editing session". Two cases bypass the window, because they are exactly the ones
|
||||||
|
worth undoing:
|
||||||
|
|
||||||
|
- a write that shrinks a note to under half its size (`large-deletion`)
|
||||||
|
- clearing a drawing (`drawing-cleared`)
|
||||||
|
|
||||||
|
Restoring snapshots the current state first (`before-restore`), so picking the
|
||||||
|
wrong version is not a one-way door. A restored body also rebuilds its entries
|
||||||
|
in the backlink index.
|
||||||
|
|
||||||
|
**Retention** — the newest 50 versions per note, trimmed on every write, plus a
|
||||||
|
daily sweep of anything older than 90 days. Both are configurable:
|
||||||
|
|
||||||
|
| Variable | Default | Meaning |
|
||||||
|
|----------------------------|---------|------------------------------------------|
|
||||||
|
| `MAX_VERSIONS_PER_NOTE` | `50` | Versions kept per note |
|
||||||
|
| `VERSION_RETENTION_DAYS` | `90` | Age after which a version is dropped |
|
||||||
|
| `VERSION_COALESCE_MINUTES` | `5` | Window in which saves share one snapshot |
|
||||||
|
|
||||||
|
**Deleting a note** — moving one to the trash keeps its history, because the
|
||||||
|
note can still be restored. Deleting it permanently, or emptying the trash,
|
||||||
|
removes its versions along with it (`ON DELETE CASCADE`), leaving no orphaned
|
||||||
|
rows behind.
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|-------------------------------------------|----------------------------|
|
||||||
|
| GET | `/api/notes/:id/versions` | List (metadata only) |
|
||||||
|
| GET | `/api/notes/:id/versions/:versionId` | One version, decrypted |
|
||||||
|
| POST | `/api/notes/:id/versions/:versionId/restore` | Restore it |
|
||||||
|
|
||||||
|
> Version history protects against **overwritten** data. It does not protect
|
||||||
|
> against a lost disk, because it lives in the same database — that is what
|
||||||
|
> backing up the data directory is for.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Drawings
|
||||||
|
|
||||||
|
Drawings are **vector strokes**, not bitmaps. Every stroke is recorded in one canonical
|
||||||
|
page space of 1200 × 1600 units:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "v": 1, "w": 1200, "h": 1600, "strokes": [
|
||||||
|
{ "t": "pen", "c": "#e94560", "s": 5.5, "p": [100,100, 200,180, 300,140] }
|
||||||
|
]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`t` is the tool (`pen` | `highlighter` | `eraser`), `c` the colour, `s` the width in
|
||||||
|
canonical units, and `p` a flat `x,y` point list. Each viewer scales that page to the
|
||||||
|
room it has and rasterizes at its own device pixel ratio, which is what makes a note
|
||||||
|
look the same on a phone and on a desktop — and stay crisp on both. Stroke data is
|
||||||
|
encrypted at rest like note content.
|
||||||
|
|
||||||
|
Notes drawn before this format still carry a PNG in `drawing_data_path`; it renders
|
||||||
|
underneath any newer strokes so nothing is lost.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Conflict Resolution
|
## Conflict Resolution
|
||||||
|
|
||||||
InkNote uses **vector clocks** to detect conflicts:
|
InkNote uses **vector clocks** to detect conflicts:
|
||||||
@@ -203,7 +435,8 @@ Wrap the React app directly in Electron for desktop. Add native file system acce
|
|||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|-----------|-------------------------------------|
|
|-----------|-------------------------------------|
|
||||||
| Frontend | React (web), adaptable to RN/Flutter |
|
| Frontend | React 18 + Vite |
|
||||||
|
| Editor | TipTap (ProseMirror) |
|
||||||
| Backend | Node.js, Express |
|
| Backend | Node.js, Express |
|
||||||
| Database | SQLite (dev), PostgreSQL (prod) |
|
| Database | SQLite (dev), PostgreSQL (prod) |
|
||||||
| Real-time | WebSocket (ws library) |
|
| Real-time | WebSocket (ws library) |
|
||||||
|
|||||||
Reference in new issue
Block a user