12 Commits

Author SHA1 Message Date
nicwands
c93fc2cc58 Note moving
Some checks are pending
Build Electron App / build (macos-latest, build:mac) (push) Waiting to run
Build Electron App / build (ubuntu-latest, build:linux) (push) Waiting to run
Build Electron App / build (windows-latest, build:win) (push) Waiting to run
2026-03-12 13:25:56 -04:00
nicwands
93edf204ce instructions content rendering 2026-03-11 13:45:10 -04:00
nicwands
4d04f4f2ff Preferences config WIP 2026-03-11 13:36:10 -04:00
nicwands
99e6761e92 Preferences WIP 2026-03-11 13:05:28 -04:00
nicwands
a1b339f668 config system + move api to frontend 2026-03-11 12:26:40 -04:00
nicwands
efc9c73751 search WIP 2026-03-10 12:10:52 -04:00
nicwands
77b8ad2dcd theme switcher 2026-03-10 11:11:22 -04:00
nicwands
23054d4981 update blackletter 2026-03-06 09:59:24 -05:00
nicwands
a3bb474399 update sizing 2026-03-06 09:56:54 -05:00
nicwands
e48779e8e0 fix prod bug
Some checks are pending
Build Electron App / build (macos-latest, build:mac) (push) Waiting to run
Build Electron App / build (ubuntu-latest, build:linux) (push) Waiting to run
Build Electron App / build (windows-latest, build:win) (push) Waiting to run
2026-03-03 20:58:31 -05:00
nicwands
73349444d6 update directory live
Some checks are pending
Build Electron App / build (macos-latest, build:mac) (push) Waiting to run
Build Electron App / build (ubuntu-latest, build:linux) (push) Waiting to run
Build Electron App / build (windows-latest, build:win) (push) Waiting to run
2026-03-03 17:21:14 -05:00
nicwands
e9e0abe380 new category flow 2026-03-03 17:09:29 -05:00
69 changed files with 106857 additions and 40576 deletions

View File

@@ -1,3 +0,0 @@
{
"recommendations": ["dbaeumer.vscode-eslint"]
}

View File

