Compare commits
15 Commits
v0.1.6
...
059329c696
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
059329c696 | ||
|
|
5c826e6b93 | ||
|
|
6f76c46299 | ||
|
|
4feb6a880c | ||
|
|
45a6952c60 | ||
|
|
85c6c44393 | ||
|
|
c93fc2cc58 | ||
|
|
93edf204ce | ||
|
|
4d04f4f2ff | ||
|
|
99e6761e92 | ||
|
|
a1b339f668 | ||
|
|
efc9c73751 | ||
|
|
77b8ad2dcd | ||
|
|
23054d4981 | ||
|
|
a3bb474399 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,6 +14,7 @@ dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
out
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["dbaeumer.vscode-eslint"]
|
||||
}
|
||||
@@ -5,9 +5,6 @@ import filesystemPlugin from "@takerofnotes/plugin-filesystem";
|
||||
import supabasePlugin from "@takerofnotes/plugin-supabase";
|
||||
import fs from "fs/promises";
|
||||
import path, { join } from "path";
|
||||
import { Index } from "flexsearch";
|
||||
import crypto from "crypto";
|
||||
import sodium from "libsodium-wrappers";
|
||||
import __cjs_mod__ from "node:module";
|
||||
const __filename = import.meta.filename;
|
||||
const __dirname = import.meta.dirname;
|
||||
@@ -26,16 +23,21 @@ class PluginRegistry {
|
||||
return this.plugins.get(id);
|
||||
}
|
||||
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__";
|
||||
class PluginConfig {
|
||||
class Config {
|
||||
constructor(defaultPlugin) {
|
||||
this.defaultPlugin = defaultPlugin;
|
||||
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) {
|
||||
if (Array.isArray(config)) {
|
||||
return config.map((item) => this._resolveDefaults(item));
|
||||
@@ -65,12 +67,15 @@ class PluginConfig {
|
||||
defaultConfig[field.key] = field.default ?? null;
|
||||
}
|
||||
parsed = {
|
||||
...parsed ? parsed : {},
|
||||
activeAdapter: this.defaultPlugin.id,
|
||||
adapterConfig: defaultConfig
|
||||
adapters: {}
|
||||
};
|
||||
parsed.adapters[this.defaultPlugin.id] = defaultConfig;
|
||||
parsed[theme] = "dark";
|
||||
await this.write(parsed);
|
||||
} else {
|
||||
parsed.adapterConfig = this._resolveDefaults(parsed.adapterConfig);
|
||||
parsed.adapters = this._resolveDefaults(parsed.adapters);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -79,7 +84,7 @@ class PluginConfig {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const resolvedConfig = {
|
||||
...configObject,
|
||||
adapterConfig: this._resolveDefaults(configObject.adapterConfig)
|
||||
adapters: this._resolveDefaults(configObject.adapters)
|
||||
};
|
||||
await fs.writeFile(
|
||||
this.configPath,
|
||||
@@ -88,194 +93,14 @@ class PluginConfig {
|
||||
);
|
||||
}
|
||||
}
|
||||
class NotesAPI {
|
||||
constructor(adapter, encryptionKey = null) {
|
||||
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)"
|
||||
);
|
||||
}
|
||||
let combined;
|
||||
try {
|
||||
combined = Buffer.from(encryptedData, "base64");
|
||||
} 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 nonce = combined.slice(0, sodium.crypto_secretbox_NONCEBYTES);
|
||||
const ciphertext = combined.slice(sodium.crypto_secretbox_NONCEBYTES);
|
||||
let decrypted;
|
||||
try {
|
||||
decrypted = sodium.crypto_secretbox_open_easy(
|
||||
ciphertext,
|
||||
nonce,
|
||||
key
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error("Decryption failed: wrong key or corrupted data");
|
||||
}
|
||||
if (!decrypted) {
|
||||
throw new Error("Decryption failed: no data returned");
|
||||
}
|
||||
const decryptedStr = Buffer.from(decrypted).toString("utf8");
|
||||
try {
|
||||
return JSON.parse(decryptedStr);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Decryption succeeded but invalid JSON: ${decryptedStr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
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 DEFAULT_WINDOW_SIZE = { width: 354, height: 549 };
|
||||
const DEFAULT_MOVE_WINDOW_SIZE = { width: 708, height: 549 };
|
||||
const preloadPath = join(__dirname, "../preload/index.mjs");
|
||||
const rendererPath = join(__dirname, "../renderer/index.html");
|
||||
function createWindow() {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 354,
|
||||
height: 549,
|
||||
width: DEFAULT_WINDOW_SIZE.width,
|
||||
height: DEFAULT_WINDOW_SIZE.height,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
@@ -298,8 +123,8 @@ function createWindow() {
|
||||
}
|
||||
function createNoteWindow(noteId) {
|
||||
const noteWindow = new BrowserWindow({
|
||||
width: 354,
|
||||
height: 549,
|
||||
width: DEFAULT_WINDOW_SIZE.width,
|
||||
height: DEFAULT_WINDOW_SIZE.height,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
@@ -322,37 +147,68 @@ app.whenReady().then(async () => {
|
||||
ipcMain.on("open-note-window", (_, noteId) => {
|
||||
createNoteWindow(noteId);
|
||||
});
|
||||
const registry = new PluginRegistry();
|
||||
registry.register(filesystemPlugin);
|
||||
registry.register(supabasePlugin);
|
||||
const config = await new PluginConfig(filesystemPlugin).load();
|
||||
const plugin = registry.get(config.activeAdapter);
|
||||
const adapter = plugin.createAdapter(config.adapterConfig);
|
||||
const notesAPI = new NotesAPI(
|
||||
adapter,
|
||||
"729a0d21d783654c68f1a0123e2a0e986350de536b5324f1f35876ea12ffeaf5"
|
||||
);
|
||||
await notesAPI.init();
|
||||
const broadcastNoteChange = (event, data) => {
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
win.webContents.send(event, data);
|
||||
});
|
||||
};
|
||||
ipcMain.handle("notesAPI:call", async (_, method, args) => {
|
||||
if (!notesAPI[method]) {
|
||||
throw new Error("Invalid method");
|
||||
const registry = new PluginRegistry();
|
||||
registry.register(filesystemPlugin);
|
||||
registry.register(supabasePlugin);
|
||||
const config = new Config(filesystemPlugin);
|
||||
const initialConfig = await config.load();
|
||||
const setActivePlugin = async (pluginId) => {
|
||||
const currentConfig = await config.load();
|
||||
await config.write({ ...currentConfig, activeAdapter: pluginId });
|
||||
const plugin = registry.get(pluginId);
|
||||
const adapterConfig = currentConfig.adapters[pluginId] || {};
|
||||
const adapter = plugin.createAdapter(adapterConfig);
|
||||
await adapter.init();
|
||||
ipcMain.removeHandler("adapter:call");
|
||||
ipcMain.handle("adapter:call", async (_, method, args) => {
|
||||
if (!adapter[method]) {
|
||||
throw new Error(`Invalid adapter method: ${method}`);
|
||||
}
|
||||
const result = await notesAPI[method](...args);
|
||||
if (method === "createNote") {
|
||||
broadcastNoteChange("note-created", result);
|
||||
} else if (method === "updateNote") {
|
||||
broadcastNoteChange("note-updated", result);
|
||||
} else if (method === "updateNoteMetadata") {
|
||||
broadcastNoteChange("note-updated", result);
|
||||
} else if (method === "deleteNote") {
|
||||
broadcastNoteChange("note-deleted", { id: args[0] });
|
||||
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 result;
|
||||
});
|
||||
electronApp.setAppUserModelId("com.electron");
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
const api = {
|
||||
getConfig: () => ipcRenderer.invoke("getConfig"),
|
||||
setConfig: (config) => ipcRenderer.invoke("setConfig", config),
|
||||
listPlugins: () => ipcRenderer.invoke("listPlugins"),
|
||||
setActivePlugin: (pluginId) => ipcRenderer.invoke("setActivePlugin", pluginId),
|
||||
openNoteWindow: (noteId) => {
|
||||
ipcRenderer.send("open-note-window", noteId);
|
||||
},
|
||||
@@ -11,18 +15,31 @@ const api = {
|
||||
},
|
||||
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 = {
|
||||
call: (method, ...args) => ipcRenderer.invoke("notesAPI:call", method, args)
|
||||
const adapter = {
|
||||
call: (method, ...args) => ipcRenderer.invoke("adapter:call", method, args)
|
||||
};
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld("api", api);
|
||||
contextBridge.exposeInMainWorld("notesAPI", notesAPI);
|
||||
contextBridge.exposeInMainWorld("adapter", adapter);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
window.api = api;
|
||||
window.adapter = adapter;
|
||||
}
|
||||
|
||||
4
out/renderer/assets/__vite-browser-external-2Ng8QIWW.js
Normal file
4
out/renderer/assets/__vite-browser-external-2Ng8QIWW.js
Normal file
@@ -0,0 +1,4 @@
|
||||
const __viteBrowserExternal = {};
|
||||
export {
|
||||
__viteBrowserExternal as default
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
BIN
out/renderer/assets/neuefraktur-A4S1ACH2.woff2
Normal file
BIN
out/renderer/assets/neuefraktur-A4S1ACH2.woff2
Normal file
Binary file not shown.
BIN
out/renderer/assets/neuefraktur-CwjUIZ0G.woff
Normal file
BIN
out/renderer/assets/neuefraktur-CwjUIZ0G.woff
Normal file
Binary file not shown.
@@ -8,8 +8,8 @@
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
|
||||
/>
|
||||
<script type="module" crossorigin src="./assets/index-DdUrngdf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-D9ZUihqb.css">
|
||||
<script type="module" crossorigin src="./assets/index-D2TWwJ08.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BFwBEQYI.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
72
package-lock.json
generated
72
package-lock.json
generated
@@ -18,6 +18,7 @@
|
||||
"@tiptap/extension-document": "^3.19.0",
|
||||
"@tiptap/extension-highlight": "^3.20.0",
|
||||
"@tiptap/extension-list": "^3.20.0",
|
||||
"@tiptap/markdown": "^3.20.4",
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"@tiptap/vue-3": "^3.19.0",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
@@ -26,10 +27,10 @@
|
||||
"fecha": "^4.2.3",
|
||||
"flexsearch": "^0.8.212",
|
||||
"gsap": "^3.14.2",
|
||||
"lenis": "^1.3.17",
|
||||
"libsodium-wrappers": "^0.8.2",
|
||||
"lodash": "^4.17.23",
|
||||
"lowlight": "^3.3.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"sass": "^1.97.3",
|
||||
"sass-embedded": "^1.97.3",
|
||||
"tempus": "^1.0.0-dev.17",
|
||||
@@ -2382,16 +2383,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.0.tgz",
|
||||
"integrity": "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==",
|
||||
"version": "3.20.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.4.tgz",
|
||||
"integrity": "sha512-3i/DG89TFY/b34T5P+j35UcjYuB5d3+9K8u6qID+iUqNPiza015HPIZLuPfE5elNwVdV3EXIoPo0LLeBLgXXAg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "^3.20.0"
|
||||
"@tiptap/pm": "^3.20.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
@@ -2752,10 +2753,27 @@
|
||||
"@tiptap/pm": "^3.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/markdown": {
|
||||
"version": "3.20.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.20.4.tgz",
|
||||
"integrity": "sha512-1ARtZzJ1skQCZi4LyVSmImgg6JIIMP5dEs0FvHXS3a7M3O+uMOUvY1sWeggVZExg8DXoVyHd7BjRIpm7uNRuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"marked": "^17.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.20.4",
|
||||
"@tiptap/pm": "^3.20.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.0.tgz",
|
||||
"integrity": "sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A==",
|
||||
"version": "3.20.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.4.tgz",
|
||||
"integrity": "sha512-rCHYSBToilBEuI6PtjziHDdRkABH/XqwJ7dG4Amn/SD3yGiZKYCiEApQlTUS2zZeo8DsLeuqqqB4vEOeD4OEPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
@@ -6117,32 +6135,6 @@
|
||||
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
|
||||
"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": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.8.2.tgz",
|
||||
@@ -6336,6 +6328,18 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "17.0.4",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz",
|
||||
"integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/matcher": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@tiptap/extension-document": "^3.19.0",
|
||||
"@tiptap/extension-highlight": "^3.20.0",
|
||||
"@tiptap/extension-list": "^3.20.0",
|
||||
"@tiptap/markdown": "^3.20.4",
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"@tiptap/vue-3": "^3.19.0",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
@@ -42,10 +43,10 @@
|
||||
"fecha": "^4.2.3",
|
||||
"flexsearch": "^0.8.212",
|
||||
"gsap": "^3.14.2",
|
||||
"lenis": "^1.3.17",
|
||||
"libsodium-wrappers": "^0.8.2",
|
||||
"lodash": "^4.17.23",
|
||||
"lowlight": "^3.3.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"sass": "^1.97.3",
|
||||
"sass-embedded": "^1.97.3",
|
||||
"tempus": "^1.0.0-dev.17",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
resources/fonts/neuefraktur.woff
Normal file
BIN
resources/fonts/neuefraktur.woff
Normal file
Binary file not shown.
BIN
resources/fonts/neuefraktur.woff2
Normal file
BIN
resources/fonts/neuefraktur.woff2
Normal file
Binary file not shown.
@@ -4,13 +4,13 @@ import { app } from 'electron'
|
||||
|
||||
const USER_DATA_STRING = '__DEFAULT_USER_DATA__'
|
||||
|
||||
export default class PluginConfig {
|
||||
export default class Config {
|
||||
constructor(defaultPlugin) {
|
||||
this.defaultPlugin = defaultPlugin
|
||||
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) {
|
||||
if (Array.isArray(config)) {
|
||||
return config.map((item) => this._resolveDefaults(item))
|
||||
@@ -48,14 +48,18 @@ export default class PluginConfig {
|
||||
}
|
||||
|
||||
parsed = {
|
||||
...(parsed ? parsed : {}),
|
||||
activeAdapter: this.defaultPlugin.id,
|
||||
adapterConfig: defaultConfig,
|
||||
adapters: {},
|
||||
}
|
||||
parsed.adapters[this.defaultPlugin.id] = defaultConfig
|
||||
|
||||
parsed[theme] = 'dark'
|
||||
|
||||
await this.write(parsed)
|
||||
} else {
|
||||
// Ensure any "__DEFAULT_USER_DATA__" values are resolved on load
|
||||
parsed.adapterConfig = this._resolveDefaults(parsed.adapterConfig)
|
||||
parsed.adapters = this._resolveDefaults(parsed.adapters)
|
||||
}
|
||||
|
||||
return parsed
|
||||
@@ -70,7 +74,7 @@ export default class PluginConfig {
|
||||
// Resolve defaults before writing
|
||||
const resolvedConfig = {
|
||||
...configObject,
|
||||
adapterConfig: this._resolveDefaults(configObject.adapterConfig),
|
||||
adapters: this._resolveDefaults(configObject.adapters),
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
@@ -16,6 +16,11 @@ export default class PluginRegistry {
|
||||
}
|
||||
|
||||
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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,20 @@ import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
||||
import filesystemPlugin from '@takerofnotes/plugin-filesystem'
|
||||
import supabasePlugin from '@takerofnotes/plugin-supabase'
|
||||
import PluginRegistry from './core/PluginRegistry.js'
|
||||
import PluginConfig from './core/PluginConfig.js'
|
||||
import NotesAPI from './core/NotesAPI.js'
|
||||
import Config from './core/Config.js'
|
||||
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 rendererPath = join(__dirname, '../renderer/index.html')
|
||||
|
||||
// Main window
|
||||
function createWindow() {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 354,
|
||||
height: 549,
|
||||
width: DEFAULT_WINDOW_SIZE.width,
|
||||
height: DEFAULT_WINDOW_SIZE.height,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
@@ -43,8 +45,8 @@ function createWindow() {
|
||||
// Open note in new window
|
||||
function createNoteWindow(noteId) {
|
||||
const noteWindow = new BrowserWindow({
|
||||
width: 354,
|
||||
height: 549,
|
||||
width: DEFAULT_WINDOW_SIZE.width,
|
||||
height: DEFAULT_WINDOW_SIZE.height,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
@@ -71,6 +73,13 @@ app.whenReady().then(async () => {
|
||||
createNoteWindow(noteId)
|
||||
})
|
||||
|
||||
// Broadcast note changes to all windows
|
||||
const broadcastNoteChange = (event, data) => {
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
win.webContents.send(event, data)
|
||||
})
|
||||
}
|
||||
|
||||
// Create plugin registry
|
||||
const registry = new PluginRegistry()
|
||||
|
||||
@@ -79,51 +88,81 @@ app.whenReady().then(async () => {
|
||||
registry.register(supabasePlugin)
|
||||
|
||||
// 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 plugin = registry.get(config.activeAdapter)
|
||||
// const plugin = registry.get(supabasePlugin.id)
|
||||
const adapter = plugin.createAdapter(config.adapterConfig)
|
||||
// const adapter = plugin.createAdapter({
|
||||
// supabaseKey: process.env.SUPABASE_KEY,
|
||||
// supabaseUrl: process.env.SUPABASE_URL,
|
||||
// })
|
||||
const setActivePlugin = async (pluginId) => {
|
||||
const currentConfig = await config.load()
|
||||
await config.write({ ...currentConfig, activeAdapter: pluginId })
|
||||
|
||||
// Init Notes API
|
||||
const notesAPI = new NotesAPI(
|
||||
adapter,
|
||||
'729a0d21d783654c68f1a0123e2a0e986350de536b5324f1f35876ea12ffeaf5',
|
||||
)
|
||||
await notesAPI.init()
|
||||
const plugin = registry.get(pluginId)
|
||||
const adapterConfig = currentConfig.adapters[pluginId] || {}
|
||||
const adapter = plugin.createAdapter(adapterConfig)
|
||||
|
||||
// Broadcast note changes to all windows
|
||||
const broadcastNoteChange = (event, data) => {
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
win.webContents.send(event, data)
|
||||
// Initialize adapter
|
||||
await adapter.init()
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Handle Notes API
|
||||
ipcMain.handle('notesAPI:call', async (_, method, args) => {
|
||||
if (!notesAPI[method]) {
|
||||
throw new Error('Invalid method')
|
||||
// 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()
|
||||
|
||||
const result = await notesAPI[method](...args)
|
||||
|
||||
// Broadcast changes to all windows
|
||||
if (method === 'createNote') {
|
||||
broadcastNoteChange('note-created', result)
|
||||
} else if (method === 'updateNote') {
|
||||
broadcastNoteChange('note-updated', result)
|
||||
} else if (method === 'updateNoteMetadata') {
|
||||
broadcastNoteChange('note-updated', result)
|
||||
} else if (method === 'deleteNote') {
|
||||
broadcastNoteChange('note-deleted', { id: args[0] })
|
||||
if (windowSize[0] === 708) {
|
||||
activeWindow.setSize(
|
||||
DEFAULT_WINDOW_SIZE.width,
|
||||
DEFAULT_WINDOW_SIZE.height,
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
electronApp.setAppUserModelId('com.electron')
|
||||
|
||||
@@ -2,6 +2,11 @@ import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
getConfig: () => ipcRenderer.invoke('getConfig'),
|
||||
setConfig: (config) => ipcRenderer.invoke('setConfig', config),
|
||||
listPlugins: () => ipcRenderer.invoke('listPlugins'),
|
||||
setActivePlugin: (pluginId) =>
|
||||
ipcRenderer.invoke('setActivePlugin', pluginId),
|
||||
openNoteWindow: (noteId) => {
|
||||
ipcRenderer.send('open-note-window', noteId)
|
||||
},
|
||||
@@ -14,21 +19,33 @@ const api = {
|
||||
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
|
||||
const notesAPI = {
|
||||
call: (method, ...args) =>
|
||||
ipcRenderer.invoke('notesAPI:call', method, args),
|
||||
// Implement adapter API - communicates with plugin adapter in main process
|
||||
const adapter = {
|
||||
call: (method, ...args) => ipcRenderer.invoke('adapter:call', method, args),
|
||||
}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
contextBridge.exposeInMainWorld('notesAPI', notesAPI)
|
||||
contextBridge.exposeInMainWorld('adapter', adapter)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
} else {
|
||||
window.api = api
|
||||
window.adapter = adapter
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<template>
|
||||
<div :class="classes" :style="styles">
|
||||
<Nav />
|
||||
|
||||
<div class="layout">
|
||||
<div class="page">
|
||||
<Nav v-if="$route.name !== 'note'" ref="nav" />
|
||||
<Suspense>
|
||||
<router-view :key="$route.fullPath" />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<MoveMenu />
|
||||
</div>
|
||||
|
||||
<Menu />
|
||||
|
||||
@@ -11,19 +18,25 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import loadFonts from '@fuzzco/font-loader'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import Menu from '@/components/menu/Index.vue'
|
||||
import { useWindowSize, useElementBounding } from '@vueuse/core'
|
||||
import Menu from '@/components/Menu.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 nav = ref()
|
||||
|
||||
const { height } = useWindowSize()
|
||||
const { height: navHeight } = useElementBounding(nav)
|
||||
const { config } = useConfig()
|
||||
|
||||
const classes = computed(() => [
|
||||
'container',
|
||||
{ 'fonts-ready': !fontsLoading.value },
|
||||
'theme-dark',
|
||||
`theme-${config.value?.theme || 'dark'}`,
|
||||
])
|
||||
|
||||
const fontsLoading = ref(true)
|
||||
@@ -49,6 +62,7 @@ onMounted(async () => {
|
||||
|
||||
const styles = computed(() => ({
|
||||
'--vh': height.value ? height.value / 100 + 'px' : '100vh',
|
||||
'--nav-height': navHeight.value + 'px',
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -59,7 +73,17 @@ const styles = computed(() => ({
|
||||
overflow-x: clip;
|
||||
background: var(--theme-bg);
|
||||
color: var(--theme-fg);
|
||||
transition: opacity 1000ms;
|
||||
transition: opacity 400ms;
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
min-height: calc(100 * var(--vh));
|
||||
|
||||
.page {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.fonts-ready) {
|
||||
opacity: 0;
|
||||
|
||||
@@ -39,6 +39,7 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
wrapper: String,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['edited'])
|
||||
@@ -70,22 +71,23 @@ const onSave = async () => {
|
||||
}
|
||||
|
||||
const wrapper = computed(() => {
|
||||
return props.editable ? 'div' : RouterLink
|
||||
return props.wrapper || (props.editable ? 'div' : RouterLink)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.category-row {
|
||||
display: grid;
|
||||
grid-template-columns: size-vw(26px) 1fr auto;
|
||||
grid-template-columns: 26px 1fr auto;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding: size-vw(5px) 0 size-vw(15px);
|
||||
padding: 8px 0 6px;
|
||||
border-bottom: 1px dashed currentColor;
|
||||
cursor: pointer;
|
||||
|
||||
.index {
|
||||
margin-top: size-vw(19px);
|
||||
margin-top: 19px;
|
||||
@include p;
|
||||
}
|
||||
.title {
|
||||
@@ -120,13 +122,6 @@ const wrapper = computed(() => {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
&::after {
|
||||
content: '----------------------------------------';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
@include p;
|
||||
}
|
||||
&.router-link-exact-active,
|
||||
&.editable {
|
||||
cursor: default;
|
||||
|
||||
@@ -3,17 +3,20 @@
|
||||
<div v-if="menuOpen" class="menu" ref="container">
|
||||
<Nav />
|
||||
|
||||
<div class="menu-wrap layout-block-inner">
|
||||
<div class="menu-wrap layout-block">
|
||||
<new-note class="menu-item" @noteOpened="closeMenu" />
|
||||
<router-link class="menu-item" to="/category"
|
||||
>+ New Capitulum</router-link
|
||||
>
|
||||
<button class="menu-item">Change Theme</button>
|
||||
<router-link class="menu-item" to="/instructions"
|
||||
>Instructio</router-link
|
||||
>
|
||||
<router-link class="menu-item" to="/category">
|
||||
+ New Capitulum
|
||||
</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">Export</button>
|
||||
<router-link class="menu-item" to="/preferences">
|
||||
Preferences
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
@@ -21,9 +24,10 @@
|
||||
|
||||
<script setup>
|
||||
import NewNote from '@/components/NewNote.vue'
|
||||
import ThemeSwitcher from '@/components/ThemeSwitcher.vue'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
import Nav from '../Nav.vue'
|
||||
import Nav from './Nav.vue'
|
||||
import useMenu from '@/composables/useMenu'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
@@ -60,12 +64,11 @@ const openNewCategory = () => {}
|
||||
.menu-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: size-vw(3px);
|
||||
padding-bottom: size-vw(10px);
|
||||
padding-top: var(--nav-height);
|
||||
padding-bottom: 10px;
|
||||
|
||||
.menu-item {
|
||||
display: block;
|
||||
padding: size-vw(16px) 0;
|
||||
padding: 16px 0;
|
||||
text-align: center;
|
||||
|
||||
&:not(:last-child) {
|
||||
64
src/renderer/src/components/MoveMenu.vue
Normal file
64
src/renderer/src/components/MoveMenu.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div v-if="open" class="move-menu layout-block">
|
||||
<button class="cancel-button" @click="close">Cancel</button>
|
||||
|
||||
<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);
|
||||
|
||||
.cancel-button {
|
||||
color: var(--grey-100);
|
||||
padding: 9px 0 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,29 @@
|
||||
<template>
|
||||
<nav class="nav layout-block-inner">
|
||||
<nav class="nav layout-block">
|
||||
<div class="left">
|
||||
<router-link v-if="HAS_BACK_BUTTON.includes($route.name)" to="/"
|
||||
><- Back</router-link
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<button @click="toggleMenu">Menu</button>
|
||||
|
||||
<router-link to="/search">Search</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import useMenu from '@/composables/useMenu'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
const HAS_BACK_BUTTON = [
|
||||
'category',
|
||||
'create-category',
|
||||
'instructions',
|
||||
'search',
|
||||
'preferences',
|
||||
]
|
||||
|
||||
const { menuOpen, closeMenu, openMenu } = useMenu()
|
||||
|
||||
@@ -17,19 +34,22 @@ const toggleMenu = () => {
|
||||
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>
|
||||
|
||||
<style lang="scss">
|
||||
.nav {
|
||||
padding-top: size-vw(9px);
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 9px;
|
||||
padding-bottom: 16px;
|
||||
color: var(--grey-100);
|
||||
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
<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="title bold">{{ note.title }}</span>
|
||||
<div class="title-actions">
|
||||
<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>
|
||||
|
||||
<script setup>
|
||||
import useOpenNote from '@/composables/useOpenNote'
|
||||
import useState from '@/composables/useState'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import { format } from 'fecha'
|
||||
|
||||
const props = defineProps({ note: Object })
|
||||
@@ -17,35 +31,56 @@ const formatDate = (date) => {
|
||||
const d = new Date(date)
|
||||
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>
|
||||
|
||||
<style lang="scss">
|
||||
.note-row {
|
||||
grid-template-columns: auto 1fr;
|
||||
display: grid;
|
||||
gap: size-vw(20px);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
gap: 20px;
|
||||
|
||||
.title {
|
||||
width: size-vw(159px);
|
||||
position: relative;
|
||||
.title-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
|
||||
&::after {
|
||||
content: '(open)';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
transform: translateX(100%);
|
||||
font-weight: 700;
|
||||
.action {
|
||||
opacity: 0;
|
||||
|
||||
&:not(:hover) {
|
||||
color: var(--grey-100);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
}
|
||||
&:hover,
|
||||
&.move-active {
|
||||
color: var(--theme-accent);
|
||||
|
||||
.title::after {
|
||||
.title-actions .action {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&.move-active {
|
||||
.title-actions .move {
|
||||
color: var(--theme-accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
24
src/renderer/src/components/PageLoading.vue
Normal file
24
src/renderer/src/components/PageLoading.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="page-loading">
|
||||
<svg-spinner />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SvgSpinner from '@/components/svg/Spinner.vue'
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.page-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -75,7 +75,7 @@ watch(
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: size-vw(8px);
|
||||
width: 8px;
|
||||
will-change: transform;
|
||||
border-left: 1px solid var(--grey-100);
|
||||
|
||||
@@ -85,7 +85,7 @@ watch(
|
||||
|
||||
.handle {
|
||||
width: 100%;
|
||||
height: size-vw(388px);
|
||||
height: 388px;
|
||||
background: var(--grey-100);
|
||||
border-radius: 20px;
|
||||
position: absolute;
|
||||
|
||||
74
src/renderer/src/components/SearchInput.vue
Normal file
74
src/renderer/src/components/SearchInput.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="search-input">
|
||||
<input
|
||||
v-model="model"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
ref="input"
|
||||
@input="emit('input', model.value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: String,
|
||||
})
|
||||
|
||||
const model = defineModel()
|
||||
|
||||
const emit = defineEmits(['input'])
|
||||
|
||||
const input = ref()
|
||||
|
||||
defineExpose({
|
||||
focus: () => input.value.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.search-input {
|
||||
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%
|
||||
);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--grey-100);
|
||||
}
|
||||
}
|
||||
&::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%
|
||||
);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
49
src/renderer/src/components/ThemeSwitcher.vue
Normal file
49
src/renderer/src/components/ThemeSwitcher.vue
Normal 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>
|
||||
61
src/renderer/src/components/note/Download.vue
Normal file
61
src/renderer/src/components/note/Download.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="note-download">
|
||||
<button @click="download">{{ noteTitle }}.md ↓</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import _kebabCase from 'lodash/kebabCase'
|
||||
|
||||
const DEFAULT_TITLE = 'Untitled'
|
||||
|
||||
const props = defineProps({
|
||||
editor: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const noteTitle = computed(() => {
|
||||
if (!props.editor) return DEFAULT_TITLE
|
||||
|
||||
let title
|
||||
const doc = props.editor.state.doc
|
||||
|
||||
const firstNode = doc.firstChild
|
||||
if (!firstNode || firstNode.type.name !== 'heading') title = DEFAULT_TITLE
|
||||
|
||||
title = firstNode.textContent.trim() || DEFAULT_TITLE
|
||||
|
||||
return _kebabCase(title)
|
||||
})
|
||||
|
||||
const download = () => {
|
||||
if (!props.editor) return
|
||||
|
||||
const content = props.editor.getMarkdown()
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${noteTitle.value}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.note-download {
|
||||
margin-bottom: 16px;
|
||||
color: var(--grey-100);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
button {
|
||||
&:hover {
|
||||
color: var(--theme-accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,89 +1,71 @@
|
||||
<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>
|
||||
<button
|
||||
@click="editor.chain().focus().toggleHighlight().run()"
|
||||
:class="{ active: editor.isActive('highlight') }"
|
||||
>
|
||||
Highlight
|
||||
</button>
|
||||
</div>
|
||||
</bubble-menu>
|
||||
|
||||
<div v-if="editor" class="note-editor">
|
||||
<editor-content :editor="editor" class="editor-wrap" />
|
||||
</main>
|
||||
|
||||
<editor-menu :editor="editor" />
|
||||
</div>
|
||||
|
||||
<page-loading v-else />
|
||||
</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 PageLoading from '@/components/PageLoading.vue'
|
||||
import { Editor, EditorContent } from '@tiptap/vue-3'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import { Placeholder } from '@tiptap/extensions'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { TaskList, TaskItem } from '@tiptap/extension-list'
|
||||
import { Highlight } from '@tiptap/extension-highlight'
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
|
||||
import { BubbleMenu } from '@tiptap/vue-3/menus'
|
||||
import { all, createLowlight } from 'lowlight'
|
||||
import useNotes from '@/composables/useNotes'
|
||||
import { useRoute } from 'vue-router'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import _debounce from 'lodash/debounce'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import EditorMenu from './Menu.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const id = route.params.id
|
||||
|
||||
const { loadNote, updateNoteContent, updateNoteMetadata } = useNotes()
|
||||
|
||||
const CustomDocument = Document.extend({
|
||||
content: 'heading block*',
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const editor = shallowRef()
|
||||
|
||||
const updateNote = _debounce(async ({ editor }) => {
|
||||
const { loadNote, updateNote } = useNotes()
|
||||
|
||||
const onUpdate = _debounce(async ({ editor }) => {
|
||||
const json = editor.getJSON()
|
||||
const text = editor.getText()
|
||||
|
||||
await updateNoteContent(id, json)
|
||||
|
||||
updateTitle(editor)
|
||||
}, 300)
|
||||
|
||||
// Track title updates for file
|
||||
let lastTitle
|
||||
const updateTitle = _debounce(async (editor) => {
|
||||
// Get doc title
|
||||
let title
|
||||
const doc = editor.state.doc
|
||||
|
||||
const firstNode = doc.firstChild
|
||||
if (!firstNode || firstNode.type.name !== 'heading') return
|
||||
if (!firstNode || firstNode.type.name !== 'heading') title = 'Untitled'
|
||||
|
||||
const newTitle = firstNode.textContent.trim() || 'Untitled'
|
||||
title = firstNode.textContent.trim() || 'Untitled'
|
||||
|
||||
if (newTitle === lastTitle) return
|
||||
lastTitle = newTitle
|
||||
|
||||
await updateNoteMetadata(id, { title: newTitle })
|
||||
await updateNote(props.id, {
|
||||
title,
|
||||
content: json,
|
||||
plainText: text,
|
||||
})
|
||||
}, 300)
|
||||
|
||||
onMounted(async () => {
|
||||
const note = await loadNote(id)
|
||||
lastTitle = note.title
|
||||
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,
|
||||
@@ -104,25 +86,27 @@ onMounted(async () => {
|
||||
TaskList,
|
||||
TaskItem,
|
||||
Highlight,
|
||||
Markdown,
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
enableTabIndentation: true,
|
||||
}),
|
||||
],
|
||||
content: note.content || [],
|
||||
onUpdate: updateNote,
|
||||
onUpdate: onUpdate,
|
||||
})
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy?.()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
editor,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
main.editor {
|
||||
padding-top: size-vw(8px);
|
||||
padding-bottom: size-vw(20px);
|
||||
|
||||
.note-editor {
|
||||
h1 {
|
||||
font-weight: 700 !important;
|
||||
@include p;
|
||||
@@ -141,11 +125,11 @@ main.editor {
|
||||
font-weight: 700;
|
||||
}
|
||||
p em {
|
||||
font-style: italic;
|
||||
/* font-style: italic; */
|
||||
color: var(--grey-100);
|
||||
}
|
||||
hr::before {
|
||||
content: '----------------------------------------';
|
||||
@include p;
|
||||
hr {
|
||||
border: 1px dashed currentColor;
|
||||
}
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
@@ -153,6 +137,10 @@ main.editor {
|
||||
li {
|
||||
display: list-item;
|
||||
margin-left: 1em;
|
||||
|
||||
*:not(:last-child) {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
ol {
|
||||
@@ -160,13 +148,16 @@ main.editor {
|
||||
|
||||
li {
|
||||
display: list-item;
|
||||
margin-left: 1.5em;
|
||||
margin-left: 1.75em;
|
||||
|
||||
&::marker {
|
||||
@include p;
|
||||
}
|
||||
}
|
||||
}
|
||||
li:not(:last-child) {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
a {
|
||||
color: var(--theme-link);
|
||||
cursor: pointer;
|
||||
@@ -174,66 +165,13 @@ main.editor {
|
||||
code {
|
||||
border: 1px solid var(--grey-100);
|
||||
color: var(--theme-accent);
|
||||
padding: 0.2em;
|
||||
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);
|
||||
@@ -249,51 +187,73 @@ main.editor {
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: size-vw(1px);
|
||||
height: 1px;
|
||||
background: currentColor;
|
||||
}
|
||||
}
|
||||
mark {
|
||||
background: var(--theme-accent);
|
||||
color: var(--theme-bg);
|
||||
padding: 0.2em;
|
||||
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: size-vw(20px);
|
||||
gap: 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>
|
||||
242
src/renderer/src/components/note/Find.vue
Normal file
242
src/renderer/src/components/note/Find.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div v-if="visible" class="note-find">
|
||||
<div class="find-bar">
|
||||
<!-- <input
|
||||
@keydown.enter="findNext"
|
||||
@keydown.escape="close"
|
||||
ref="inputRef"
|
||||
/> -->
|
||||
<search-input
|
||||
v-model="searchQuery"
|
||||
placeholder="Find..."
|
||||
ref="inputRef"
|
||||
/>
|
||||
<span class="match-count">
|
||||
{{ currentMatch + 1 }} / {{ matches.length }}
|
||||
</span>
|
||||
<button @click="findPrev" :disabled="matches.length === 0">
|
||||
↑
|
||||
</button>
|
||||
<button @click="findNext" :disabled="matches.length === 0">
|
||||
↓
|
||||
</button>
|
||||
<button @click="close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import SearchInput from '@/components/SearchInput.vue'
|
||||
import { Highlight } from '@tiptap/extension-highlight'
|
||||
|
||||
const props = defineProps({
|
||||
editor: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const searchQuery = ref('')
|
||||
const matches = ref([])
|
||||
const currentMatch = ref(0)
|
||||
const inputRef = ref(null)
|
||||
|
||||
const findInDocument = () => {
|
||||
if (!props.editor || !searchQuery.value) {
|
||||
clearHighlights()
|
||||
matches.value = []
|
||||
return
|
||||
}
|
||||
|
||||
clearHighlights()
|
||||
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
const doc = props.editor.state.doc
|
||||
const foundMatches = []
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText || !node.text) return
|
||||
|
||||
const text = node.text.toLowerCase()
|
||||
let start = 0
|
||||
let index
|
||||
|
||||
while ((index = text.indexOf(query, start)) !== -1) {
|
||||
foundMatches.push({
|
||||
from: pos + index,
|
||||
to: pos + index + query.length,
|
||||
})
|
||||
start = index + 1
|
||||
}
|
||||
})
|
||||
|
||||
matches.value = foundMatches
|
||||
currentMatch.value = 0
|
||||
|
||||
if (foundMatches.length > 0) {
|
||||
highlightMatch(0)
|
||||
scrollToMatch(0)
|
||||
}
|
||||
}
|
||||
|
||||
const highlightMatch = (index) => {
|
||||
if (!props.editor || !matches.value[index]) return
|
||||
|
||||
const { from, to } = matches.value[index]
|
||||
|
||||
props.editor
|
||||
.chain()
|
||||
.setTextSelection({ from, to })
|
||||
.setHighlight({ color: 'var(--theme-accent)' })
|
||||
.run()
|
||||
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
const clearHighlights = () => {
|
||||
if (!props.editor) return
|
||||
|
||||
const { state } = props.editor
|
||||
const { doc } = state
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.marks && node.marks.length > 0) {
|
||||
node.marks.forEach((mark) => {
|
||||
if (mark.type.name === 'highlight') {
|
||||
props.editor.chain().focus().unsetHighlight().run()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
props.editor.commands.unsetHighlight()
|
||||
}
|
||||
|
||||
const scrollToMatch = (index) => {
|
||||
if (!props.editor || !matches.value[index]) return
|
||||
|
||||
const { from } = matches.value[index]
|
||||
props.editor.commands.setTextSelection({ from, to: from })
|
||||
|
||||
const dom = props.editor.view.dom
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0)
|
||||
range.commonAncestorContainer.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const findNext = () => {
|
||||
if (matches.value.length === 0) return
|
||||
|
||||
clearHighlights()
|
||||
currentMatch.value = (currentMatch.value + 1) % matches.value.length
|
||||
highlightMatch(currentMatch.value)
|
||||
scrollToMatch(currentMatch.value)
|
||||
}
|
||||
|
||||
const findPrev = () => {
|
||||
if (matches.value.length === 0) return
|
||||
|
||||
clearHighlights()
|
||||
currentMatch.value =
|
||||
(currentMatch.value - 1 + matches.value.length) % matches.value.length
|
||||
highlightMatch(currentMatch.value)
|
||||
scrollToMatch(currentMatch.value)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
clearHighlights()
|
||||
searchQuery.value = ''
|
||||
matches.value = []
|
||||
emit('close')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (newVal) => {
|
||||
if (newVal) {
|
||||
await nextTick()
|
||||
inputRef.value?.focus()
|
||||
if (props.editor && searchQuery.value) {
|
||||
console.log('visible change')
|
||||
findInDocument()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.editor,
|
||||
(newEditor) => {
|
||||
if (newEditor && props.visible && searchQuery.value) {
|
||||
console.log('editor change')
|
||||
findInDocument()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(searchQuery, () => {
|
||||
console.log('search change')
|
||||
findInDocument()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.note-find {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
|
||||
.find-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
background: var(--theme-bg);
|
||||
border-bottom: 1px solid var(--grey-100);
|
||||
padding: 12px 10px;
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
}
|
||||
.match-count {
|
||||
font-size: 12px;
|
||||
color: var(--grey-100);
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
button {
|
||||
background: transparent;
|
||||
border: 1px solid var(--grey-100);
|
||||
border-radius: 4px;
|
||||
color: var(--theme-fg);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--grey-300);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
61
src/renderer/src/components/note/Menu.vue
Normal file
61
src/renderer/src/components/note/Menu.vue
Normal 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>
|
||||
32
src/renderer/src/components/svg/Spinner.vue
Normal file
32
src/renderer/src/components/svg/Spinner.vue
Normal 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>
|
||||
@@ -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>
|
||||
42
src/renderer/src/composables/useConfig.js
Normal file
42
src/renderer/src/composables/useConfig.js
Normal 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,
|
||||
}
|
||||
}
|
||||
41
src/renderer/src/composables/useEnvironment.ts
Normal file
41
src/renderer/src/composables/useEnvironment.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,93 +1,127 @@
|
||||
import _omit from 'lodash/omit'
|
||||
import { ref } from 'vue'
|
||||
import { getNotesAPI } from '@/libs/core/getNotesAPI'
|
||||
|
||||
const categories = ref([])
|
||||
const searchResults = ref([])
|
||||
const notesChangeCount = ref(0)
|
||||
|
||||
let initialized = false
|
||||
let listenersInitialized = false
|
||||
|
||||
function setupListeners() {
|
||||
if (initialized || typeof window === 'undefined') return
|
||||
initialized = true
|
||||
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()
|
||||
|
||||
window.api.onNoteCreated(() => {
|
||||
notesChangeCount.value++
|
||||
})
|
||||
|
||||
window.api.onNoteUpdated(() => {
|
||||
notesChangeCount.value++
|
||||
})
|
||||
|
||||
// Todo update cache
|
||||
window.api.onNoteDeleted(() => {
|
||||
notesChangeCount.value++
|
||||
})
|
||||
}
|
||||
|
||||
export default () => {
|
||||
const broadcastChange = (event, data) => {
|
||||
window.api.notifyNoteChanged(event, data)
|
||||
}
|
||||
|
||||
setupListeners()
|
||||
|
||||
export default () => {
|
||||
/* -------------------------
|
||||
Initialization
|
||||
--------------------------*/
|
||||
async function loadCategories() {
|
||||
categories.value = await window.notesAPI.call('getCategories')
|
||||
const loadCategories = async () => {
|
||||
const api = await getNotesAPI()
|
||||
categories.value = api.getCategories()
|
||||
}
|
||||
|
||||
async function loadCategoryNotes(category = null) {
|
||||
return await window.notesAPI.call('getCategoryNotes', category)
|
||||
const loadCategoryNotes = async (category = null) => {
|
||||
const api = await getNotesAPI()
|
||||
return api.getCategoryNotes(category)
|
||||
}
|
||||
|
||||
async function loadNote(id) {
|
||||
return await window.notesAPI.call('getNote', id)
|
||||
const loadNote = async (id) => {
|
||||
const api = await getNotesAPI()
|
||||
return api.getNote(id)
|
||||
}
|
||||
|
||||
/* -------------------------
|
||||
Create
|
||||
--------------------------*/
|
||||
async function createNote(metadata, content) {
|
||||
const note = await window.notesAPI.call('createNote', metadata, content)
|
||||
const createNote = async (metadata, content, plainText = '') => {
|
||||
const api = await getNotesAPI()
|
||||
const note = await api.createNote(metadata, content, plainText)
|
||||
await loadCategories()
|
||||
broadcastChange('note-created', note)
|
||||
return note
|
||||
}
|
||||
|
||||
/* -------------------------
|
||||
Update
|
||||
--------------------------*/
|
||||
async function updateNoteContent(id, content) {
|
||||
const note = await window.notesAPI.call('updateNote', id, content)
|
||||
return note
|
||||
}
|
||||
const updateNote = async (id, updates) => {
|
||||
const api = await getNotesAPI()
|
||||
|
||||
async function updateNoteMetadata(id, updates) {
|
||||
const note = await window.notesAPI.call(
|
||||
'updateNoteMetadata',
|
||||
id,
|
||||
updates,
|
||||
)
|
||||
const note = await api.updateNote(id, updates)
|
||||
|
||||
if (updates.category !== undefined || updates.title !== undefined) {
|
||||
await loadCategories()
|
||||
}
|
||||
|
||||
broadcastChange('note-updated', note)
|
||||
|
||||
return note
|
||||
}
|
||||
|
||||
async function updateCategory(category, update) {
|
||||
const updateCategory = async (category, update) => {
|
||||
const notes = await loadCategoryNotes(category)
|
||||
|
||||
notes.forEach(async (note) => {
|
||||
await updateNoteMetadata(note.id, { category: update })
|
||||
})
|
||||
for (const note of notes) {
|
||||
await updateNote(note.id, { category: update })
|
||||
}
|
||||
|
||||
await loadCategories()
|
||||
}
|
||||
|
||||
/* -------------------------
|
||||
Delete
|
||||
--------------------------*/
|
||||
const deleteNote = async (id) => {
|
||||
const api = await getNotesAPI()
|
||||
await api.deleteNote(id)
|
||||
await loadCategories()
|
||||
broadcastChange('note-deleted', { id })
|
||||
}
|
||||
|
||||
/* -------------------------
|
||||
Search
|
||||
--------------------------*/
|
||||
async function search(query) {
|
||||
const search = async (query) => {
|
||||
const api = await getNotesAPI()
|
||||
|
||||
if (!query) {
|
||||
searchResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
searchResults.value = await window.notesAPI.call('search', query)
|
||||
searchResults.value = api.search(query)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -100,9 +134,9 @@ export default () => {
|
||||
loadNote,
|
||||
|
||||
createNote,
|
||||
updateNoteContent,
|
||||
updateNoteMetadata,
|
||||
updateNote,
|
||||
updateCategory,
|
||||
deleteNote,
|
||||
|
||||
search,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useEnvironment, ENVIRONMENTS } from './useEnvironment'
|
||||
|
||||
export default () => {
|
||||
const router = useRouter()
|
||||
@@ -6,13 +7,9 @@ export default () => {
|
||||
function openNote(noteId, options = {}) {
|
||||
const { newWindow = true } = options
|
||||
|
||||
// Electron environment check
|
||||
const isElectron =
|
||||
typeof window !== 'undefined' &&
|
||||
window.api &&
|
||||
typeof window.api.openNoteWindow === 'function'
|
||||
const environment = useEnvironment()
|
||||
|
||||
if (newWindow && isElectron) {
|
||||
if (newWindow && environment === ENVIRONMENTS.ELECTRON) {
|
||||
window.api.openNoteWindow(noteId)
|
||||
return
|
||||
}
|
||||
|
||||
20
src/renderer/src/composables/usePlugins.js
Normal file
20
src/renderer/src/composables/usePlugins.js
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
8
src/renderer/src/composables/useState.js
Normal file
8
src/renderer/src/composables/useState.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createGlobalState } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export default createGlobalState(() => {
|
||||
const moveMenuOpen = ref(false)
|
||||
|
||||
return { moveMenuOpen }
|
||||
})
|
||||
28
src/renderer/src/content/instructions.md
Normal file
28
src/renderer/src/content/instructions.md
Normal 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
|
||||
25
src/renderer/src/libs/core/IpcAdapter.js
Normal file
25
src/renderer/src/libs/core/IpcAdapter.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Index } from 'flexsearch'
|
||||
import crypto from 'crypto'
|
||||
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 {
|
||||
constructor(adapter, encryptionKey = null) {
|
||||
@@ -10,12 +11,11 @@ export default class NotesAPI {
|
||||
|
||||
this.adapter = adapter
|
||||
this.notesCache = new Map()
|
||||
this.encryptionKey = encryptionKey || process.env.NOTES_ENCRYPTION_KEY
|
||||
this.encryptionKey = encryptionKey
|
||||
this._sodiumReady = false
|
||||
|
||||
this.index = new Index({
|
||||
tokenize: 'tolerant',
|
||||
resolution: 9,
|
||||
tokenize: 'forward',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default class NotesAPI {
|
||||
throw new Error('Encryption key not set')
|
||||
}
|
||||
|
||||
const key = Buffer.from(this.encryptionKey, 'hex')
|
||||
const key = uint.hexToUint8Array(this.encryptionKey)
|
||||
if (key.length !== 32) {
|
||||
throw new Error(
|
||||
'Encryption key must be 64 hex characters (32 bytes)',
|
||||
@@ -42,13 +42,13 @@ export default class NotesAPI {
|
||||
const message = JSON.stringify(note)
|
||||
|
||||
const ciphertext = sodium.crypto_secretbox_easy(
|
||||
Buffer.from(message),
|
||||
new TextEncoder().encode(message),
|
||||
nonce,
|
||||
key,
|
||||
)
|
||||
|
||||
const combined = Buffer.concat([nonce, ciphertext])
|
||||
return combined.toString('base64')
|
||||
const combined = uint.concatUint8Arrays(nonce, ciphertext)
|
||||
return uint.uint8ArrayToBase64(combined)
|
||||
}
|
||||
|
||||
_decrypt(encryptedData) {
|
||||
@@ -56,7 +56,7 @@ export default class NotesAPI {
|
||||
throw new Error('Encryption key not set')
|
||||
}
|
||||
|
||||
const key = Buffer.from(this.encryptionKey, 'hex')
|
||||
const key = uint.hexToUint8Array(this.encryptionKey)
|
||||
if (key.length !== 32) {
|
||||
throw new Error(
|
||||
'Encryption key must be 64 hex characters (32 bytes)',
|
||||
@@ -65,7 +65,7 @@ export default class NotesAPI {
|
||||
|
||||
let combined
|
||||
try {
|
||||
combined = Buffer.from(encryptedData, 'base64')
|
||||
combined = uint.base64ToUint8Array(encryptedData)
|
||||
} catch (e) {
|
||||
throw new Error('Invalid encrypted data: not valid base64')
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export default class NotesAPI {
|
||||
throw new Error('Decryption failed: no data returned')
|
||||
}
|
||||
|
||||
const decryptedStr = Buffer.from(decrypted).toString('utf8')
|
||||
const decryptedStr = new TextDecoder().decode(decrypted)
|
||||
|
||||
try {
|
||||
return JSON.parse(decryptedStr)
|
||||
@@ -110,6 +110,7 @@ export default class NotesAPI {
|
||||
async init() {
|
||||
await this._initSodium()
|
||||
await this.adapter.init()
|
||||
this.notesCache.clear()
|
||||
|
||||
const encryptedNotes = await this.adapter.getAll()
|
||||
|
||||
@@ -118,13 +119,28 @@ export default class NotesAPI {
|
||||
const note = this._decrypt(encryptedNote.data || encryptedNote)
|
||||
|
||||
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) {
|
||||
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
|
||||
------------------------*/
|
||||
@@ -140,18 +156,20 @@ export default class NotesAPI {
|
||||
return Array.from(categories).sort()
|
||||
}
|
||||
|
||||
getCategoryNotes(categoryName) {
|
||||
getCategoryNotes(categoryName = null) {
|
||||
return Array.from(this.notesCache.values())
|
||||
.filter((n) => n.category === categoryName)
|
||||
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
|
||||
.map((n) => ({ ...n }))
|
||||
}
|
||||
|
||||
getNote(id) {
|
||||
return this.notesCache.get(id) ?? null
|
||||
const note = this.notesCache.get(id)
|
||||
return note ? { ...note } : null
|
||||
}
|
||||
|
||||
async createNote(metadata = {}, content = '') {
|
||||
const id = crypto.randomUUID()
|
||||
async createNote(metadata = {}, content = '', plainText = '') {
|
||||
const id = uuidv4()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const note = {
|
||||
@@ -161,6 +179,7 @@ export default class NotesAPI {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
content,
|
||||
plainText,
|
||||
}
|
||||
|
||||
const encryptedNote = {
|
||||
@@ -169,7 +188,7 @@ export default class NotesAPI {
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -183,60 +202,46 @@ export default class NotesAPI {
|
||||
this.index.remove(id)
|
||||
}
|
||||
|
||||
async updateNote(id, content) {
|
||||
async updateNote(id, updates = {}) {
|
||||
const note = this.notesCache.get(id)
|
||||
if (!note) throw new Error('Note not found')
|
||||
|
||||
note.content = content
|
||||
note.updatedAt = new Date().toISOString()
|
||||
const allowedFields = ['title', 'category', 'content', 'plainText']
|
||||
|
||||
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}`)
|
||||
throw new Error(`Invalid update field: ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.title !== undefined) {
|
||||
note.title = updates.title
|
||||
const updatedNote = {
|
||||
...note,
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (updates.category !== undefined) {
|
||||
note.category = updates.category
|
||||
}
|
||||
|
||||
note.updatedAt = new Date().toISOString()
|
||||
|
||||
const encryptedNote = {
|
||||
id: note.id,
|
||||
data: this._encrypt(note),
|
||||
id: updatedNote.id,
|
||||
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)
|
||||
|
||||
return note
|
||||
return updatedNote
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
47
src/renderer/src/libs/core/getNotesAPI.js
Normal file
47
src/renderer/src/libs/core/getNotesAPI.js
Normal 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
|
||||
}
|
||||
@@ -4,43 +4,27 @@ const colors = {
|
||||
'grey-100': '#747474',
|
||||
green: '#87FF5B',
|
||||
blue: '#5B92FF',
|
||||
purple: '#94079E',
|
||||
}
|
||||
|
||||
const themes = {
|
||||
light: {
|
||||
bg: colors.white,
|
||||
fg: colors.black,
|
||||
accent: colors.green,
|
||||
link: colors.blue,
|
||||
},
|
||||
dark: {
|
||||
bg: colors.black,
|
||||
fg: colors.white,
|
||||
accent: colors.green,
|
||||
link: colors.blue,
|
||||
},
|
||||
}
|
||||
|
||||
const breakpoints = {
|
||||
mobile: 800,
|
||||
}
|
||||
|
||||
const viewports = {
|
||||
mobile: {
|
||||
width: 200,
|
||||
height: 956,
|
||||
},
|
||||
desktop: {
|
||||
width: 354,
|
||||
height: 549,
|
||||
light: {
|
||||
bg: colors.white,
|
||||
fg: colors.black,
|
||||
accent: colors.purple,
|
||||
link: colors.blue,
|
||||
},
|
||||
}
|
||||
|
||||
export { colors, themes, breakpoints, viewports }
|
||||
export { colors, themes }
|
||||
|
||||
export default {
|
||||
colors,
|
||||
themes,
|
||||
breakpoints,
|
||||
viewports,
|
||||
}
|
||||
|
||||
35
src/renderer/src/libs/uint.js
Normal file
35
src/renderer/src/libs/uint.js
Normal 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
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
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 Instructions from '@/views/Instructions.vue'
|
||||
import Search from '@/views/Search.vue'
|
||||
import Preferences from '@/views/Preferences.vue'
|
||||
|
||||
const routes = [
|
||||
{ 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: '/instructions', name: 'instructions', component: Instructions },
|
||||
{ path: '/search', name: 'search', component: Search },
|
||||
{ path: '/preferences', name: 'preferences', component: Preferences },
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.3;
|
||||
font-size: size-vw(30px);
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
@mixin h1-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
font-size: size-vw(22px);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
@mixin p {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
font-size: size-vw(12px);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src:
|
||||
url('../../../../resources/fonts/leibniz-fraktur.woff2') format('woff2'),
|
||||
url('../../../../resources/fonts/leibniz-fraktur.woff') format('woff');
|
||||
url('../../../../resources/fonts/neuefraktur.woff2') format('woff2'),
|
||||
url('../../../../resources/fonts/neuefraktur.woff') format('woff');
|
||||
}
|
||||
|
||||
/* Geist Mono */
|
||||
|
||||
@@ -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) {
|
||||
@return calc(
|
||||
(#{$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) {
|
||||
opacity: 0;
|
||||
transition: opacity $duration ease;
|
||||
@@ -90,90 +27,12 @@ $mobile-height: get('viewports.mobile.height');
|
||||
}
|
||||
|
||||
// Clamp text block to number of lines
|
||||
@mixin line-clamp($lines: 3, $mobile-lines: $lines) {
|
||||
@mixin line-clamp($lines: 3) {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-box-orient: vertical;
|
||||
-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) {
|
||||
@@ -185,20 +44,18 @@ $mobile-height: get('viewports.mobile.height');
|
||||
}
|
||||
|
||||
@mixin hover {
|
||||
@include desktop {
|
||||
&:hover {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin drop-cap() {
|
||||
font-family: var(--font-mono);
|
||||
font-size: size-vw(12px);
|
||||
font-size: 12px;
|
||||
font-weight: 400 !important;
|
||||
|
||||
&:first-child::first-letter {
|
||||
font-family: var(--font-display);
|
||||
font-size: size-vw(42px);
|
||||
font-size: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,7 @@
|
||||
|
||||
// css classes exposed globally:
|
||||
// .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-inner: same as .layout-grid but using padding instead of margin
|
||||
|
||||
@use 'sass:map';
|
||||
|
||||
@@ -20,26 +18,21 @@
|
||||
// 'variable': (mobile, desktop)
|
||||
$layout: (
|
||||
'columns-count': (
|
||||
5,
|
||||
18,
|
||||
6,
|
||||
),
|
||||
'columns-gap': (
|
||||
20px,
|
||||
20px,
|
||||
10px,
|
||||
),
|
||||
'margin': (
|
||||
30px,
|
||||
60px,
|
||||
20px,
|
||||
),
|
||||
);
|
||||
|
||||
//internal process, do not touch
|
||||
:root {
|
||||
--layout-column-count: #{list.nth(map.get($layout, 'columns-count'), 1)};
|
||||
--layout-column-gap: #{size-vw(
|
||||
list.nth(map.get($layout, 'columns-gap'), 1)
|
||||
)};
|
||||
--layout-margin: #{size-vw(list.nth(map.get($layout, 'margin'), 1))};
|
||||
--layout-column-gap: #{list.nth(map.get($layout, 'columns-gap'), 1)};
|
||||
--layout-margin: #{list.nth(map.get($layout, 'margin'), 1)};
|
||||
--layout-width: calc(100vw - (2 * var(--layout-margin)));
|
||||
--layout-column-width: calc(
|
||||
(
|
||||
@@ -54,13 +47,6 @@ $layout: (
|
||||
}
|
||||
|
||||
.layout-block {
|
||||
max-width: var(--layout-width);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.layout-block-inner {
|
||||
padding-left: var(--layout-margin);
|
||||
padding-right: var(--layout-margin);
|
||||
width: 100%;
|
||||
@@ -73,11 +59,3 @@ $layout: (
|
||||
grid-template-columns: repeat(var(--layout-column-count), minmax(0, 1fr));
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
52
src/renderer/src/styles/_syntax.scss
Normal file
52
src/renderer/src/styles/_syntax.scss
Normal file
@@ -0,0 +1,52 @@
|
||||
/* Code styling */
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: var(--grey-100);
|
||||
}
|
||||
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-attribute,
|
||||
.hljs-tag,
|
||||
.hljs-name,
|
||||
.hljs-regexp,
|
||||
.hljs-link,
|
||||
.hljs-name,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class {
|
||||
color: #ff5b69;
|
||||
}
|
||||
|
||||
.hljs-number,
|
||||
.hljs-meta,
|
||||
.hljs-built_in,
|
||||
.hljs-builtin-name,
|
||||
.hljs-literal,
|
||||
.hljs-type,
|
||||
.hljs-params {
|
||||
color: #ffa55b;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet {
|
||||
color: var(--theme-accent);
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-section {
|
||||
color: #fcff5b;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag {
|
||||
color: #5b9aff;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -17,26 +17,8 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
@include desktop {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
@include mobile {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
html:not(.has-scroll-smooth) {
|
||||
.hide-on-native-scroll {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
html.has-scroll-smooth {
|
||||
.hide-on-smooth-scroll {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
@use 'font-style' as *;
|
||||
@use 'layout' as *;
|
||||
@use 'scroll' as *;
|
||||
@use 'syntax' as *;
|
||||
@use 'transitions' as *;
|
||||
|
||||
:root {
|
||||
@@ -46,7 +47,10 @@ p,
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
pre {
|
||||
pre,
|
||||
span,
|
||||
label,
|
||||
li {
|
||||
@include p;
|
||||
}
|
||||
.bold {
|
||||
@@ -64,9 +68,9 @@ button {
|
||||
// Text selection
|
||||
::selection {
|
||||
color: var(--theme-bg);
|
||||
background: var(--theme-accent);
|
||||
background: var(--theme-fg);
|
||||
}
|
||||
::-moz-selection {
|
||||
color: var(--theme-bg);
|
||||
background: var(--theme-accent);
|
||||
background: var(--theme-fg);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<main class="category layout-block">
|
||||
<router-link class="back" to="/"><- Go Back</router-link>
|
||||
|
||||
<category-row
|
||||
:index="categoryIndex"
|
||||
:category="id"
|
||||
@@ -61,23 +59,20 @@ const categoryIndex = computed(() => {
|
||||
|
||||
<style lang="scss">
|
||||
main.category {
|
||||
.back {
|
||||
display: block;
|
||||
opacity: 0.25;
|
||||
margin-top: size-vw(9px);
|
||||
}
|
||||
padding-top: var(--nav-height);
|
||||
|
||||
.category-row {
|
||||
margin-top: size-vw(4px);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.notes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: size-vw(14px);
|
||||
margin-top: size-vw(9px);
|
||||
gap: 14px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.new-note {
|
||||
display: block;
|
||||
margin: size-vw(50px) auto 0;
|
||||
margin: 50px auto 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<main class="create-category layout-block-inner">
|
||||
<main class="create-category layout-block">
|
||||
<category-row :index="1" editable @edited="onCategoryEdited" />
|
||||
</main>
|
||||
</template>
|
||||
@@ -14,3 +14,9 @@ const onCategoryEdited = (name) => {
|
||||
router.push({ name: 'category', params: { id: name } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.create-category {
|
||||
padding-top: var(--nav-height);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<main class="directory layout-block">
|
||||
<main v-if="loaded" class="directory layout-block">
|
||||
<category-row
|
||||
v-for="(category, i) in categories"
|
||||
:index="i"
|
||||
@@ -7,28 +7,38 @@
|
||||
:key="category"
|
||||
/>
|
||||
|
||||
<h2 class="label">Summarium</h2>
|
||||
<h2 v-if="notes?.length" class="label">Summarium</h2>
|
||||
|
||||
<div class="notes">
|
||||
<note-row v-for="note in notes" :note="note" :key="note.id" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<page-loading v-else />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import useNotes from '@/composables/useNotes'
|
||||
import usePlugins from '@/composables/usePlugins'
|
||||
import useConfig from '@/composables/useConfig'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import CategoryRow from '@/components/CategoryRow.vue'
|
||||
import NoteRow from '@/components/NoteRow.vue'
|
||||
import PageLoading from '@/components/PageLoading.vue'
|
||||
|
||||
const { categories, loadCategories, loadCategoryNotes, notesChangeCount } =
|
||||
useNotes()
|
||||
|
||||
const notes = ref()
|
||||
const { config } = useConfig()
|
||||
|
||||
async function refreshNotes() {
|
||||
const notes = ref()
|
||||
const loaded = ref(false)
|
||||
|
||||
const refreshNotes = async () => {
|
||||
loaded.value = false
|
||||
await loadCategories()
|
||||
notes.value = await loadCategoryNotes()
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -42,17 +52,18 @@ watch(notesChangeCount, async () => {
|
||||
|
||||
<style lang="scss">
|
||||
main.directory {
|
||||
padding-top: size-vw(18px);
|
||||
padding-top: var(--nav-height);
|
||||
padding-bottom: 30px;
|
||||
|
||||
.label {
|
||||
text-transform: uppercase;
|
||||
margin: size-vw(17px) 0 size-vw(24px);
|
||||
margin: 17px 0 24px;
|
||||
@include p;
|
||||
}
|
||||
.notes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: size-vw(14px);
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,74 +1,29 @@
|
||||
<template>
|
||||
<main class="instructions layout-block-inner">
|
||||
<router-link class="back-link" to="/"><- Go Back</router-link>
|
||||
|
||||
<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>
|
||||
<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 class="instructions layout-block">
|
||||
<div class="content" v-html="renderedContent" />
|
||||
</main>
|
||||
</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">
|
||||
main.instructions {
|
||||
.back-link {
|
||||
opacity: 0.25;
|
||||
display: block;
|
||||
margin-top: size-vw(9px);
|
||||
margin-bottom: size-vw(14px);
|
||||
padding-top: var(--nav-height);
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
hr {
|
||||
border-bottom: 1px dashed currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
41
src/renderer/src/views/Note.vue
Normal file
41
src/renderer/src/views/Note.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<main class="note layout-block">
|
||||
<note-download :editor="editorRef?.editor" />
|
||||
|
||||
<note-find
|
||||
:editor="editorRef?.editor"
|
||||
:visible="findVisible"
|
||||
@close="findVisible = false"
|
||||
/>
|
||||
<note-editor ref="editorRef" :id="id" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import { useMagicKeys } from '@vueuse/core'
|
||||
import { useRoute } from 'vue-router'
|
||||
import NoteEditor from '@/components/note/Editor.vue'
|
||||
import NoteFind from '@/components/note/Find.vue'
|
||||
import NoteDownload from '@/components/note/Download.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const id = route.params.id
|
||||
|
||||
const editorRef = ref(null)
|
||||
const findVisible = ref(false)
|
||||
|
||||
const { ctrl, f } = useMagicKeys()
|
||||
watchEffect(() => {
|
||||
if (ctrl.value && f.value) {
|
||||
findVisible.value = !findVisible.value
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
main.note {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
189
src/renderer/src/views/Preferences.vue
Normal file
189
src/renderer/src/views/Preferences.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<main class="preferences layout-block">
|
||||
<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 normalizeConfig = () => {
|
||||
if (!config.value.adapters) {
|
||||
config.value.adapters = {}
|
||||
}
|
||||
|
||||
for (const plugin of plugins.value) {
|
||||
if (!config.value.adapters[plugin.id]) {
|
||||
config.value.adapters[plugin.id] = {}
|
||||
}
|
||||
|
||||
for (const field of plugin.configSchema) {
|
||||
if (config.value.adapters[plugin.id][field.key] === undefined) {
|
||||
config.value.adapters[plugin.id][field.key] =
|
||||
field.default ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
normalizeConfig()
|
||||
|
||||
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: var(--nav-height);
|
||||
padding-bottom: 60px;
|
||||
|
||||
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 var(--theme-fg);
|
||||
cursor: pointer;
|
||||
|
||||
&:checked {
|
||||
background-color: var(--theme-fg);
|
||||
}
|
||||
}
|
||||
|
||||
.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>
|
||||
62
src/renderer/src/views/Search.vue
Normal file
62
src/renderer/src/views/Search.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<main class="search layout-block">
|
||||
<form @submit.prevent="onSearch">
|
||||
<search-input
|
||||
v-model="query"
|
||||
placeholder="Search"
|
||||
ref="searchInput"
|
||||
@input="onInput"
|
||||
/>
|
||||
</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 SearchInput from '@/components/SearchInput.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: var(--nav-height);
|
||||
|
||||
.results {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user