@@ -5,9 +5,6 @@ import filesystemPlugin from "@takerofnotes/plugin-filesystem";
import supabasePlugin from "@takerofnotes/plugin-supabase"; import supabasePlugin from "@takerofnotes/plugin-supabase";
import fs from "fs/promises"; import fs from "fs/promises";
import path, { join } from "path"; import path, { join } from "path";
import { Index } from "flexsearch";
import crypto from "crypto";
import sodium from "libsodium-wrappers";
import __cjs_mod__ from "node:module"; import __cjs_mod__ from "node:module";
const __filename = import.meta.filename; const __filename = import.meta.filename;
const __dirname = import.meta.dirname; const __dirname = import.meta.dirname;
@@ -26,16 +23,21 @@ class PluginRegistry {
return this.plugins.get(id); return this.plugins.get(id);
} }
list() { list() {
return Array.from(this.plugins.values()); return Array.from(this.plugins.values()).map((plugin) => ({
id: plugin.id,
name: plugin.name,
description: plugin.description,
configSchema: plugin.configSchema
}));
} }
} }
const USER_DATA_STRING = "__DEFAULT_USER_DATA__"; const USER_DATA_STRING = "__DEFAULT_USER_DATA__";
class PluginConfig { class Config {
constructor(defaultPlugin) { constructor(defaultPlugin) {
this.defaultPlugin = defaultPlugin; this.defaultPlugin = defaultPlugin;
this.configPath = path.join(app.getPath("userData"), "config.json"); this.configPath = path.join(app.getPath("userData"), "config.json");
} }
// Helper to replace placeholders with dynamic values, recursively // Helper to replace placeholders with dynamic values
_resolveDefaults(config) { _resolveDefaults(config) {
if (Array.isArray(config)) { if (Array.isArray(config)) {
return config.map((item) => this._resolveDefaults(item)); return config.map((item) => this._resolveDefaults(item));
@@ -65,9 +67,10 @@ class PluginConfig {
defaultConfig[field.key] = field.default ?? null; defaultConfig[field.key] = field.default ?? null;
} }
parsed = { parsed = {
activeAdapter: this.defaultPlugin.id, ...parsed ? parsed : {},
adapterConfig: defaultConfig activeAdapter: this.defaultPlugin.id
}; };
parsed.adapters[this.defaultPlugin.id] = defaultConfig;
await this.write(parsed); await this.write(parsed);
} else { } else {
parsed.adapterConfig = this._resolveDefaults(parsed.adapterConfig); parsed.adapterConfig = this._resolveDefaults(parsed.adapterConfig);
@@ -88,167 +91,14 @@ class PluginConfig {
); );
} }
} }
class NotesAPI { const DEFAULT_WINDOW_SIZE = { width: 354, height: 549 };
constructor(adapter, encryptionKey = null) { const DEFAULT_MOVE_WINDOW_SIZE = { width: 708, height: 549 };
if (!adapter) {
throw new Error("NotesAPI requires a storage adapter");
}
this.adapter = adapter;
this.notesCache = /* @__PURE__ */ new Map();
this.encryptionKey = encryptionKey || process.env.NOTES_ENCRYPTION_KEY;
this._sodiumReady = false;
this.index = new Index({
tokenize: "tolerant",
resolution: 9
});
}
async _initSodium() {
if (!this._sodiumReady) {
await sodium.ready;
this._sodiumReady = true;
}
}
_encrypt(note) {
if (!this.encryptionKey) {
throw new Error("Encryption key not set");
}
const key = Buffer.from(this.encryptionKey, "hex");
if (key.length !== 32) {
throw new Error("Encryption key must be 64 hex characters (32 bytes)");
}
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const message = JSON.stringify(note);
const ciphertext = sodium.crypto_secretbox_easy(
Buffer.from(message),
nonce,
key
);
const combined = Buffer.concat([nonce, ciphertext]);
return combined.toString("base64");
}
_decrypt(encryptedData) {
if (!this.encryptionKey) {
throw new Error("Encryption key not set");
}
const key = Buffer.from(this.encryptionKey, "hex");
if (key.length !== 32) {
throw new Error("Encryption key must be 64 hex characters (32 bytes)");
}
const combined = Buffer.from(encryptedData, "base64");
const nonce = combined.slice(0, sodium.crypto_secretbox_NONCEBYTES);
const ciphertext = combined.slice(sodium.crypto_secretbox_NONCEBYTES);
const decrypted = sodium.crypto_secretbox_open_easy(
ciphertext,
nonce,
key
);
return JSON.parse(decrypted.toString());
}
async init() {
await this._initSodium();
await this.adapter.init();
const encryptedNotes = await this.adapter.getAll();
for (const encryptedNote of encryptedNotes) {
try {
const note = this._decrypt(encryptedNote.data || encryptedNote);
this.notesCache.set(note.id, note);
this.index.add(note.id, note.title + "\n" + note.content);
} catch (error) {
console.error("Failed to decrypt note:", error);
}
}
}
/* -----------------------
Public API
------------------------*/
getCategories() {
const categories = /* @__PURE__ */ new Set();
for (const note of this.notesCache.values()) {
if (note.category) {
categories.add(note.category);
}
}
return Array.from(categories).sort();
}
getCategoryNotes(categoryName) {
return Array.from(this.notesCache.values()).filter((n) => n.category === categoryName).sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
}
getNote(id) {
return this.notesCache.get(id) ?? null;
}
async createNote(metadata = {}, content = "") {
const id = crypto.randomUUID();
const now = (/* @__PURE__ */ new Date()).toISOString();
const note = {
id,
title: metadata.title || "Untitled",
category: metadata.category || null,
createdAt: now,
updatedAt: now,
content
};
const encryptedNote = {
id: note.id,
data: this._encrypt(note)
};
this.notesCache.set(id, note);
this.index.add(id, note.title + "\n" + content);
await this.adapter.create(encryptedNote);
return note;
}
async deleteNote(id) {
await this.adapter.delete(id);
this.notesCache.delete(id);
this.index.remove(id);
}
async updateNote(id, content) {
const note = this.notesCache.get(id);
if (!note) throw new Error("Note not found");
note.content = content;
note.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
const encryptedNote = {
id: note.id,
data: this._encrypt(note)
};
this.index.update(id, note.title + "\n" + content);
await this.adapter.update(encryptedNote);
return note;
}
async updateNoteMetadata(id, updates = {}) {
const note = this.notesCache.get(id);
if (!note) throw new Error("Note not found");
const allowedFields = ["title", "category"];
for (const key of Object.keys(updates)) {
if (!allowedFields.includes(key)) {
throw new Error(`Invalid metadata field: ${key}`);
}
}
if (updates.title !== void 0) {
note.title = updates.title;
}
if (updates.category !== void 0) {
note.category = updates.category;
}
note.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
const encryptedNote = {
id: note.id,
data: this._encrypt(note)
};
this.index.update(id, note.title + "\n" + note.content);
await this.adapter.update(encryptedNote);
return note;
}
search(query) {
const ids = this.index.search(query);
return ids.map((id) => this.notesCache.get(id));
}
}
const preloadPath = join(__dirname, "../preload/index.mjs"); const preloadPath = join(__dirname, "../preload/index.mjs");
const rendererPath = join(__dirname, "../renderer/index.html"); const rendererPath = join(__dirname, "../renderer/index.html");
function createWindow() { function createWindow() {
const mainWindow2 = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 354, width: DEFAULT_WINDOW_SIZE.width,
height: 549, height: DEFAULT_WINDOW_SIZE.height,
show: false, show: false,
autoHideMenuBar: true, autoHideMenuBar: true,
webPreferences: { webPreferences: {
@@ -256,23 +106,23 @@ function createWindow() {
sandbox: false sandbox: false
} }
}); });
mainWindow2.on("ready-to-show", () => { mainWindow.on("ready-to-show", () => {
mainWindow2.show(); mainWindow.show();
}); });
mainWindow2.webContents.setWindowOpenHandler((details) => { mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url); shell.openExternal(details.url);
return { action: "deny" }; return { action: "deny" };
}); });
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
mainWindow2.loadURL(process.env["ELECTRON_RENDERER_URL"]); mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
} else { } else {
mainWindow2.loadFile(rendererPath); mainWindow.loadFile(rendererPath);
} }
} }
function createNoteWindow(noteId) { function createNoteWindow(noteId) {
const noteWindow = new BrowserWindow({ const noteWindow = new BrowserWindow({
width: 354, width: DEFAULT_WINDOW_SIZE.width,
height: 549, height: DEFAULT_WINDOW_SIZE.height,
autoHideMenuBar: true, autoHideMenuBar: true,
webPreferences: { webPreferences: {
preload: preloadPath, preload: preloadPath,
@@ -283,11 +133,11 @@ function createNoteWindow(noteId) {
}); });
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) { if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
noteWindow.loadURL( noteWindow.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}/note/${noteId}` `${process.env["ELECTRON_RENDERER_URL"]}/#/note/${noteId}`
); );
} else { } else {
mainWindow.loadFile(rendererPath, { noteWindow.loadFile(rendererPath, {
path: `/notes/${noteId}` hash: `/note/${noteId}`
}); });
} }
} }
@@ -295,22 +145,68 @@ app.whenReady().then(async () => {
ipcMain.on("open-note-window", (_, noteId) => { ipcMain.on("open-note-window", (_, noteId) => {
createNoteWindow(noteId); createNoteWindow(noteId);
}); });
const broadcastNoteChange = (event, data) => {
BrowserWindow.getAllWindows().forEach((win) => {
win.webContents.send(event, data);
});
};
const registry = new PluginRegistry(); const registry = new PluginRegistry();
registry.register(filesystemPlugin); registry.register(filesystemPlugin);
registry.register(supabasePlugin); registry.register(supabasePlugin);
const config = await new PluginConfig(filesystemPlugin).load(); const config = new Config(filesystemPlugin);
const plugin = registry.get(config.activeAdapter); const initialConfig = await config.load();
const adapter = plugin.createAdapter(config.adapterConfig); const setActivePlugin = async (pluginId) => {
const notesAPI = new NotesAPI( const currentConfig = await config.load();
adapter, await config.write({ ...currentConfig, activeAdapter: pluginId });
"729a0d21d783654c68f1a0123e2a0e986350de536b5324f1f35876ea12ffeaf5" const plugin = registry.get(pluginId);
); const adapterConfig = currentConfig.adapters[pluginId] || {};
await notesAPI.init(); const adapter = plugin.createAdapter(adapterConfig);
ipcMain.handle("notesAPI:call", (_, method, args) => { await adapter.init();
if (!notesAPI[method]) { ipcMain.removeHandler("adapter:call");
throw new Error("Invalid method"); ipcMain.handle("adapter:call", async (_, method, args) => {
if (!adapter[method]) {
throw new Error(`Invalid adapter method: ${method}`);
}
return await adapter[method](...args);
});
broadcastNoteChange("plugin-changed", pluginId);
return true;
};
await setActivePlugin(initialConfig.activeAdapter);
ipcMain.handle("getConfig", async () => {
return await config.load();
});
ipcMain.handle("setConfig", async (_, newConfig) => {
await config.write(newConfig);
});
ipcMain.handle("listPlugins", async () => {
return registry.list();
});
ipcMain.handle("setActivePlugin", async (_, pluginId) => {
return await setActivePlugin(pluginId);
});
ipcMain.on("note-changed", (_, event, data) => {
broadcastNoteChange(event, data);
});
ipcMain.handle("move-opened", (_) => {
const activeWindow = BrowserWindow.getFocusedWindow();
const windowSize = activeWindow.getSize();
if (windowSize[0] < DEFAULT_MOVE_WINDOW_SIZE.width) {
activeWindow.setSize(
DEFAULT_MOVE_WINDOW_SIZE.width,
DEFAULT_MOVE_WINDOW_SIZE.height
);
}
});
ipcMain.handle("move-closed", (_) => {
const activeWindow = BrowserWindow.getFocusedWindow();
const windowSize = activeWindow.getSize();
if (windowSize[0] === 708) {
activeWindow.setSize(
DEFAULT_WINDOW_SIZE.width,
DEFAULT_WINDOW_SIZE.height
);
} }
return notesAPI[method](...args);
}); });
electronApp.setAppUserModelId("com.electron"); electronApp.setAppUserModelId("com.electron");
app.on("browser-window-created", (_, window) => { app.on("browser-window-created", (_, window) => {

View File

@@ -1,19 +1,45 @@
import { contextBridge, ipcRenderer } from "electron"; import { contextBridge, ipcRenderer } from "electron";
const api = { const api = {
getConfig: () => ipcRenderer.invoke("getConfig"),
setConfig: (config) => ipcRenderer.invoke("setConfig", config),
listPlugins: () => ipcRenderer.invoke("listPlugins"),
setActivePlugin: (pluginId) => ipcRenderer.invoke("setActivePlugin", pluginId),
openNoteWindow: (noteId) => { openNoteWindow: (noteId) => {
ipcRenderer.send("open-note-window", noteId); ipcRenderer.send("open-note-window", noteId);
},
onNoteCreated: (callback) => {
ipcRenderer.on("note-created", (_, data) => callback(data));
},
onNoteUpdated: (callback) => {
ipcRenderer.on("note-updated", (_, data) => callback(data));
},
onNoteDeleted: (callback) => {
ipcRenderer.on("note-deleted", (_, data) => callback(data));
},
onPluginChanged: (callback) => {
ipcRenderer.on("plugin-changed", (_, data) => callback(data));
},
notifyNoteChanged: (event, data) => {
ipcRenderer.send("note-changed", event, data);
},
moveOpened: () => {
ipcRenderer.invoke("move-opened");
},
moveClosed: () => {
ipcRenderer.invoke("move-closed");
} }
}; };
const notesAPI = { const adapter = {
call: (method, ...args) => ipcRenderer.invoke("notesAPI:call", method, args) call: (method, ...args) => ipcRenderer.invoke("adapter:call", method, args)
}; };
if (process.contextIsolated) { if (process.contextIsolated) {
try { try {
contextBridge.exposeInMainWorld("api", api); contextBridge.exposeInMainWorld("api", api);
contextBridge.exposeInMainWorld("notesAPI", notesAPI); contextBridge.exposeInMainWorld("adapter", adapter);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
} else { } else {
window.api = api; window.api = api;
window.adapter = adapter;
} }

View File

@@ -0,0 +1,4 @@
const __viteBrowserExternal = {};
export {
__viteBrowserExternal as default
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@@ -1,18 +1,18 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Electron</title> <title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP --> <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/> />
<script type="module" crossorigin src="./assets/index-Dv1qfmwr.js"></script> <script type="module" crossorigin src="./assets/index-CzxWU9vx.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CZWw79gc.css"> <link rel="stylesheet" crossorigin href="./assets/index-NYhAwsHy.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
</body> </body>
</html> </html>

192
package-lock.json generated
View File

@@ -14,10 +14,10 @@
"@fuzzco/font-loader": "^1.0.2", "@fuzzco/font-loader": "^1.0.2",
"@takerofnotes/plugin-filesystem": "^0.2.0", "@takerofnotes/plugin-filesystem": "^0.2.0",
"@takerofnotes/plugin-supabase": "^0.1.0", "@takerofnotes/plugin-supabase": "^0.1.0",
"@tiptap/extension-code-block-lowlight": "^3.20.0",
"@tiptap/extension-document": "^3.19.0", "@tiptap/extension-document": "^3.19.0",
"@tiptap/extension-image": "^3.19.0", "@tiptap/extension-highlight": "^3.20.0",
"@tiptap/extension-table": "^3.19.0", "@tiptap/extension-list": "^3.20.0",
"@tiptap/markdown": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0", "@tiptap/starter-kit": "^3.19.0",
"@tiptap/vue-3": "^3.19.0", "@tiptap/vue-3": "^3.19.0",
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
@@ -26,9 +26,10 @@
"fecha": "^4.2.3", "fecha": "^4.2.3",
"flexsearch": "^0.8.212", "flexsearch": "^0.8.212",
"gsap": "^3.14.2", "gsap": "^3.14.2",
"lenis": "^1.3.17",
"libsodium-wrappers": "^0.8.2", "libsodium-wrappers": "^0.8.2",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"lowlight": "^3.3.0",
"markdown-it": "^14.1.1",
"sass": "^1.97.3", "sass": "^1.97.3",
"sass-embedded": "^1.97.3", "sass-embedded": "^1.97.3",
"tempus": "^1.0.0-dev.17", "tempus": "^1.0.0-dev.17",
@@ -2464,17 +2465,34 @@
} }
}, },
"node_modules/@tiptap/extension-code-block": { "node_modules/@tiptap/extension-code-block": {
"version": "3.19.0", "version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.19.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.0.tgz",
"integrity": "sha512-b/2qR+tMn8MQb+eaFYgVk4qXnLNkkRYmwELQ8LEtEDQPxa5Vl7J3eu8+4OyoIFhZrNDZvvoEp80kHMCP8sI6rg==", "integrity": "sha512-lBbmNek14aCjrHcBcq3PRqWfNLvC6bcRa2Osc6e/LtmXlcpype4f6n+Yx+WZ+f2uUh0UmDRCz7BEyUETEsDmlQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^3.19.0", "@tiptap/core": "^3.20.0",
"@tiptap/pm": "^3.19.0" "@tiptap/pm": "^3.20.0"
}
},
"node_modules/@tiptap/extension-code-block-lowlight": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.20.0.tgz",
"integrity": "sha512-9lN9rn07lOWkLnByT5C1axtq56MHpOI7MpLaCmX3p+x1bDl6Uvixm6AoBdTLfZUmUYeEFBsf7t5cR+QepMbkiA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0",
"@tiptap/extension-code-block": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"highlight.js": "^11",
"lowlight": "^2 || ^3"
} }
}, },
"node_modules/@tiptap/extension-document": { "node_modules/@tiptap/extension-document": {
@@ -2558,6 +2576,19 @@
"@tiptap/core": "^3.19.0" "@tiptap/core": "^3.19.0"
} }
}, },
"node_modules/@tiptap/extension-highlight": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.20.0.tgz",
"integrity": "sha512-ANL1wFz0s1ScNR4uBfO0s6Sz+qqGp2u6ynrCVk6TCT3d10CQ+gD1gSDTrVRC3CtlMKtHHH4fYrFAq284+J0gKA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.0"
}
},
"node_modules/@tiptap/extension-horizontal-rule": { "node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.19.0", "version": "3.19.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.19.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.19.0.tgz",
@@ -2572,19 +2603,6 @@
"@tiptap/pm": "^3.19.0" "@tiptap/pm": "^3.19.0"
} }
}, },
"node_modules/@tiptap/extension-image": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.19.0.tgz",
"integrity": "sha512-/rGl8nBziBPVJJ/9639eQWFDKcI3RQsDM3s+cqYQMFQfMqc7sQB9h4o4sHCBpmKxk3Y0FV/0NjnjLbBVm8OKdQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.19.0"
}
},
"node_modules/@tiptap/extension-italic": { "node_modules/@tiptap/extension-italic": {
"version": "3.19.0", "version": "3.19.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.19.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.19.0.tgz",
@@ -2694,20 +2712,6 @@
"@tiptap/core": "^3.19.0" "@tiptap/core": "^3.19.0"
} }
}, },
"node_modules/@tiptap/extension-table": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.19.0.tgz",
"integrity": "sha512-Lg8DlkkDUMYE/CcGOxoCWF98B2i7VWh+AGgqlF+XWrHjhlKHfENLRXm1a0vWuyyP3NknRYILoaaZ1s7QzmXKRA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.19.0",
"@tiptap/pm": "^3.19.0"
}
},
"node_modules/@tiptap/extension-text": { "node_modules/@tiptap/extension-text": {
"version": "3.19.0", "version": "3.19.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.19.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.19.0.tgz",
@@ -2748,23 +2752,6 @@
"@tiptap/pm": "^3.19.0" "@tiptap/pm": "^3.19.0"
} }
}, },
"node_modules/@tiptap/markdown": {
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.19.0.tgz",
"integrity": "sha512-Pnfacq2FHky1rqwmGwEmUJxuZu8VZ8XjaJIqsQC34S3CQWiOU+PukC9In2odzcooiVncLWT9s97jKuYpbmF1tQ==",
"license": "MIT",
"dependencies": {
"marked": "^17.0.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.19.0",
"@tiptap/pm": "^3.19.0"
}
},
"node_modules/@tiptap/pm": { "node_modules/@tiptap/pm": {
"version": "3.20.0", "version": "3.20.0",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.0.tgz", "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.0.tgz",
@@ -2890,6 +2877,15 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "*"
}
},
"node_modules/@types/http-cache-semantics": { "node_modules/@types/http-cache-semantics": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -2970,6 +2966,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/verror": { "node_modules/@types/verror": {
"version": "1.10.11", "version": "1.10.11",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz",
@@ -4422,6 +4424,15 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -4439,6 +4450,19 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/dir-compare": { "node_modules/dir-compare": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
@@ -5670,6 +5694,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/hookable": { "node_modules/hookable": {
"version": "5.5.3", "version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
@@ -6084,32 +6117,6 @@
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lenis": {
"version": "1.3.17",
"resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.17.tgz",
"integrity": "sha512-k9T9rgcxne49ggJOvXCraWn5dt7u2mO+BNkhyu6yxuEnm9c092kAW5Bus5SO211zUvx7aCCEtzy9UWr0RB+oJw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/darkroomengineering"
},
"peerDependencies": {
"@nuxt/kit": ">=3.0.0",
"react": ">=17.0.0",
"vue": ">=3.0.0"
},
"peerDependenciesMeta": {
"@nuxt/kit": {
"optional": true
},
"react": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/libsodium": { "node_modules/libsodium": {
"version": "0.8.2", "version": "0.8.2",
"resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.8.2.tgz", "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.8.2.tgz",
@@ -6202,6 +6209,21 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/lowlight": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
"integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"devlop": "^1.0.0",
"highlight.js": "~11.11.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -6288,18 +6310,6 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/marked": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.2.tgz",
"integrity": "sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/matcher": { "node_modules/matcher": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",

View File

@@ -30,10 +30,10 @@
"@fuzzco/font-loader": "^1.0.2", "@fuzzco/font-loader": "^1.0.2",
"@takerofnotes/plugin-filesystem": "^0.2.0", "@takerofnotes/plugin-filesystem": "^0.2.0",
"@takerofnotes/plugin-supabase": "^0.1.0", "@takerofnotes/plugin-supabase": "^0.1.0",
"@tiptap/extension-code-block-lowlight": "^3.20.0",
"@tiptap/extension-document": "^3.19.0", "@tiptap/extension-document": "^3.19.0",
"@tiptap/extension-image": "^3.19.0", "@tiptap/extension-highlight": "^3.20.0",
"@tiptap/extension-table": "^3.19.0", "@tiptap/extension-list": "^3.20.0",
"@tiptap/markdown": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0", "@tiptap/starter-kit": "^3.19.0",
"@tiptap/vue-3": "^3.19.0", "@tiptap/vue-3": "^3.19.0",
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
@@ -42,9 +42,10 @@
"fecha": "^4.2.3", "fecha": "^4.2.3",
"flexsearch": "^0.8.212", "flexsearch": "^0.8.212",
"gsap": "^3.14.2", "gsap": "^3.14.2",
"lenis": "^1.3.17",
"libsodium-wrappers": "^0.8.2", "libsodium-wrappers": "^0.8.2",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"lowlight": "^3.3.0",
"markdown-it": "^14.1.1",
"sass": "^1.97.3", "sass": "^1.97.3",
"sass-embedded": "^1.97.3", "sass-embedded": "^1.97.3",
"tempus": "^1.0.0-dev.17", "tempus": "^1.0.0-dev.17",

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -4,13 +4,13 @@ import { app } from 'electron'
const USER_DATA_STRING = '__DEFAULT_USER_DATA__' const USER_DATA_STRING = '__DEFAULT_USER_DATA__'
export default class PluginConfig { export default class Config {
constructor(defaultPlugin) { constructor(defaultPlugin) {
this.defaultPlugin = defaultPlugin this.defaultPlugin = defaultPlugin
this.configPath = path.join(app.getPath('userData'), 'config.json') this.configPath = path.join(app.getPath('userData'), 'config.json')
} }
// Helper to replace placeholders with dynamic values, recursively // Helper to replace placeholders with dynamic values
_resolveDefaults(config) { _resolveDefaults(config) {
if (Array.isArray(config)) { if (Array.isArray(config)) {
return config.map((item) => this._resolveDefaults(item)) return config.map((item) => this._resolveDefaults(item))
@@ -48,9 +48,10 @@ export default class PluginConfig {
} }
parsed = { parsed = {
...(parsed ? parsed : {}),
activeAdapter: this.defaultPlugin.id, activeAdapter: this.defaultPlugin.id,
adapterConfig: defaultConfig,
} }
parsed.adapters[this.defaultPlugin.id] = defaultConfig
await this.write(parsed) await this.write(parsed)
} else { } else {

View File

@@ -16,6 +16,11 @@ export default class PluginRegistry {
} }
list() { list() {
return Array.from(this.plugins.values()) return Array.from(this.plugins.values()).map((plugin) => ({
id: plugin.id,
name: plugin.name,
description: plugin.description,
configSchema: plugin.configSchema,
}))
} }
} }

View File

@@ -4,18 +4,20 @@ import { app, shell, BrowserWindow, ipcMain } from 'electron'
import filesystemPlugin from '@takerofnotes/plugin-filesystem' import filesystemPlugin from '@takerofnotes/plugin-filesystem'
import supabasePlugin from '@takerofnotes/plugin-supabase' import supabasePlugin from '@takerofnotes/plugin-supabase'
import PluginRegistry from './core/PluginRegistry.js' import PluginRegistry from './core/PluginRegistry.js'
import PluginConfig from './core/PluginConfig.js' import Config from './core/Config.js'
import NotesAPI from './core/NotesAPI.js'
import { join } from 'path' import { join } from 'path'
const DEFAULT_WINDOW_SIZE = { width: 354, height: 549 }
const DEFAULT_MOVE_WINDOW_SIZE = { width: 708, height: 549 }
const preloadPath = join(__dirname, '../preload/index.mjs') const preloadPath = join(__dirname, '../preload/index.mjs')
const rendererPath = join(__dirname, '../renderer/index.html') const rendererPath = join(__dirname, '../renderer/index.html')
// Main window // Main window
function createWindow() { function createWindow() {
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 354, width: DEFAULT_WINDOW_SIZE.width,
height: 549, height: DEFAULT_WINDOW_SIZE.height,
show: false, show: false,
autoHideMenuBar: true, autoHideMenuBar: true,
webPreferences: { webPreferences: {
@@ -43,8 +45,8 @@ function createWindow() {
// Open note in new window // Open note in new window
function createNoteWindow(noteId) { function createNoteWindow(noteId) {
const noteWindow = new BrowserWindow({ const noteWindow = new BrowserWindow({
width: 354, width: DEFAULT_WINDOW_SIZE.width,
height: 549, height: DEFAULT_WINDOW_SIZE.height,
autoHideMenuBar: true, autoHideMenuBar: true,
webPreferences: { webPreferences: {
preload: preloadPath, preload: preloadPath,
@@ -56,11 +58,11 @@ function createNoteWindow(noteId) {
if (is.dev && process.env['ELECTRON_RENDERER_URL']) { if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
noteWindow.loadURL( noteWindow.loadURL(
`${process.env['ELECTRON_RENDERER_URL']}/note/${noteId}`, `${process.env['ELECTRON_RENDERER_URL']}/#/note/${noteId}`,
) )
} else { } else {
mainWindow.loadFile(rendererPath, { noteWindow.loadFile(rendererPath, {
path: `/notes/${noteId}`, hash: `/note/${noteId}`,
}) })
} }
} }
@@ -71,6 +73,13 @@ app.whenReady().then(async () => {
createNoteWindow(noteId) createNoteWindow(noteId)
}) })
// Broadcast note changes to all windows
const broadcastNoteChange = (event, data) => {
BrowserWindow.getAllWindows().forEach((win) => {
win.webContents.send(event, data)
})
}
// Create plugin registry // Create plugin registry
const registry = new PluginRegistry() const registry = new PluginRegistry()
@@ -79,30 +88,81 @@ app.whenReady().then(async () => {
registry.register(supabasePlugin) registry.register(supabasePlugin)
// Pull plugin config // Pull plugin config
const config = await new PluginConfig(filesystemPlugin).load() const config = new Config(filesystemPlugin)
const initialConfig = await config.load()
// Create instance of active adapter const setActivePlugin = async (pluginId) => {
const plugin = registry.get(config.activeAdapter) const currentConfig = await config.load()
// const plugin = registry.get(supabasePlugin.id) await config.write({ ...currentConfig, activeAdapter: pluginId })
const adapter = plugin.createAdapter(config.adapterConfig)
// const adapter = plugin.createAdapter({
// supabaseKey: process.env.SUPABASE_KEY,
// supabaseUrl: process.env.SUPABASE_URL,
// })
// Init Notes API const plugin = registry.get(pluginId)
const notesAPI = new NotesAPI( const adapterConfig = currentConfig.adapters[pluginId] || {}
adapter, const adapter = plugin.createAdapter(adapterConfig)
'729a0d21d783654c68f1a0123e2a0e986350de536b5324f1f35876ea12ffeaf5',
)
await notesAPI.init()
// Handle Notes API // Initialize adapter
ipcMain.handle('notesAPI:call', (_, method, args) => { await adapter.init()
if (!notesAPI[method]) {
throw new Error('Invalid method') // Handle adapter methods via IPC
ipcMain.removeHandler('adapter:call')
ipcMain.handle('adapter:call', async (_, method, args) => {
if (!adapter[method]) {
throw new Error(`Invalid adapter method: ${method}`)
}
return await adapter[method](...args)
})
broadcastNoteChange('plugin-changed', pluginId)
return true
}
// Set active plugin
await setActivePlugin(initialConfig.activeAdapter)
// Get/set config
ipcMain.handle('getConfig', async () => {
return await config.load()
})
ipcMain.handle('setConfig', async (_, newConfig) => {
await config.write(newConfig)
})
// Get/set plugins
ipcMain.handle('listPlugins', async () => {
return registry.list()
})
ipcMain.handle('setActivePlugin', async (_, pluginId) => {
return await setActivePlugin(pluginId)
})
// Handle note change events from renderer
ipcMain.on('note-changed', (_, event, data) => {
broadcastNoteChange(event, data)
})
// Handle resizing for note "move" functionality
ipcMain.handle('move-opened', (_) => {
const activeWindow = BrowserWindow.getFocusedWindow()
const windowSize = activeWindow.getSize()
if (windowSize[0] < DEFAULT_MOVE_WINDOW_SIZE.width) {
activeWindow.setSize(
DEFAULT_MOVE_WINDOW_SIZE.width,
DEFAULT_MOVE_WINDOW_SIZE.height,
)
}
})
ipcMain.handle('move-closed', (_) => {
const activeWindow = BrowserWindow.getFocusedWindow()
const windowSize = activeWindow.getSize()
if (windowSize[0] === 708) {
activeWindow.setSize(
DEFAULT_WINDOW_SIZE.width,
DEFAULT_WINDOW_SIZE.height,
)
} }
return notesAPI[method](...args)
}) })
electronApp.setAppUserModelId('com.electron') electronApp.setAppUserModelId('com.electron')

View File

@@ -2,24 +2,50 @@ import { contextBridge, ipcRenderer } from 'electron'
// Custom APIs for renderer // Custom APIs for renderer
const api = { const api = {
getConfig: () => ipcRenderer.invoke('getConfig'),
setConfig: (config) => ipcRenderer.invoke('setConfig', config),
listPlugins: () => ipcRenderer.invoke('listPlugins'),
setActivePlugin: (pluginId) =>
ipcRenderer.invoke('setActivePlugin', pluginId),
openNoteWindow: (noteId) => { openNoteWindow: (noteId) => {
ipcRenderer.send('open-note-window', noteId) ipcRenderer.send('open-note-window', noteId)
}, },
onNoteCreated: (callback) => {
ipcRenderer.on('note-created', (_, data) => callback(data))
},
onNoteUpdated: (callback) => {
ipcRenderer.on('note-updated', (_, data) => callback(data))
},
onNoteDeleted: (callback) => {
ipcRenderer.on('note-deleted', (_, data) => callback(data))
},
onPluginChanged: (callback) => {
ipcRenderer.on('plugin-changed', (_, data) => callback(data))
},
notifyNoteChanged: (event, data) => {
ipcRenderer.send('note-changed', event, data)
},
moveOpened: () => {
ipcRenderer.invoke('move-opened')
},
moveClosed: () => {
ipcRenderer.invoke('move-closed')
},
} }
// Implement notes API // Implement adapter API - communicates with plugin adapter in main process
const notesAPI = { const adapter = {
call: (method, ...args) => call: (method, ...args) => ipcRenderer.invoke('adapter:call', method, args),
ipcRenderer.invoke('notesAPI:call', method, args),
} }
if (process.contextIsolated) { if (process.contextIsolated) {
try { try {
contextBridge.exposeInMainWorld('api', api) contextBridge.exposeInMainWorld('api', api)
contextBridge.exposeInMainWorld('notesAPI', notesAPI) contextBridge.exposeInMainWorld('adapter', adapter)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
} else { } else {
window.api = api window.api = api
window.adapter = adapter
} }

View File

@@ -1,17 +1,17 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Electron</title> <title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP --> <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/> />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

View File

@@ -2,7 +2,13 @@
<div :class="classes" :style="styles"> <div :class="classes" :style="styles">
<Nav /> <Nav />
<router-view :key="$route.fullPath" /> <Suspense>
<div class="layout-container">
<router-view :key="$route.fullPath" />
<MoveMenu />
</div>
</Suspense>
<Menu /> <Menu />
@@ -14,16 +20,21 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import loadFonts from '@fuzzco/font-loader' import loadFonts from '@fuzzco/font-loader'
import { useWindowSize } from '@vueuse/core' import { useWindowSize } from '@vueuse/core'
import Menu from '@/components/menu/Index.vue' import Menu from '@/components/Menu.vue'
import Nav from '@/components/Nav.vue' import Nav from '@/components/Nav.vue'
import ScrollBar from './components/ScrollBar.vue' import MoveMenu from '@/components/MoveMenu.vue'
import ScrollBar from '@/components/ScrollBar.vue'
import useConfig from '@/composables/useConfig'
const { height } = useWindowSize() const { height } = useWindowSize()
// Theme state
const { config } = useConfig()
const classes = computed(() => [ const classes = computed(() => [
'container', 'container',
{ 'fonts-ready': !fontsLoading.value }, { 'fonts-ready': !fontsLoading.value },
'theme-dark', `theme-${config.value?.theme || 'dark'}`,
]) ])
const fontsLoading = ref(true) const fontsLoading = ref(true)
@@ -59,7 +70,13 @@ const styles = computed(() => ({
overflow-x: clip; overflow-x: clip;
background: var(--theme-bg); background: var(--theme-bg);
color: var(--theme-fg); color: var(--theme-fg);
transition: opacity 1000ms; transition: opacity 400ms;
.layout-container {
display: grid;
grid-template-columns: 1fr auto;
min-height: calc(100 * var(--vh));
}
&:not(.fonts-ready) { &:not(.fonts-ready) {
opacity: 0; opacity: 0;

View File

@@ -1,41 +1,132 @@
<template> <template>
<router-link class="category-row" :to="`/category/${category}`"> <component
:class="['category-row', { editable }]"
:to="`/category/${category}`"
:is="wrapper"
>
<span class="index">{{ String(index + 1).padStart(2, '0') }}.</span> <span class="index">{{ String(index + 1).padStart(2, '0') }}.</span>
<span class="title h1">{{ category }}</span> <form v-if="isEditing" @submit.prevent="onSave">
</router-link> <input
v-model="categoryInput"
class="category-input"
type="text"
ref="input"
@blur="onSave"
/>
</form>
<span v-else class="title h1">{{ categoryInput }}</span>
<button v-if="isEditing" class="save-button" @click="onSave">
Save
</button>
<button v-else-if="editable" class="edit-button" @click="onEdit">
Edit
</button>
</component>
</template> </template>
<script setup> <script setup>
const props = defineProps({ index: Number, category: String }) import { computed, ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
const props = defineProps({
index: Number,
category: {
type: String,
default: () => '',
},
editable: {
type: Boolean,
default: () => false,
},
wrapper: String,
})
const emit = defineEmits(['edited'])
const isEditing = ref(false)
const categoryInput = ref('')
const input = ref()
onMounted(() => {
categoryInput.value = props.category
if (categoryInput.value === '') {
onEdit()
}
})
const onEdit = async () => {
isEditing.value = true
await new Promise((res) => setTimeout(res, 300))
input.value?.focus()
}
const onSave = async () => {
isEditing.value = false
emit('edited', categoryInput.value)
await new Promise((res) => setTimeout(res, 300))
input.value?.blur()
}
const wrapper = computed(() => {
return props.wrapper || (props.editable ? 'div' : RouterLink)
})
</script> </script>
<style lang="scss"> <style lang="scss">
.category-row { .category-row {
display: grid; display: grid;
grid-template-columns: size-vw(26px) 1fr; grid-template-columns: 26px 1fr auto;
align-items: flex-start;
width: 100%; width: 100%;
position: relative; position: relative;
padding: size-vw(5px) 0 size-vw(15px); padding: 8px 0 6px;
border-bottom: 1px dashed currentColor;
cursor: pointer; cursor: pointer;
.index { .index {
margin-top: size-vw(19px); margin-top: 19px;
@include p;
} }
.title { .title {
display: block; display: block;
width: 100%; width: 100%;
@include line-clamp(2); @include line-clamp(2);
} }
&::after { .category-input {
content: '----------------------------------------'; display: block;
position: absolute; width: 100%;
bottom: 0; @include h1;
left: 0;
&:focus {
outline: none;
}
} }
&.router-link-exact-active { .edit-button,
.save-button {
color: var(--grey-100);
cursor: pointer;
padding-right: 0.5em;
padding-left: 0.5em;
margin-top: 1.5em;
}
.edit-button {
opacity: 0;
pointer-events: none;
}
&.editable:hover {
.edit-button {
opacity: 1;
pointer-events: auto;
}
}
&.router-link-exact-active,
&.editable {
cursor: default; cursor: default;
} }
&:hover:not(.router-link-exact-active) { &:hover:not(.router-link-exact-active):not(.editable) {
color: var(--theme-accent); color: var(--theme-accent);
} }
} }

View File

@@ -3,15 +3,20 @@
<div v-if="menuOpen" class="menu" ref="container"> <div v-if="menuOpen" class="menu" ref="container">
<Nav /> <Nav />
<div class="menu-wrap layout-block-inner"> <div class="menu-wrap layout-block">
<new-note class="menu-item" @noteOpened="closeMenu" /> <new-note class="menu-item" @noteOpened="closeMenu" />
<button class="menu-item">+ New Capitulum</button> <router-link class="menu-item" to="/category">
<button class="menu-item">Change Theme</button> + New Capitulum
<router-link class="menu-item" to="/instructions" </router-link>
>Instructio</router-link <theme-switcher class="menu-item" />
> <router-link class="menu-item" to="/instructions">
Instructio
</router-link>
<button class="menu-item">Import</button> <button class="menu-item">Import</button>
<button class="menu-item">Export</button> <button class="menu-item">Export</button>
<router-link class="menu-item" to="/preferences">
Preferences
</router-link>
</div> </div>
</div> </div>
</transition> </transition>
@@ -19,9 +24,10 @@
<script setup> <script setup>
import NewNote from '@/components/NewNote.vue' import NewNote from '@/components/NewNote.vue'
import ThemeSwitcher from '@/components/ThemeSwitcher.vue'
import { onClickOutside } from '@vueuse/core' import { onClickOutside } from '@vueuse/core'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import Nav from '../Nav.vue' import Nav from './Nav.vue'
import useMenu from '@/composables/useMenu' import useMenu from '@/composables/useMenu'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
@@ -58,12 +64,11 @@ const openNewCategory = () => {}
.menu-wrap { .menu-wrap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-top: size-vw(3px); padding-top: 1.2em;
padding-bottom: size-vw(10px); padding-bottom: 10px;
.menu-item { .menu-item {
display: block; padding: 16px 0;
padding: size-vw(16px) 0;
text-align: center; text-align: center;
&:not(:last-child) { &:not(:last-child) {

View File

@@ -0,0 +1,57 @@
<template>
<div v-if="open" class="move-menu layout-block">
<template v-for="(category, i) in categories">
<category-row
v-if="category !== fromCategory"
:category="category"
:index="i"
wrapper="button"
@click="onCategoryClick(category)"
:key="category"
/>
</template>
</div>
</template>
<script setup>
import CategoryRow from '@/components/CategoryRow.vue'
import { computed, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import useNotes from '@/composables/useNotes'
import useState from '@/composables/useState'
import _omit from 'lodash/omit'
const { categories, updateNote } = useNotes()
const route = useRoute()
const router = useRouter()
const open = computed(() => route.query.move !== undefined)
const noteId = computed(() => route.query.move)
const fromCategory = computed(() => route.params.id)
const close = async () => {
await router.push({
query: _omit(route.query, ['move']),
})
await window.api.moveClosed()
}
const onCategoryClick = async (category) => {
if (!category || !noteId.value) return
await updateNote(noteId.value, { category: category })
await close()
}
watch(open, async () => {
if (!open.value) await close()
})
</script>
<style lang="scss">
.move-menu {
width: 50vw;
height: 100%;
border-left: 1px solid var(--grey-100);
}
</style>

View File

@@ -1,12 +1,13 @@
<template> <template>
<nav class="nav layout-block-inner"> <nav class="nav layout-block">
<button @click="toggleMenu">Menu</button> <button @click="toggleMenu">Menu</button>
<router-link to="/search">Search</router-link>
</nav> </nav>
</template> </template>
<script setup> <script setup>
import useMenu from '@/composables/useMenu' import useMenu from '@/composables/useMenu'
import { onMounted } from 'vue'
const { menuOpen, closeMenu, openMenu } = useMenu() const { menuOpen, closeMenu, openMenu } = useMenu()
@@ -17,19 +18,15 @@ const toggleMenu = () => {
openMenu() openMenu()
} }
} }
onMounted(() => {
// Initialize menu state or perform any other necessary setup
// Example: Check if the user is logged in and update menu accordingly
// if (isLoggedIn()) {
// openMenu()
// }
})
</script> </script>
<style lang="scss"> <style lang="scss">
.nav { .nav {
padding-top: size-vw(9px); position: absolute;
display: flex;
align-items: center;
gap: 10px;
padding-top: 9px;
color: var(--grey-100); color: var(--grey-100);
} }
</style> </style>

View File

@@ -1,12 +1,26 @@
<template> <template>
<button class="note-row" @click="openNote(note.id)"> <div :class="['note-row', { 'move-active': moveActive }]">
<span class="date">{{ formatDate(note.createdAt) }}</span> <span class="date">{{ formatDate(note.createdAt) }}</span>
<span class="title bold">{{ note.title }}</span> <div class="title-actions">
</button> <button class="title bold" @click="openNote(note.id)">
{{ note.title }}
</button>
<button class="action bold" @click="openNote(note.id)">
(open)
</button>
<button class="action bold move" @click="onMoveOpened">
(move)
</button>
</div>
</div>
</template> </template>
<script setup> <script setup>
import useOpenNote from '@/composables/useOpenNote' import useOpenNote from '@/composables/useOpenNote'
import useState from '@/composables/useState'
import { useRoute, useRouter } from 'vue-router'
import { computed } from 'vue'
import { format } from 'fecha' import { format } from 'fecha'
const props = defineProps({ note: Object }) const props = defineProps({ note: Object })
@@ -17,35 +31,56 @@ const formatDate = (date) => {
const d = new Date(date) const d = new Date(date)
return format(d, 'MM/DD/YYYY') return format(d, 'MM/DD/YYYY')
} }
// Moving
const route = useRoute()
const router = useRouter()
const onMoveOpened = async () => {
await window.api.moveOpened()
await router.push({
query: {
...route.query,
move: props.note.id,
},
})
console.log(route.query)
}
const moveActive = computed(() => route.query.move === props.note.id)
</script> </script>
<style lang="scss"> <style lang="scss">
.note-row { .note-row {
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
display: grid; display: grid;
gap: size-vw(20px); width: 100%;
cursor: pointer; gap: 20px;
.title { .title-actions {
width: size-vw(159px); display: grid;
position: relative; grid-template-columns: 1fr auto auto;
align-items: flex-start;
gap: 2px;
&::after { .action {
content: '(open)';
position: absolute;
bottom: 0;
right: 0;
transform: translateX(100%);
font-weight: 700;
opacity: 0; opacity: 0;
&:not(:hover) {
color: var(--grey-100);
}
} }
} }
&:hover { &:hover,
&.move-active {
color: var(--theme-accent); color: var(--theme-accent);
.title::after { .title-actions .action {
opacity: 1; opacity: 1;
} }
} }
&.move-active {
.title-actions .move {
color: var(--theme-accent);
}
}
} }
</style> </style>

View File

@@ -75,7 +75,7 @@ watch(
top: 0; top: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
width: size-vw(8px); width: 8px;
will-change: transform; will-change: transform;
border-left: 1px solid var(--grey-100); border-left: 1px solid var(--grey-100);
@@ -85,7 +85,7 @@ watch(
.handle { .handle {
width: 100%; width: 100%;
height: size-vw(388px); height: 388px;
background: var(--grey-100); background: var(--grey-100);
border-radius: 20px; border-radius: 20px;
position: absolute; position: absolute;

View File

@@ -0,0 +1,49 @@
<template>
<div v-if="config" class="theme-switcher">
<span>Change Theme</span>
<button
v-for="(value, key) in themes"
:class="[`theme-${key}`, { active: config.theme === key }]"
@click="setTheme(key)"
/>
</div>
</template>
<script setup>
import useConfig from '@/composables/useConfig'
import { themes } from '@/libs/theme'
const { config } = useConfig()
const setTheme = (value) => {
if (!config.value) return
config.value.theme = value
}
</script>
<style lang="scss">
.theme-switcher {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
button {
background: var(--theme-bg);
display: block;
width: 14px;
height: 14px;
border-radius: 50%;
border: none;
cursor: pointer;
&.active {
border: 1px solid var(--theme-fg);
}
}
&:hover {
color: var(--theme-fg) !important;
}
}
</style>

View File

@@ -0,0 +1,303 @@
<template>
<div v-if="editor" class="note-editor">
<editor-content :editor="editor" class="editor-wrap" />
<editor-menu :editor="editor" />
</div>
</template>
<script setup>
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import { onBeforeUnmount, onMounted, shallowRef } from 'vue'
import { TaskList, TaskItem } from '@tiptap/extension-list'
import { Highlight } from '@tiptap/extension-highlight'
import { Editor, EditorContent } from '@tiptap/vue-3'
import Document from '@tiptap/extension-document'
import { Placeholder } from '@tiptap/extensions'
import { all, createLowlight } from 'lowlight'
import useNotes from '@/composables/useNotes'
import StarterKit from '@tiptap/starter-kit'
import _debounce from 'lodash/debounce'
import EditorMenu from './Menu.vue'
const props = defineProps({
id: {
type: String,
required: true,
},
})
const editor = shallowRef()
const { loadNote, updateNote } = useNotes()
const onUpdate = _debounce(async ({ editor }) => {
const json = editor.getJSON()
const text = editor.getText()
// Get doc title
let title
const doc = editor.state.doc
const firstNode = doc.firstChild
if (!firstNode || firstNode.type.name !== 'heading') title = 'Untitled'
title = firstNode.textContent.trim() || 'Untitled'
await updateNote(props.id, {
title,
content: json,
plainText: text,
})
}, 300)
onMounted(async () => {
const note = await loadNote(props.id)
// Lowlight setup
const lowlight = createLowlight(all)
// Force note format
const CustomDocument = Document.extend({
content: 'heading block*',
})
editor.value = new Editor({
extensions: [
CustomDocument,
StarterKit.configure({
document: false,
heading: { levels: [1] },
trailingNode: {
node: 'paragraph',
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === 'heading') {
return 'Title'
}
},
}),
TaskList,
TaskItem,
Highlight,
CodeBlockLowlight.configure({
lowlight,
enableTabIndentation: true,
}),
],
content: note.content || [],
onUpdate: onUpdate,
})
})
onBeforeUnmount(() => {
editor.value?.destroy?.()
})
</script>
<style lang="scss">
.note-editor {
h1 {
font-weight: 700 !important;
@include p;
&:first-child {
@include drop-cap;
}
}
h1.is-editor-empty:first-child::before {
color: var(--grey-100);
content: attr(data-placeholder);
pointer-events: none;
@include drop-cap;
}
p strong {
font-weight: 700;
}
p em {
/* font-style: italic; */
color: var(--grey-100);
}
hr {
border: 1px dashed currentColor;
}
ul {
list-style-type: disc;
li {
display: list-item;
margin-left: 1em;
*:not(:last-child) {
margin-bottom: 0.5em;
}
}
}
ol {
list-style-type: decimal;
li {
display: list-item;
margin-left: 1.5em;
&::marker {
@include p;
}
}
}
li:not(:last-child) {
margin-bottom: 0.5em;
}
a {
color: var(--theme-link);
cursor: pointer;
}
code {
border: 1px solid var(--grey-100);
color: var(--theme-accent);
padding: 0 0.2em;
border-radius: 0.2em;
}
pre code {
display: block;
color: inherit;
padding: 1em;
/* Code styling */
.hljs-comment,
.hljs-quote {
color: #616161;
}
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #f98181;
}
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #fbbc88;
}
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #b9f18d;
}
.hljs-title,
.hljs-section {
color: #faf594;
}
.hljs-keyword,
.hljs-selector-tag {
color: #70cff8;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 700;
}
}
blockquote {
border-left: 4px solid var(--grey-100);
padding-left: 0.5em;
}
s {
position: relative;
&::after {
content: ' ';
display: block;
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: currentColor;
}
}
mark {
background: var(--theme-accent);
color: var(--theme-bg);
padding: 0 0.2em;
}
ul[data-type='taskList'] {
list-style: none;
margin-left: 0;
padding: 0;
li {
align-items: center;
display: flex;
margin-left: 0;
> label {
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
margin-bottom: 0;
input {
margin: 0;
}
}
> div {
flex: 1 1 auto;
}
}
input[type='checkbox'] {
cursor: pointer;
display: block;
width: 1.5em;
height: 1.5em;
border: 1px solid var(--grey-100);
border-radius: 0.2em;
&::after {
content: '✓';
font-size: 1.5em;
opacity: 0;
}
&:checked::after {
opacity: 1;
}
}
ul[data-type='taskList'] {
margin: 0;
}
}
.editor-wrap {
> div {
display: flex;
flex-direction: column;
gap: 20px;
&:focus {
outline: none;
}
}
}
}
</style>

View File

@@ -0,0 +1,12 @@
<template>
<div class="note-find"></div>
</template>
<script setup>
const props = defineProps({})
</script>
<style lang="scss">
.note-find {
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<bubble-menu v-if="editor" :editor="editor">
<div class="note-menu">
<button
@click="editor.chain().focus().toggleBold().run()"
:class="{ active: editor.isActive('bold') }"
>
Bold
</button>
<button
@click="editor.chain().focus().toggleItalic().run()"
:class="{ active: editor.isActive('italic') }"
>
Italic
</button>
<button
@click="editor.chain().focus().toggleHighlight().run()"
:class="{ active: editor.isActive('highlight') }"
>
Highlight
</button>
</div>
</bubble-menu>
</template>
<script setup>
import { BubbleMenu } from '@tiptap/vue-3/menus'
const props = defineProps({
editor: {
type: Object,
required: true,
},
})
</script>
<style lang="scss">
.note-menu {
display: flex;
gap: 5px;
border: 1px solid var(--grey-100);
color: var(--grey-100);
border-radius: 0.2em;
background: var(--theme-bg);
button {
cursor: pointer;
padding: 0.2em;
border-radius: 0.2em;
&:hover {
background: var(--grey-100);
color: var(--theme-bg);
}
&.active {
background: var(--theme-fg);
color: var(--theme-bg);
}
}
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<svg
class="svg-spinner"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
style="margin: auto; display: block"
width="18px"
height="18px"
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid"
>
<circle
cx="50"
cy="50"
r="40"
stroke-width="4"
stroke="currentColor"
stroke-dasharray="62 62"
fill="none"
stroke-linecap="round"
>
<animateTransform
attributeName="transform"
type="rotate"
repeatCount="indefinite"
dur="1s"
keyTimes="0;1"
values="0 50 50;360 50 50"
></animateTransform>
</circle>
</svg>
</template>

View File

@@ -1,10 +0,0 @@
<template>
<svg
class="svg-icon-hr"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M2 11H4V13H2V11ZM6 11H18V13H6V11ZM20 11H22V13H20V11Z"></path>
</svg>
</template>

View File

@@ -0,0 +1,42 @@
import { ref, watch, toRaw, onMounted } from 'vue'
const config = ref()
let configResolve = null
const configPromise = new Promise((resolve) => {
configResolve = resolve
})
export default () => {
onMounted(async () => {
if (config.value) {
configResolve()
return
}
config.value = await window.api.getConfig()
configResolve()
})
watch(
config,
async (newValue) => {
await window.api.setConfig(toRaw(newValue))
},
{ deep: true },
)
const ensureConfig = async () => {
if (config.value) return config.value
return configPromise
}
const refreshConfig = async () => {
config.value = await window.api.getConfig()
configResolve()
}
return {
config,
ensureConfig,
refreshConfig,
}
}

View File

@@ -0,0 +1,41 @@
export enum ENVIRONMENTS {
ELECTRON = 'electron',
WEB = 'web',
}
export const useEnvironment = (): ENVIRONMENTS => {
function isElectron() {
// Renderer process
if (
typeof window !== 'undefined' &&
typeof window.process === 'object' &&
window.process.type === 'renderer'
) {
return true
}
// Main process
if (
typeof process !== 'undefined' &&
typeof process.versions === 'object' &&
!!process.versions.electron
) {
return true
}
// Detect the user agent when the `nodeIntegration` option is set to true
if (
typeof navigator === 'object' &&
typeof navigator.userAgent === 'string' &&
navigator.userAgent.indexOf('Electron') >= 0
) {
return true
}
return false
}
const environment = isElectron() ? ENVIRONMENTS.ELECTRON : ENVIRONMENTS.WEB
return environment
}

View File

@@ -1,14 +0,0 @@
import { inject, onBeforeUnmount } from 'vue'
export default (callback = () => {}, instanceId) => {
const instanceKey = `lenis${instanceId ? `-${instanceId}` : ''}`
const lenis = inject(instanceKey)
if (lenis.value) {
lenis.value.on('scroll', callback)
}
onBeforeUnmount(() => lenis.value?.off('scroll', callback))
return lenis
}

View File

@@ -6,7 +6,7 @@ export default () => {
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const menuOpen = computed(() => route.query.menuOpen === 'true') const menuOpen = computed(() => route.query?.menuOpen === 'true')
const closeMenu = () => { const closeMenu = () => {
router.push({ router.push({

View File

@@ -1,74 +1,142 @@
import _omit from 'lodash/omit'
import { ref } from 'vue' import { ref } from 'vue'
import { getNotesAPI } from '@/libs/core/getNotesAPI'
const categories = ref([]) const categories = ref([])
const searchResults = ref([]) const searchResults = ref([])
const notesChangeCount = ref(0)
let listenersInitialized = false
const setupListeners = () => {
if (listenersInitialized || typeof window === 'undefined') return
listenersInitialized = true
const updateCacheCount = async (note) => {
const api = await getNotesAPI()
await api.updateNote(
note.id,
_omit(note, ['id', 'createdAt', 'updatedAt']),
)
notesChangeCount.value++
}
window.api.onNoteCreated(updateCacheCount)
window.api.onNoteUpdated(updateCacheCount)
window.api.onPluginChanged(async () => {
const api = await getNotesAPI()
await api.init()
notesChangeCount.value++
})
// Todo update cache
window.api.onNoteDeleted(() => {
notesChangeCount.value++
})
}
const broadcastChange = (event, data) => {
window.api.notifyNoteChanged(event, data)
}
setupListeners()
export default () => { export default () => {
/* ------------------------- /* -------------------------
Initialization Initialization
--------------------------*/ --------------------------*/
async function loadCategories() { const loadCategories = async () => {
categories.value = await window.notesAPI.call('getCategories') const api = await getNotesAPI()
categories.value = api.getCategories()
} }
async function loadCategoryNotes(category = null) { const loadCategoryNotes = async (category = null) => {
return await window.notesAPI.call('getCategoryNotes', category) const api = await getNotesAPI()
return api.getCategoryNotes(category)
} }
async function loadNote(id) { const loadNote = async (id) => {
return await window.notesAPI.call('getNote', id) const api = await getNotesAPI()
return api.getNote(id)
} }
/* ------------------------- /* -------------------------
Create Create
--------------------------*/ --------------------------*/
async function createNote(metadata, content) { const createNote = async (metadata, content, plainText = '') => {
const note = await window.notesAPI.call('createNote', metadata, content) const api = await getNotesAPI()
const note = await api.createNote(metadata, content, plainText)
await loadCategories() await loadCategories()
broadcastChange('note-created', note)
return note return note
} }
/* ------------------------- /* -------------------------
Update Update
--------------------------*/ --------------------------*/
async function updateNoteContent(id, content) { const updateNote = async (id, updates) => {
const note = await window.notesAPI.call('updateNote', id, content) const api = await getNotesAPI()
const note = await api.updateNote(id, updates)
if (updates.category !== undefined || updates.title !== undefined) {
await loadCategories()
}
broadcastChange('note-updated', note)
return note return note
} }
async function updateNoteMetadata(id, updates) { const updateCategory = async (category, update) => {
const note = await window.notesAPI.call( const notes = await loadCategoryNotes(category)
'updateNoteMetadata',
id, for (const note of notes) {
updates, await updateNote(note.id, { category: update })
) }
await loadCategories() await loadCategories()
return note }
/* -------------------------
Delete
--------------------------*/
const deleteNote = async (id) => {
const api = await getNotesAPI()
await api.deleteNote(id)
await loadCategories()
broadcastChange('note-deleted', { id })
} }
/* ------------------------- /* -------------------------
Search Search
--------------------------*/ --------------------------*/
async function search(query) { const search = async (query) => {
const api = await getNotesAPI()
if (!query) { if (!query) {
searchResults.value = [] searchResults.value = []
return return
} }
searchResults.value = await window.notesAPI.call('search', query) searchResults.value = api.search(query)
} }
return { return {
categories, categories,
searchResults, searchResults,
notesChangeCount,
loadCategories, loadCategories,
loadCategoryNotes, loadCategoryNotes,
loadNote, loadNote,
createNote, createNote,
updateNoteContent, updateNote,
updateNoteMetadata, updateCategory,
deleteNote,
search, search,
} }

View File

@@ -1,4 +1,5 @@
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useEnvironment, ENVIRONMENTS } from './useEnvironment'
export default () => { export default () => {
const router = useRouter() const router = useRouter()
@@ -6,13 +7,9 @@ export default () => {
function openNote(noteId, options = {}) { function openNote(noteId, options = {}) {
const { newWindow = true } = options const { newWindow = true } = options
// Electron environment check const environment = useEnvironment()
const isElectron =
typeof window !== 'undefined' &&
window.api &&
typeof window.api.openNoteWindow === 'function'
if (newWindow && isElectron) { if (newWindow && environment === ENVIRONMENTS.ELECTRON) {
window.api.openNoteWindow(noteId) window.api.openNoteWindow(noteId)
return return
} }

View File

@@ -0,0 +1,20 @@
import { ref } from 'vue'
import useConfig from './useConfig'
export default async () => {
const { refreshConfig } = useConfig()
const plugins = ref([])
plugins.value = await window.api.listPlugins()
const setActivePlugin = async (pluginId) => {
await window.api.setActivePlugin(pluginId)
await refreshConfig()
}
return {
plugins,
setActivePlugin,
}
}

View File

@@ -1,29 +0,0 @@
import { useWindowSize } from '@vueuse/core'
import { viewports } from '@/libs/theme'
const { width: wWidth, height: wHeight } = useWindowSize()
export default () => {
// Desktop
const dvw = (pixels) => {
return (pixels / viewports.desktop.width) * wWidth.value
}
const dvh = (pixels) => {
return (pixels / viewports.desktop.height) * wHeight.value
}
// Mobile
const mvw = (pixels) => {
return (pixels / viewports.mobile.width) * wWidth.value
}
const mvh = (pixels) => {
return (pixels / viewports.mobile.height) * wHeight.value
}
return {
dvw,
dvh,
mvw,
mvh,
}
}

View File

@@ -1,46 +0,0 @@
import {
useElementBounding,
useIntersectionObserver,
useWindowSize,
} from '@vueuse/core'
import { ref } from 'vue'
import { mapRange, clamp } from '@/libs/math'
import useLenis from '@/composables/useLenis'
const { height: wHeight } = useWindowSize()
export const useScrollProgress = (el, callback, entry = 0.5, exit = 0.5) => {
const isActive = ref(true)
const smoothProgress = ref(0)
const { height, top } = useElementBounding(el)
const isIntersected = ref(false)
const { stop } = useIntersectionObserver(el, ([{ isIntersecting }]) => {
isIntersected.value = isIntersecting
})
useLenis(({ scroll }) => {
if (!isActive.value) return
if (!height.value || !wHeight.value) return
if (!isIntersected.value) return
const pageTop = scroll + top.value
const start = pageTop - wHeight.value * entry
const end = pageTop + height.value - wHeight.value * exit
let rawProgress = mapRange(start, end, scroll, 0, 1)
rawProgress = clamp(0, rawProgress, 1)
smoothProgress.value += (rawProgress - smoothProgress.value) * 0.1
callback?.(smoothProgress.value)
})
const destroy = () => {
isActive.value = false
stop?.()
}
return { destroy }
}

View File

@@ -0,0 +1,8 @@
import { createGlobalState } from '@vueuse/core'
import { ref } from 'vue'
export default createGlobalState(() => {
const moveMenuOpen = ref(false)
return { moveMenuOpen }
})

View File

@@ -0,0 +1,28 @@
Medieval Translation
Nota = Note\
Capitulum = Category\
Intructio = Instructions\
Tabula = Index/Overview
\*This can be disabled via toolbar
---
Program Key Commands
cmd + s = save\
cmd + t = new capitulum\
cmd + n = new nota\
cmd + x = close window\
dbl click = change name / open nota\
paste hyperlink twice = activated url
---
Text Markdowns
cmd + b = Bold\
cmd + u = underline\
--- = ---------- (ruled line break)\
/_text_/ = Desaturated text

View File

@@ -0,0 +1,25 @@
export default class IpcAdapter {
constructor() {
this._methods = ['init', 'getAll', 'create', 'update', 'delete']
}
async init() {
return await window.adapter.call('init')
}
async getAll() {
return await window.adapter.call('getAll')
}
async create(note) {
return await window.adapter.call('create', note)
}
async update(note) {
return await window.adapter.call('update', note)
}
async delete(id) {
return await window.adapter.call('delete', id)
}
}

View File

@@ -1,6 +1,7 @@
import { Index } from 'flexsearch'
import crypto from 'crypto'
import sodium from 'libsodium-wrappers' import sodium from 'libsodium-wrappers'
import { v4 as uuidv4 } from 'uuid'
import { Index } from 'flexsearch'
import * as uint from '@/libs/uint'
export default class NotesAPI { export default class NotesAPI {
constructor(adapter, encryptionKey = null) { constructor(adapter, encryptionKey = null) {
@@ -10,12 +11,11 @@ export default class NotesAPI {
this.adapter = adapter this.adapter = adapter
this.notesCache = new Map() this.notesCache = new Map()
this.encryptionKey = encryptionKey || process.env.NOTES_ENCRYPTION_KEY this.encryptionKey = encryptionKey
this._sodiumReady = false this._sodiumReady = false
this.index = new Index({ this.index = new Index({
tokenize: 'tolerant', tokenize: 'forward',
resolution: 9,
}) })
} }
@@ -31,22 +31,24 @@ export default class NotesAPI {
throw new Error('Encryption key not set') throw new Error('Encryption key not set')
} }
const key = Buffer.from(this.encryptionKey, 'hex') const key = uint.hexToUint8Array(this.encryptionKey)
if (key.length !== 32) { if (key.length !== 32) {
throw new Error('Encryption key must be 64 hex characters (32 bytes)') throw new Error(
'Encryption key must be 64 hex characters (32 bytes)',
)
} }
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES) const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES)
const message = JSON.stringify(note) const message = JSON.stringify(note)
const ciphertext = sodium.crypto_secretbox_easy( const ciphertext = sodium.crypto_secretbox_easy(
Buffer.from(message), new TextEncoder().encode(message),
nonce, nonce,
key key,
) )
const combined = Buffer.concat([nonce, ciphertext]) const combined = uint.concatUint8Arrays(nonce, ciphertext)
return combined.toString('base64') return uint.uint8ArrayToBase64(combined)
} }
_decrypt(encryptedData) { _decrypt(encryptedData) {
@@ -54,41 +56,91 @@ export default class NotesAPI {
throw new Error('Encryption key not set') throw new Error('Encryption key not set')
} }
const key = Buffer.from(this.encryptionKey, 'hex') const key = uint.hexToUint8Array(this.encryptionKey)
if (key.length !== 32) { if (key.length !== 32) {
throw new Error('Encryption key must be 64 hex characters (32 bytes)') throw new Error(
'Encryption key must be 64 hex characters (32 bytes)',
)
}
let combined
try {
combined = uint.base64ToUint8Array(encryptedData)
} catch (e) {
throw new Error('Invalid encrypted data: not valid base64')
}
if (
combined.length <
sodium.crypto_secretbox_NONCEBYTES +
sodium.crypto_secretbox_MACBYTES
) {
throw new Error('Invalid encrypted data: too short')
} }
const combined = Buffer.from(encryptedData, 'base64')
const nonce = combined.slice(0, sodium.crypto_secretbox_NONCEBYTES) const nonce = combined.slice(0, sodium.crypto_secretbox_NONCEBYTES)
const ciphertext = combined.slice(sodium.crypto_secretbox_NONCEBYTES) const ciphertext = combined.slice(sodium.crypto_secretbox_NONCEBYTES)
const decrypted = sodium.crypto_secretbox_open_easy( let decrypted
ciphertext, try {
nonce, decrypted = sodium.crypto_secretbox_open_easy(
key ciphertext,
) nonce,
key,
)
} catch (e) {
throw new Error('Decryption failed: wrong key or corrupted data')
}
return JSON.parse(decrypted.toString()) if (!decrypted) {
throw new Error('Decryption failed: no data returned')
}
const decryptedStr = new TextDecoder().decode(decrypted)
try {
return JSON.parse(decryptedStr)
} catch (e) {
throw new Error(
`Decryption succeeded but invalid JSON: ${decryptedStr}`,
)
}
} }
async init() { async init() {
await this._initSodium() await this._initSodium()
await this.adapter.init() await this.adapter.init()
this.notesCache.clear()
const encryptedNotes = await this.adapter.getAll() const encryptedNotes = await this.adapter.getAll()
for (const encryptedNote of encryptedNotes) { for (const encryptedNote of encryptedNotes) {
try { try {
const note = this._decrypt(encryptedNote.data || encryptedNote) const note = this._decrypt(encryptedNote.data || encryptedNote)
this.notesCache.set(note.id, note) this.notesCache.set(note.id, note)
this.index.add(note.id, note.title + '\n' + note.content) const searchText =
note.plainText || this._extractPlainText(note.content)
this.index.add(note.id, note.title + '\n' + searchText)
} catch (error) { } catch (error) {
console.error('Failed to decrypt note:', error) console.error('Failed to decrypt note:', error)
} }
} }
} }
_extractPlainText(content) {
if (!content) return ''
if (typeof content === 'string') return content
const extractText = (node) => {
if (typeof node === 'string') return node
if (!node || !node.content) return ''
return node.content.map(extractText).join(' ')
}
return extractText(content)
}
/* ----------------------- /* -----------------------
Public API Public API
------------------------*/ ------------------------*/
@@ -104,18 +156,20 @@ export default class NotesAPI {
return Array.from(categories).sort() return Array.from(categories).sort()
} }
getCategoryNotes(categoryName) { getCategoryNotes(categoryName = null) {
return Array.from(this.notesCache.values()) return Array.from(this.notesCache.values())
.filter((n) => n.category === categoryName) .filter((n) => n.category === categoryName)
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)) .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
.map((n) => ({ ...n }))
} }
getNote(id) { getNote(id) {
return this.notesCache.get(id) ?? null const note = this.notesCache.get(id)
return note ? { ...note } : null
} }
async createNote(metadata = {}, content = '') { async createNote(metadata = {}, content = '', plainText = '') {
const id = crypto.randomUUID() const id = uuidv4()
const now = new Date().toISOString() const now = new Date().toISOString()
const note = { const note = {
@@ -125,6 +179,7 @@ export default class NotesAPI {
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
content, content,
plainText,
} }
const encryptedNote = { const encryptedNote = {
@@ -133,7 +188,7 @@ export default class NotesAPI {
} }
this.notesCache.set(id, note) this.notesCache.set(id, note)
this.index.add(id, note.title + '\n' + content) this.index.add(id, note.title + '\n' + plainText)
await this.adapter.create(encryptedNote) await this.adapter.create(encryptedNote)
@@ -147,60 +202,46 @@ export default class NotesAPI {
this.index.remove(id) this.index.remove(id)
} }
async updateNote(id, content) { async updateNote(id, updates = {}) {
const note = this.notesCache.get(id) const note = this.notesCache.get(id)
if (!note) throw new Error('Note not found') if (!note) throw new Error('Note not found')
note.content = content const allowedFields = ['title', 'category', 'content', 'plainText']
note.updatedAt = new Date().toISOString()
const encryptedNote = {
id: note.id,
data: this._encrypt(note),
}
this.index.update(id, note.title + '\n' + content)
await this.adapter.update(encryptedNote)
return note
}
async updateNoteMetadata(id, updates = {}) {
const note = this.notesCache.get(id)
if (!note) throw new Error('Note not found')
const allowedFields = ['title', 'category']
for (const key of Object.keys(updates)) { for (const key of Object.keys(updates)) {
if (!allowedFields.includes(key)) { if (!allowedFields.includes(key)) {
throw new Error(`Invalid metadata field: ${key}`) throw new Error(`Invalid update field: ${key}`)
} }
} }
if (updates.title !== undefined) { const updatedNote = {
note.title = updates.title ...note,
...updates,
updatedAt: new Date().toISOString(),
} }
if (updates.category !== undefined) {
note.category = updates.category
}
note.updatedAt = new Date().toISOString()
const encryptedNote = { const encryptedNote = {
id: note.id, id: updatedNote.id,
data: this._encrypt(note), data: this._encrypt(updatedNote),
} }
this.index.update(id, note.title + '\n' + note.content) this.notesCache.set(id, updatedNote)
const searchText =
updatedNote.plainText || this._extractPlainText(updatedNote.content)
this.index.update(id, updatedNote.title + '\n' + searchText)
await this.adapter.update(encryptedNote) await this.adapter.update(encryptedNote)
return note return updatedNote
} }
search(query) { search(query) {
const ids = this.index.search(query) const ids = this.index.search(query, {
limit: 50,
suggest: true,
})
return ids.map((id) => this.notesCache.get(id)) return ids.map((id) => this.notesCache.get(id))
} }
} }

View File

@@ -0,0 +1,47 @@
import NotesAPI from '@/libs/core/NotesAPI.js'
import IpcAdapter from '@/libs/core/IpcAdapter.js'
import useConfig from '@/composables/useConfig.js'
// Singleton pattern to make sure only one instance of NotesAPI exists
let notesAPI = null
let initPromise = null
const generateEncryptionKey = () => {
const array = new Uint8Array(32)
crypto.getRandomValues(array)
return Array.from(array)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
const createInstance = async () => {
const { config, ensureConfig } = useConfig()
await ensureConfig()
let encryptionKey = config.value?.encryptionKey
if (!encryptionKey) {
encryptionKey = generateEncryptionKey()
config.value.encryptionKey = encryptionKey
}
const adapter = new IpcAdapter()
const api = new NotesAPI(adapter, encryptionKey)
await api.init()
return api
}
export const getNotesAPI = async () => {
if (notesAPI) return notesAPI
if (!initPromise) {
initPromise = createInstance().then((api) => {
notesAPI = api
return api
})
}
return initPromise
}

View File

@@ -7,40 +7,23 @@ const colors = {
} }
const themes = { const themes = {
light: {
bg: colors.white,
fg: colors.black,
accent: colors.green,
link: colors.blue,
},
dark: { dark: {
bg: colors.black, bg: colors.black,
fg: colors.white, fg: colors.white,
accent: colors.green, accent: colors.green,
link: colors.blue, link: colors.blue,
}, },
} light: {
bg: colors.white,
const breakpoints = { fg: colors.black,
mobile: 800, accent: colors.green,
} link: colors.blue,
const viewports = {
mobile: {
width: 200,
height: 956,
},
desktop: {
width: 354,
height: 549,
}, },
} }
export { colors, themes, breakpoints, viewports } export { colors, themes }
export default { export default {
colors, colors,
themes, themes,
breakpoints,
viewports,
} }

View File

@@ -0,0 +1,35 @@
export const hexToUint8Array = (hex) => {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16)
}
return bytes
}
export const base64ToUint8Array = (base64) => {
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
}
export const uint8ArrayToBase64 = (bytes) => {
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
export const concatUint8Arrays = (...arrays) => {
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0)
const result = new Uint8Array(totalLength)
let offset = 0
for (const arr of arrays) {
result.set(arr, offset)
offset += arr.length
}
return result
}

View File

@@ -1,18 +1,24 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import Directory from '@/views/Directory.vue' import Directory from '@/views/Directory.vue'
import Editor from '@/views/Editor.vue' import Note from '@/views/Note.vue'
import CreateCategory from '@/views/CreateCategory.vue'
import Category from '@/views/Category.vue' import Category from '@/views/Category.vue'
import Instructions from '@/views/Instructions.vue' import Instructions from '@/views/Instructions.vue'
import Search from '@/views/Search.vue'
import Preferences from '@/views/Preferences.vue'
const routes = [ const routes = [
{ path: '/', name: 'directory', component: Directory }, { path: '/', name: 'directory', component: Directory },
{ path: '/note/:id', name: 'note', component: Editor }, { path: '/note/:id', name: 'note', component: Note },
{ path: '/category', name: 'create-category', component: CreateCategory },
{ path: '/category/:id', name: 'category', component: Category }, { path: '/category/:id', name: 'category', component: Category },
{ path: '/instructions', name: 'instructions', component: Instructions }, { path: '/instructions', name: 'instructions', component: Instructions },
{ path: '/search', name: 'search', component: Search },
{ path: '/preferences', name: 'preferences', component: Preferences },
] ]
export const router = createRouter({ export const router = createRouter({
history: createWebHistory(), history: createWebHashHistory(),
routes, routes,
}) })

View File

@@ -5,19 +5,19 @@
font-weight: 400; font-weight: 400;
letter-spacing: -0.02em; letter-spacing: -0.02em;
line-height: 1.3; line-height: 1.3;
font-size: size-vw(30px); font-size: 30px;
} }
@mixin h1-mono { @mixin h1-mono {
font-family: var(--font-mono); font-family: var(--font-mono);
font-weight: 400; font-weight: 400;
line-height: 1; line-height: 1;
font-size: size-vw(22px); font-size: 22px;
} }
@mixin p { @mixin p {
font-family: var(--font-mono); font-family: var(--font-mono);
font-weight: 400; font-weight: 400;
line-height: 1; line-height: 1;
font-size: size-vw(12px); font-size: 12px;
} }

View File

@@ -17,8 +17,8 @@
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
src: src:
url('../../../../resources/fonts/leibniz-fraktur.woff2') format('woff2'), url('../../../../resources/fonts/neuefraktur.woff2') format('woff2'),
url('../../../../resources/fonts/leibniz-fraktur.woff') format('woff'); url('../../../../resources/fonts/neuefraktur.woff') format('woff');
} }
/* Geist Mono */ /* Geist Mono */

View File

@@ -1,58 +1,3 @@
@use 'sass:math';
/* Breakpoints */
$mobile-breakpoint: get('breakpoints.mobile');
// Viewport Sizes
$desktop-width: get('viewports.desktop.width');
$desktop-height: get('viewports.desktop.height');
$mobile-width: get('viewports.mobile.width');
$mobile-height: get('viewports.mobile.height');
// Breakpoint
@mixin mobile {
@media (max-width: #{$mobile-breakpoint * 1px - 1px}) {
@content;
}
}
@mixin desktop {
@media (min-width: #{$mobile-breakpoint * 1px}) {
@content;
}
}
@function mobile-vw($pixels, $base-vw: $mobile-width) {
$px: math.div($pixels, $base-vw);
$perc: math.div($px, 1px);
@return calc($perc * 100vw);
}
@function mobile-vh($pixels, $base-vh: $mobile-height) {
$px: math.div($pixels, $base-vh);
$perc: math.div($px, 1px);
@return calc($perc * 100vh);
}
@function desktop-vw($pixels, $base-vw: $desktop-width) {
$px: math.div($pixels, $base-vw);
$perc: math.div($px, 1px);
@return calc($perc * 100vw);
}
@function desktop-vh($pixels, $base-vh: $desktop-height) {
$px: math.div($pixels, $base-vh);
$perc: math.div($px, 1px);
@return calc($perc * 100vh);
}
@function size-vw($pixels, $base-vw: 354) {
$px: math.div($pixels, $base-vw);
$perc: math.div($px, 1px);
@return calc($perc * 100vw);
}
@function columns($columns) { @function columns($columns) {
@return calc( @return calc(
(#{$columns} * var(--layout-column-width)) + (#{$columns} * var(--layout-column-width)) +
@@ -72,14 +17,6 @@ $mobile-height: get('viewports.mobile.height');
} }
} }
@mixin fill($position: absolute) {
position: #{$position};
bottom: 0;
right: 0;
left: 0;
top: 0;
}
@mixin fade-on-ready($class: 'ready', $duration: 400ms) { @mixin fade-on-ready($class: 'ready', $duration: 400ms) {
opacity: 0; opacity: 0;
transition: opacity $duration ease; transition: opacity $duration ease;
@@ -90,90 +27,12 @@ $mobile-height: get('viewports.mobile.height');
} }
// Clamp text block to number of lines // Clamp text block to number of lines
@mixin line-clamp($lines: 3, $mobile-lines: $lines) { @mixin line-clamp($lines: 3) {
display: -webkit-box; display: -webkit-box;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: $lines; -webkit-line-clamp: $lines;
@include mobile {
-webkit-line-clamp: $mobile-lines;
}
}
// Flip animations
@keyframes flip-r {
50% {
transform: translateX(100%);
opacity: 0;
}
51% {
transform: translateX(-100%);
opacity: 0;
}
}
@keyframes flip-l {
50% {
transform: translateX(-100%);
opacity: 0;
}
51% {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes flip-d {
50% {
transform: translateY(100%);
opacity: 0;
}
51% {
transform: translateY(-100%);
opacity: 0;
}
}
@keyframes flip-u {
50% {
transform: translateY(-100%);
opacity: 0;
}
51% {
transform: translateY(100%);
opacity: 0;
}
}
@mixin flip-animation(
$direction: 'r',
$duration: 600ms,
$easing: var(--ease-out-expo),
$iteration-count: 1
) {
overflow: hidden;
animation: flip-#{$direction} $duration $easing $iteration-count forwards;
}
@mixin link-hover {
position: relative;
&::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: var(--theme-fg);
transform-origin: left;
transform: scaleX(0);
transition: transform 300ms var(--ease-out-quad);
}
@include desktop {
&:hover::before {
transform: scaleX(1);
}
}
} }
@mixin stagger-animate($stagger: 100, $num-children: 10, $base-delay: 0) { @mixin stagger-animate($stagger: 100, $num-children: 10, $base-delay: 0) {
@@ -185,20 +44,18 @@ $mobile-height: get('viewports.mobile.height');
} }
@mixin hover { @mixin hover {
@include desktop { &:hover {
&:hover { @content;
@content;
}
} }
} }
@mixin drop-cap() { @mixin drop-cap() {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: size-vw(12px); font-size: 12px;
font-weight: 400 !important; font-weight: 400 !important;
&:first-child::first-letter { &:first-child::first-letter {
font-family: var(--font-display); font-family: var(--font-display);
font-size: size-vw(42px); font-size: 42px;
} }
} }

View File

@@ -10,9 +10,7 @@
// css classes exposed globally: // css classes exposed globally:
// .layout-block: element takes the whole layout width // .layout-block: element takes the whole layout width
// .layout-block-inner: same as .layout-block but using padding instead of margin
// .layout-grid: extends .layout-block with grid behaviour using layout settings // .layout-grid: extends .layout-block with grid behaviour using layout settings
// .layout-grid-inner: same as .layout-grid but using padding instead of margin
@use 'sass:map'; @use 'sass:map';
@@ -20,26 +18,21 @@
// 'variable': (mobile, desktop) // 'variable': (mobile, desktop)
$layout: ( $layout: (
'columns-count': ( 'columns-count': (
5, 6,
18,
), ),
'columns-gap': ( 'columns-gap': (
20px, 10px,
20px,
), ),
'margin': ( 'margin': (
30px, 20px,
60px,
), ),
); );
//internal process, do not touch //internal process, do not touch
:root { :root {
--layout-column-count: #{list.nth(map.get($layout, 'columns-count'), 1)}; --layout-column-count: #{list.nth(map.get($layout, 'columns-count'), 1)};
--layout-column-gap: #{size-vw( --layout-column-gap: #{list.nth(map.get($layout, 'columns-gap'), 1)};
list.nth(map.get($layout, 'columns-gap'), 1) --layout-margin: #{list.nth(map.get($layout, 'margin'), 1)};
)};
--layout-margin: #{size-vw(list.nth(map.get($layout, 'margin'), 1))};
--layout-width: calc(100vw - (2 * var(--layout-margin))); --layout-width: calc(100vw - (2 * var(--layout-margin)));
--layout-column-width: calc( --layout-column-width: calc(
( (
@@ -54,13 +47,6 @@ $layout: (
} }
.layout-block { .layout-block {
max-width: var(--layout-width);
margin-left: auto;
margin-right: auto;
width: 100%;
}
.layout-block-inner {
padding-left: var(--layout-margin); padding-left: var(--layout-margin);
padding-right: var(--layout-margin); padding-right: var(--layout-margin);
width: 100%; width: 100%;
@@ -73,11 +59,3 @@ $layout: (
grid-template-columns: repeat(var(--layout-column-count), minmax(0, 1fr)); grid-template-columns: repeat(var(--layout-column-count), minmax(0, 1fr));
grid-gap: var(--layout-column-gap); grid-gap: var(--layout-column-gap);
} }
.layout-grid-inner {
@extend .layout-block-inner;
display: grid;
grid-template-columns: repeat(var(--layout-column-count), minmax(0, 1fr));
grid-gap: var(--layout-column-gap);
}

View File

@@ -9,8 +9,8 @@
- The "html" element is excluded, otherwise a bug in Chrome breaks the CSS hyphens property (https://github.com/elad2412/the-new-css-reset/issues/36) - The "html" element is excluded, otherwise a bug in Chrome breaks the CSS hyphens property (https://github.com/elad2412/the-new-css-reset/issues/36)
*/ */
*:where( *:where(
:not(html, iframe, canvas, img, svg, video, audio):not(svg *, symbol *) :not(html, iframe, canvas, img, svg, video, audio):not(svg *, symbol *)
) { ) {
all: unset; all: unset;
display: revert; display: revert;
} }

View File

@@ -10,23 +10,3 @@ html {
} }
} }
} }
html.lenis {
height: auto;
}
.lenis.lenis-smooth {
scroll-behavior: auto;
}
.lenis.lenis-smooth [data-lenis-prevent] {
overscroll-behavior: contain;
}
.lenis.lenis-stopped {
overflow: hidden;
}
.lenis.lenis-scrolling iframe {
pointer-events: none;
}

View File

@@ -17,26 +17,8 @@
position: relative; position: relative;
} }
.mobile-only {
@include desktop {
display: none !important;
}
}
.desktop-only {
@include mobile {
display: none !important;
}
}
html:not(.has-scroll-smooth) { html:not(.has-scroll-smooth) {
.hide-on-native-scroll { .hide-on-native-scroll {
display: none; display: none;
} }
} }
html.has-scroll-smooth {
.hide-on-smooth-scroll {
display: none;
}
}

View File

@@ -46,7 +46,10 @@ p,
a, a,
button, button,
input, input,
pre { pre,
span,
label,
li {
@include p; @include p;
} }
.bold { .bold {
@@ -64,9 +67,9 @@ button {
// Text selection // Text selection
::selection { ::selection {
color: var(--theme-bg); color: var(--theme-bg);
background: var(--theme-accent); background: var(--theme-fg);
} }
::-moz-selection { ::-moz-selection {
color: var(--theme-bg); color: var(--theme-bg);
background: var(--theme-accent); background: var(--theme-fg);
} }

View File

@@ -2,7 +2,12 @@
<main class="category layout-block"> <main class="category layout-block">
<router-link class="back" to="/"><- Go Back</router-link> <router-link class="back" to="/"><- Go Back</router-link>
<category-row :index="categoryIndex" :category="id" /> <category-row
:index="categoryIndex"
:category="id"
editable
@edited="onCategoryEdited"
/>
<div class="notes"> <div class="notes">
<note-row v-for="note in notes" :note="note" :key="note.id" /> <note-row v-for="note in notes" :note="note" :key="note.id" />
@@ -13,48 +18,68 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import useNotes from '@/composables/useNotes' import useNotes from '@/composables/useNotes'
import NoteRow from '@/components/NoteRow.vue' import NoteRow from '@/components/NoteRow.vue'
import CategoryRow from '@/components/CategoryRow.vue' import CategoryRow from '@/components/CategoryRow.vue'
import NewNote from '@/components/NewNote.vue' import NewNote from '@/components/NewNote.vue'
const route = useRoute() const route = useRoute()
const id = route.params.id const id = route.params?.id
const router = useRouter()
const { categories, loadCategoryNotes } = useNotes() const { categories, loadCategoryNotes, updateCategory, notesChangeCount } =
useNotes()
const notes = ref() const notes = ref()
async function refreshNotes() {
if (id) {
notes.value = await loadCategoryNotes(id)
}
}
onMounted(async () => { onMounted(async () => {
notes.value = await loadCategoryNotes(id) await refreshNotes()
}) })
watch(notesChangeCount, async () => {
await refreshNotes()
})
const onCategoryEdited = async (editedCategory) => {
await updateCategory(id, editedCategory)
router.push({ name: 'category', params: { id: editedCategory } })
}
const categoryIndex = computed(() => { const categoryIndex = computed(() => {
return categories.value?.findIndex((category) => category === id) || 0 return categories.value?.findIndex((category) => category === id) || 1
}) })
</script> </script>
<style lang="scss"> <style lang="scss">
main.category { main.category {
padding-top: 1.24em;
.back { .back {
display: block; display: block;
opacity: 0.25; opacity: 0.25;
margin-top: size-vw(9px); margin-top: 9px;
} }
.category-row { .category-row {
margin-top: size-vw(4px); margin-top: 4px;
} }
.notes { .notes {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: size-vw(14px); gap: 14px;
margin-top: size-vw(9px); margin-top: 9px;
} }
.new-note { .new-note {
display: block; display: block;
margin: size-vw(50px) auto 0; margin: 50px auto 0;
} }
} }
</style> </style>

View File

@@ -0,0 +1,22 @@
<template>
<main class="create-category layout-block">
<category-row :index="1" editable @edited="onCategoryEdited" />
</main>
</template>
<script setup>
import CategoryRow from '@/components/CategoryRow.vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const onCategoryEdited = (name) => {
router.push({ name: 'category', params: { id: name } })
}
</script>
<style lang="scss">
.create-category {
padding-top: 1.2em;
}
</style>

View File

@@ -7,7 +7,7 @@
:key="category" :key="category"
/> />
<h2 class="label">Summarium</h2> <h2 v-if="notes?.length" class="label">Summarium</h2>
<div class="notes"> <div class="notes">
<note-row v-for="note in notes" :note="note" :key="note.id" /> <note-row v-for="note in notes" :note="note" :key="note.id" />
@@ -17,33 +17,47 @@
<script setup> <script setup>
import useNotes from '@/composables/useNotes' import useNotes from '@/composables/useNotes'
import { onMounted, ref } from 'vue' import usePlugins from '@/composables/usePlugins'
import useConfig from '@/composables/useConfig'
import { onMounted, ref, watch } from 'vue'
import CategoryRow from '@/components/CategoryRow.vue' import CategoryRow from '@/components/CategoryRow.vue'
import NoteRow from '@/components/NoteRow.vue' import NoteRow from '@/components/NoteRow.vue'
const { categories, loadCategories, loadCategoryNotes } = useNotes() const { categories, loadCategories, loadCategoryNotes, notesChangeCount } =
useNotes()
const { config } = useConfig()
const notes = ref() const notes = ref()
onMounted(async () => { async function refreshNotes() {
await loadCategories() await loadCategories()
notes.value = await loadCategoryNotes() notes.value = await loadCategoryNotes()
}
onMounted(async () => {
await refreshNotes()
})
watch(notesChangeCount, async () => {
await refreshNotes()
}) })
</script> </script>
<style lang="scss"> <style lang="scss">
main.directory { main.directory {
padding-top: size-vw(18px); padding-top: 26px;
padding-bottom: 30px;
.label { .label {
text-transform: uppercase; text-transform: uppercase;
margin: size-vw(17px) 0 size-vw(24px); margin: 17px 0 24px;
@include p; @include p;
} }
.notes { .notes {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: size-vw(14px); gap: 14px;
} }
} }
</style> </style>

View File

@@ -1,195 +0,0 @@
<template>
<main v-if="editor" class="editor layout-block">
<bubble-menu :editor="editor">
<div class="bubble-menu">
<button
@click="editor.chain().focus().toggleBold().run()"
:class="{ active: editor.isActive('bold') }"
>
Bold
</button>
<button
@click="editor.chain().focus().toggleItalic().run()"
:class="{ active: editor.isActive('italic') }"
>
Italic
</button>
</div>
</bubble-menu>
<editor-content :editor="editor" class="editor-wrap" />
</main>
</template>
<script setup>
import { onBeforeUnmount, onMounted, shallowRef } from 'vue'
import { Editor, EditorContent } from '@tiptap/vue-3'
import { Markdown } from '@tiptap/markdown'
import Image from '@tiptap/extension-image'
import Document from '@tiptap/extension-document'
import { Placeholder } from '@tiptap/extensions'
import StarterKit from '@tiptap/starter-kit'
import { BubbleMenu } from '@tiptap/vue-3/menus'
import useNotes from '@/composables/useNotes'
import { useRoute } from 'vue-router'
import _debounce from 'lodash/debounce'
const route = useRoute()
const id = route.params.id
const { loadNote, updateNoteContent, updateNoteMetadata } = useNotes()
const CustomDocument = Document.extend({
content: 'heading block*',
})
const editor = shallowRef()
const updateNote = _debounce(async ({ editor }) => {
const markdown = editor.getMarkdown()
await updateNoteContent(id, markdown)
updateTitle(editor)
}, 300)
// Track title updates for file
let lastTitle
const updateTitle = _debounce(async (editor) => {
const doc = editor.state.doc
const firstNode = doc.firstChild
if (!firstNode || firstNode.type.name !== 'heading') return
const newTitle = firstNode.textContent.trim() || 'Untitled'
if (newTitle === lastTitle) return
lastTitle = newTitle
await updateNoteMetadata(id, { title: newTitle })
}, 300)
onMounted(async () => {
const note = await loadNote(id)
console.log(note)
lastTitle = note.title
editor.value = new Editor({
extensions: [
CustomDocument,
StarterKit.configure({
document: false,
heading: { levels: [1] },
trailingNode: {
node: 'paragraph',
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === 'heading') {
return 'Title'
}
},
}),
Image,
],
content: note.content,
onUpdate: updateNote,
})
})
onBeforeUnmount(() => {
editor.value?.destroy?.()
})
</script>
<style lang="scss">
main.editor {
padding-top: size-vw(8px);
padding-bottom: size-vw(20px);
h1 {
font-weight: 700 !important;
@include p;
&:first-child {
@include drop-cap;
}
}
h1.is-editor-empty:first-child::before {
color: var(--grey-100);
content: attr(data-placeholder);
pointer-events: none;
@include drop-cap;
}
p strong {
font-weight: 700;
}
p em {
font-style: italic;
}
hr::before {
content: '----------------------------------------';
@include p;
}
ul {
list-style-type: disc;
li {
display: list-item;
margin-left: 1em;
}
}
ol {
list-style-type: decimal;
li {
display: list-item;
margin-left: 1.5em;
&::marker {
@include p;
}
}
}
a {
color: var(--theme-link);
cursor: pointer;
}
.editor-wrap {
> div {
display: flex;
flex-direction: column;
gap: size-vw(20px);
&:focus {
outline: none;
}
}
}
.bubble-menu {
display: flex;
gap: size-vw(5px);
border: 1px solid var(--grey-100);
color: var(--grey-100);
border-radius: 0.2em;
background: var(--theme-bg);
button {
cursor: pointer;
padding: 0.2em;
border-radius: 0.2em;
&:hover {
background: var(--grey-100);
color: var(--theme-bg);
}
&.active {
background: var(--theme-fg);
color: var(--theme-bg);
}
}
}
}
</style>

View File

@@ -1,74 +1,38 @@
<template> <template>
<main class="instructions layout-block-inner"> <main class="instructions layout-block">
<router-link class="back-link" to="/"><- Go Back</router-link> <router-link class="back-link" to="/"><- Go Back</router-link>
<p> <div class="content" v-html="renderedContent" />
Medieval Translation Nota = Note Capitulum = Category Intructio =
Instructions Tabula = Index/Overview *This can be disabled via
toolbar -------------------------------------------- Program Key
Commands cmd + s = save cmd + t = new capitulum cmd + n = new nota
cmd + x = close window dbl click = change name / open nota paste
hyperlink twice = activated url
-------------------------------------------- Text Markdowns cmd + b
= Bold cmd + u = underline --- = ---------- (ruled line break)
/*text*/ = Desaturated text
</p>
<p>
Medieval Translation Nota = Note Capitulum = Category Intructio =
Instructions Tabula = Index/Overview *This can be disabled via
toolbar -------------------------------------------- Program Key
Commands cmd + s = save cmd + t = new capitulum cmd + n = new nota
cmd + x = close window dbl click = change name / open nota paste
hyperlink twice = activated url
-------------------------------------------- Text Markdowns cmd + b
= Bold cmd + u = underline --- = ---------- (ruled line break)
/*text*/ = Desaturated text
</p>
<p>
Medieval Translation Nota = Note Capitulum = Category Intructio =
Instructions Tabula = Index/Overview *This can be disabled via
toolbar -------------------------------------------- Program Key
Commands cmd + s = save cmd + t = new capitulum cmd + n = new nota
cmd + x = close window dbl click = change name / open nota paste
hyperlink twice = activated url
-------------------------------------------- Text Markdowns cmd + b
= Bold cmd + u = underline --- = ---------- (ruled line break)
/*text*/ = Desaturated text
</p>
<p>
Medieval Translation Nota = Note Capitulum = Category Intructio =
Instructions Tabula = Index/Overview *This can be disabled via
toolbar -------------------------------------------- Program Key
Commands cmd + s = save cmd + t = new capitulum cmd + n = new nota
cmd + x = close window dbl click = change name / open nota paste
hyperlink twice = activated url
-------------------------------------------- Text Markdowns cmd + b
= Bold cmd + u = underline --- = ---------- (ruled line break)
/*text*/ = Desaturated text
</p>
<p>
Medieval Translation Nota = Note Capitulum = Category Intructio =
Instructions Tabula = Index/Overview *This can be disabled via
toolbar -------------------------------------------- Program Key
Commands cmd + s = save cmd + t = new capitulum cmd + n = new nota
cmd + x = close window dbl click = change name / open nota paste
hyperlink twice = activated url
-------------------------------------------- Text Markdowns cmd + b
= Bold cmd + u = underline --- = ---------- (ruled line break)
/*text*/ = Desaturated text
</p>
</main> </main>
</template> </template>
<script setup></script> <script setup>
import content from '@/content/instructions.md?raw'
import MarkdownIt from 'markdown-it'
const md = new MarkdownIt()
const renderedContent = md.render(content)
</script>
<style lang="scss"> <style lang="scss">
main.instructions { main.instructions {
padding-top: 1.2em;
.back-link { .back-link {
opacity: 0.25; opacity: 0.25;
display: block; display: block;
margin-top: size-vw(9px); margin-top: 9px;
margin-bottom: size-vw(14px); margin-bottom: 14px;
}
.content {
display: flex;
flex-direction: column;
gap: 20px;
hr {
border-bottom: 1px dashed currentColor;
}
} }
} }
</style> </style>

View File

@@ -0,0 +1,29 @@
<template>
<main class="note layout-block">
<note-editor :id="id" />
</main>
</template>
<script setup>
import { watchEffect } from 'vue'
import { useMagicKeys } from '@vueuse/core'
import { useRoute } from 'vue-router'
import NoteEditor from '@/components/note/Editor.vue'
const route = useRoute()
const id = route.params.id
const { ctrl, f } = useMagicKeys()
watchEffect(() => {
if (ctrl.value && f.value) {
console.log('find')
}
})
</script>
<style lang="scss">
main.note {
padding-top: 2.2em;
padding-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,178 @@
<template>
<main class="preferences layout-block">
<router-link to="/" class="back"><- Back</router-link>
<h1 class="mono">Storage Plugin</h1>
<div v-for="plugin in plugins" class="plugin" :key="plugin.id">
<input
v-model="selectedPluginId"
name="plugins"
type="radio"
:id="plugin.id"
:value="plugin.id"
/>
<div class="info">
<p class="name bold">{{ plugin.name }}</p>
<p class="description">{{ plugin.description }}</p>
<div v-if="plugin.configSchema.length" class="config">
<div
v-for="field in plugin.configSchema"
class="config-field"
:key="field.key"
>
<label :for="field.key">
{{ field.label }} {{ field.required ? '*' : '' }}
</label>
<input
v-model="config.adapters[plugin.id][field.key]"
:id="field.key"
:type="field.type"
:placeholder="field.default"
:required="field.required"
/>
</div>
</div>
</div>
</div>
<p v-if="validationError" class="error">{{ validationError }}</p>
<button @click="save" class="save-btn">
<svg-spinner v-if="saving" />
<span v-else-if="saved">Saved</span>
<span v-else>Save</span>
</button>
</main>
</template>
<script setup>
import SvgSpinner from '@/components/svg/Spinner.vue'
import usePlugins from '@/composables/usePlugins'
import useConfig from '@/composables/useConfig'
import { ref, computed } from 'vue'
const { plugins, setActivePlugin } = await usePlugins()
const { config, ensureConfig } = useConfig()
await ensureConfig()
const selectedPluginId = ref(config.value.activeAdapter)
const validationError = ref('')
const selectedPlugin = computed(() => {
return plugins.value.find((p) => p.id === selectedPluginId.value)
})
const saving = ref(false)
const saved = ref(false)
const save = async () => {
saving.value = true
validationError.value = ''
const plugin = selectedPlugin.value
if (plugin && plugin.configSchema.length) {
const adapterConfig = config.value.adapters[plugin.id] || {}
for (const field of plugin.configSchema) {
if (field.required && !adapterConfig[field.key]) {
validationError.value = `Please fill in all required fields for ${plugin.name}`
return
}
}
}
await setActivePlugin(selectedPluginId.value)
saving.value = false
saved.value = true
setTimeout(() => {
saved.value = false
}, 2000)
}
</script>
<style lang="scss">
.preferences {
padding-top: 1.2em;
padding-bottom: 60px;
.back {
opacity: 0.25;
display: block;
margin-top: 9px;
margin-bottom: 14px;
}
h1 {
margin-bottom: 20px;
}
.plugin {
display: flex;
margin-bottom: 16px;
}
input[type='radio'] {
display: block;
flex-shrink: 0;
width: 10px;
height: 10px;
margin-right: 10px;
border: 1px solid white;
cursor: pointer;
&:checked {
background-color: white;
}
}
.info {
.description {
color: var(--grey-100);
margin-top: 6px;
}
}
.config {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 16px;
}
.config-field {
display: flex;
flex-direction: column;
gap: 4px;
input {
width: 100%;
border: 1px solid var(--grey-100);
border-radius: 0.2em;
padding: 0.2em 0.5em;
}
}
.error {
color: red;
margin-top: 16px;
}
.save-btn {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px 0;
text-align: center;
border-top: 1px dashed currentColor;
background: var(--theme-bg);
.svg-spinner {
width: 1em;
height: 1em;
}
&:hover {
color: var(--theme-accent);
}
}
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<main class="search layout-block">
<router-link class="back" to="/"><- Back</router-link>
<form @submit.prevent="onSearch">
<div class="input-wrap">
<input
v-model="query"
type="text"
placeholder="Search"
ref="searchInput"
@input="onInput"
/>
</div>
</form>
<div class="results">
<note-row
v-for="note in searchResults"
:key="note.id"
:note="note"
/>
</div>
</main>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import useNotes from '@/composables/useNotes'
import NoteRow from '@/components/NoteRow.vue'
import _debounce from 'lodash/debounce'
const query = ref('')
const searchInput = ref()
const { search, searchResults } = useNotes()
onMounted(async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
searchInput.value?.focus()
})
onBeforeUnmount(() => {
query.value = ''
searchResults.value = []
})
const onSearch = async () => {
await search(query.value)
}
const onInput = _debounce(async () => {
await search(query.value)
}, 300)
</script>
<style lang="scss">
main.search {
padding-top: 1.2em;
.back {
display: block;
opacity: 0.25;
margin-top: 9px;
}
.input-wrap {
margin-top: 19px;
position: relative;
input {
display: block;
width: 100%;
position: relative;
padding: 5px 15px 6px;
background: var(--theme-bg);
--clip-start: 16px;
clip-path: polygon(
var(--clip-start) 1px,
calc(100% - var(--clip-start)) 1px,
calc(100% - 1.5px) 50%,
calc(100% - var(--clip-start)) calc(100% - 1px),
var(--clip-start) calc(100% - 1px),
1.5px 50%
);
}
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--theme-fg);
--clip-start: 15px;
clip-path: polygon(
var(--clip-start) 0,
calc(100% - var(--clip-start)) 0,
100% 50%,
calc(100% - var(--clip-start)) 100%,
var(--clip-start) 100%,
0% 50%
);
}
}
.results {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
}
</style>