config system + move api to frontend
This commit is contained in:
@@ -5,9 +5,6 @@ import filesystemPlugin from "@takerofnotes/plugin-filesystem";
|
|||||||
import supabasePlugin from "@takerofnotes/plugin-supabase";
|
import supabasePlugin from "@takerofnotes/plugin-supabase";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path, { join } from "path";
|
import path, { join } from "path";
|
||||||
import { Index } from "flexsearch";
|
|
||||||
import crypto from "crypto";
|
|
||||||
import sodium from "libsodium-wrappers";
|
|
||||||
import __cjs_mod__ from "node:module";
|
import __cjs_mod__ from "node:module";
|
||||||
const __filename = import.meta.filename;
|
const __filename = import.meta.filename;
|
||||||
const __dirname = import.meta.dirname;
|
const __dirname = import.meta.dirname;
|
||||||
@@ -26,16 +23,21 @@ class PluginRegistry {
|
|||||||
return this.plugins.get(id);
|
return this.plugins.get(id);
|
||||||
}
|
}
|
||||||
list() {
|
list() {
|
||||||
return Array.from(this.plugins.values());
|
return Array.from(this.plugins.values()).map((plugin) => ({
|
||||||
|
id: plugin.id,
|
||||||
|
name: plugin.name,
|
||||||
|
description: plugin.description,
|
||||||
|
configSchema: plugin.configSchema
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const USER_DATA_STRING = "__DEFAULT_USER_DATA__";
|
const USER_DATA_STRING = "__DEFAULT_USER_DATA__";
|
||||||
class PluginConfig {
|
class Config {
|
||||||
constructor(defaultPlugin) {
|
constructor(defaultPlugin) {
|
||||||
this.defaultPlugin = defaultPlugin;
|
this.defaultPlugin = defaultPlugin;
|
||||||
this.configPath = path.join(app.getPath("userData"), "config.json");
|
this.configPath = path.join(app.getPath("userData"), "config.json");
|
||||||
}
|
}
|
||||||
// Helper to replace placeholders with dynamic values, recursively
|
// Helper to replace placeholders with dynamic values
|
||||||
_resolveDefaults(config) {
|
_resolveDefaults(config) {
|
||||||
if (Array.isArray(config)) {
|
if (Array.isArray(config)) {
|
||||||
return config.map((item) => this._resolveDefaults(item));
|
return config.map((item) => this._resolveDefaults(item));
|
||||||
@@ -65,9 +67,10 @@ class PluginConfig {
|
|||||||
defaultConfig[field.key] = field.default ?? null;
|
defaultConfig[field.key] = field.default ?? null;
|
||||||
}
|
}
|
||||||
parsed = {
|
parsed = {
|
||||||
activeAdapter: this.defaultPlugin.id,
|
...parsed ? parsed : {},
|
||||||
adapterConfig: defaultConfig
|
activeAdapter: this.defaultPlugin.id
|
||||||
};
|
};
|
||||||
|
parsed.adapters[this.defaultPlugin.id] = defaultConfig;
|
||||||
await this.write(parsed);
|
await this.write(parsed);
|
||||||
} else {
|
} else {
|
||||||
parsed.adapterConfig = this._resolveDefaults(parsed.adapterConfig);
|
parsed.adapterConfig = this._resolveDefaults(parsed.adapterConfig);
|
||||||
@@ -88,206 +91,6 @@ 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: "forward"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
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
|
|
||||||
------------------------*/
|
|
||||||
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 = "", plainText = "") {
|
|
||||||
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,
|
|
||||||
plainText
|
|
||||||
};
|
|
||||||
const encryptedNote = {
|
|
||||||
id: note.id,
|
|
||||||
data: this._encrypt(note)
|
|
||||||
};
|
|
||||||
this.notesCache.set(id, note);
|
|
||||||
this.index.add(id, note.title + "\n" + plainText);
|
|
||||||
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, plainText = "") {
|
|
||||||
const note = this.notesCache.get(id);
|
|
||||||
if (!note) throw new Error("Note not found");
|
|
||||||
note.content = content;
|
|
||||||
note.plainText = plainText;
|
|
||||||
note.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
||||||
const encryptedNote = {
|
|
||||||
id: note.id,
|
|
||||||
data: this._encrypt(note)
|
|
||||||
};
|
|
||||||
this.index.update(id, note.title + "\n" + plainText);
|
|
||||||
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.plainText || this._extractPlainText(note.content))
|
|
||||||
);
|
|
||||||
await this.adapter.update(encryptedNote);
|
|
||||||
return note;
|
|
||||||
}
|
|
||||||
search(query) {
|
|
||||||
const ids = this.index.search(query, {
|
|
||||||
limit: 50,
|
|
||||||
suggest: true
|
|
||||||
});
|
|
||||||
return ids.map((id) => this.notesCache.get(id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const preloadPath = join(__dirname, "../preload/index.mjs");
|
const preloadPath = join(__dirname, "../preload/index.mjs");
|
||||||
const rendererPath = join(__dirname, "../renderer/index.html");
|
const rendererPath = join(__dirname, "../renderer/index.html");
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
@@ -343,34 +146,44 @@ app.whenReady().then(async () => {
|
|||||||
const registry = new PluginRegistry();
|
const registry = new PluginRegistry();
|
||||||
registry.register(filesystemPlugin);
|
registry.register(filesystemPlugin);
|
||||||
registry.register(supabasePlugin);
|
registry.register(supabasePlugin);
|
||||||
const config = await new PluginConfig(filesystemPlugin).load();
|
const config = new Config(filesystemPlugin);
|
||||||
const plugin = registry.get(config.activeAdapter);
|
const initialConfig = await config.load();
|
||||||
const adapter = plugin.createAdapter(config.adapterConfig);
|
const setActivePlugin = async (pluginId) => {
|
||||||
const notesAPI = new NotesAPI(
|
const currentConfig = await config.load();
|
||||||
adapter,
|
await config.write({ ...currentConfig, activeAdapter: pluginId });
|
||||||
"729a0d21d783654c68f1a0123e2a0e986350de536b5324f1f35876ea12ffeaf5"
|
const plugin = registry.get(pluginId);
|
||||||
);
|
const adapterConfig = currentConfig.adapters[pluginId] || {};
|
||||||
await notesAPI.init();
|
const adapter = plugin.createAdapter(adapterConfig);
|
||||||
|
await adapter.init();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
const broadcastNoteChange = (event, data) => {
|
const broadcastNoteChange = (event, data) => {
|
||||||
BrowserWindow.getAllWindows().forEach((win) => {
|
BrowserWindow.getAllWindows().forEach((win) => {
|
||||||
win.webContents.send(event, data);
|
win.webContents.send(event, data);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
ipcMain.handle("notesAPI:call", async (_, method, args) => {
|
ipcMain.on("note-changed", (_, event, data) => {
|
||||||
if (!notesAPI[method]) {
|
broadcastNoteChange(event, data);
|
||||||
throw new Error("Invalid 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 result;
|
|
||||||
});
|
});
|
||||||
electronApp.setAppUserModelId("com.electron");
|
electronApp.setAppUserModelId("com.electron");
|
||||||
app.on("browser-window-created", (_, window) => {
|
app.on("browser-window-created", (_, window) => {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron";
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
const api = {
|
const api = {
|
||||||
|
getConfig: () => ipcRenderer.invoke("getConfig"),
|
||||||
|
setConfig: (config) => ipcRenderer.invoke("setConfig", config),
|
||||||
|
listPlugins: () => ipcRenderer.invoke("listPlugins"),
|
||||||
|
setActivePlugin: (pluginId) => ipcRenderer.invoke("setActivePlugin", pluginId),
|
||||||
openNoteWindow: (noteId) => {
|
openNoteWindow: (noteId) => {
|
||||||
ipcRenderer.send("open-note-window", noteId);
|
ipcRenderer.send("open-note-window", noteId);
|
||||||
},
|
},
|
||||||
@@ -11,18 +15,22 @@ const api = {
|
|||||||
},
|
},
|
||||||
onNoteDeleted: (callback) => {
|
onNoteDeleted: (callback) => {
|
||||||
ipcRenderer.on("note-deleted", (_, data) => callback(data));
|
ipcRenderer.on("note-deleted", (_, data) => callback(data));
|
||||||
|
},
|
||||||
|
notifyNoteChanged: (event, data) => {
|
||||||
|
ipcRenderer.send("note-changed", event, data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const notesAPI = {
|
const adapter = {
|
||||||
call: (method, ...args) => ipcRenderer.invoke("notesAPI:call", method, args)
|
call: (method, ...args) => ipcRenderer.invoke("adapter:call", method, args)
|
||||||
};
|
};
|
||||||
if (process.contextIsolated) {
|
if (process.contextIsolated) {
|
||||||
try {
|
try {
|
||||||
contextBridge.exposeInMainWorld("api", api);
|
contextBridge.exposeInMainWorld("api", api);
|
||||||
contextBridge.exposeInMainWorld("notesAPI", notesAPI);
|
contextBridge.exposeInMainWorld("adapter", adapter);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.api = api;
|
window.api = api;
|
||||||
|
window.adapter = adapter;
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
};
|
||||||
@@ -7,18 +7,18 @@
|
|||||||
--blue: #5B92FF;
|
--blue: #5B92FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root .theme-light {
|
|
||||||
--theme-bg: #D5D5D5;
|
|
||||||
--theme-fg: #181818;
|
|
||||||
--theme-accent: #87FF5B;
|
|
||||||
--theme-link: #5B92FF;
|
|
||||||
}
|
|
||||||
:root .theme-dark {
|
:root .theme-dark {
|
||||||
--theme-bg: #181818;
|
--theme-bg: #181818;
|
||||||
--theme-fg: #D5D5D5;
|
--theme-fg: #D5D5D5;
|
||||||
--theme-accent: #87FF5B;
|
--theme-accent: #87FF5B;
|
||||||
--theme-link: #5B92FF;
|
--theme-link: #5B92FF;
|
||||||
}
|
}
|
||||||
|
:root .theme-light {
|
||||||
|
--theme-bg: #D5D5D5;
|
||||||
|
--theme-fg: #181818;
|
||||||
|
--theme-accent: #87FF5B;
|
||||||
|
--theme-link: #5B92FF;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53);
|
--ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53);
|
||||||
@@ -336,7 +336,8 @@ p,
|
|||||||
a,
|
a,
|
||||||
button,
|
button,
|
||||||
input,
|
input,
|
||||||
pre {
|
pre,
|
||||||
|
span {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -363,7 +364,30 @@ button {
|
|||||||
::-moz-selection {
|
::-moz-selection {
|
||||||
color: var(--theme-bg);
|
color: var(--theme-bg);
|
||||||
background: var(--theme-fg);
|
background: var(--theme-fg);
|
||||||
|
}.theme-switcher {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.theme-switcher button {
|
||||||
|
background: var(--theme-bg);
|
||||||
|
display: block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.theme-switcher button.active {
|
||||||
|
border: 1px solid var(--theme-fg);
|
||||||
|
}
|
||||||
|
.theme-switcher:hover {
|
||||||
|
color: var(--theme-fg) !important;
|
||||||
}.nav {
|
}.nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
padding-top: 9px;
|
padding-top: 9px;
|
||||||
color: var(--grey-100);
|
color: var(--grey-100);
|
||||||
}.menu {
|
}.menu {
|
||||||
@@ -381,7 +405,6 @@ button {
|
|||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
.menu .menu-wrap .menu-item {
|
.menu .menu-wrap .menu-item {
|
||||||
display: block;
|
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -489,6 +512,7 @@ button {
|
|||||||
}.note-row {
|
}.note-row {
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -765,4 +789,38 @@ main.category .new-note {
|
|||||||
display: block;
|
display: block;
|
||||||
margin-top: 9px;
|
margin-top: 9px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
|
}main.search .back {
|
||||||
|
display: block;
|
||||||
|
opacity: 0.25;
|
||||||
|
margin-top: 9px;
|
||||||
|
}
|
||||||
|
main.search .input-wrap {
|
||||||
|
margin-top: 19px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
main.search .input-wrap 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%);
|
||||||
|
}
|
||||||
|
main.search .input-wrap::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%);
|
||||||
|
}
|
||||||
|
main.search .results {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
}
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -8,8 +8,8 @@
|
|||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
|
||||||
/>
|
/>
|
||||||
<script type="module" crossorigin src="./assets/index-B9wwyKue.js"></script>
|
<script type="module" crossorigin src="./assets/index-CoqDP7Z2.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-CiC_zCT0.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-CVyE7-c9.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { app } from 'electron'
|
|||||||
|
|
||||||
const USER_DATA_STRING = '__DEFAULT_USER_DATA__'
|
const USER_DATA_STRING = '__DEFAULT_USER_DATA__'
|
||||||
|
|
||||||
export default class PluginConfig {
|
export default class Config {
|
||||||
constructor(defaultPlugin) {
|
constructor(defaultPlugin) {
|
||||||
this.defaultPlugin = defaultPlugin
|
this.defaultPlugin = defaultPlugin
|
||||||
this.configPath = path.join(app.getPath('userData'), 'config.json')
|
this.configPath = path.join(app.getPath('userData'), 'config.json')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to replace placeholders with dynamic values, recursively
|
// Helper to replace placeholders with dynamic values
|
||||||
_resolveDefaults(config) {
|
_resolveDefaults(config) {
|
||||||
if (Array.isArray(config)) {
|
if (Array.isArray(config)) {
|
||||||
return config.map((item) => this._resolveDefaults(item))
|
return config.map((item) => this._resolveDefaults(item))
|
||||||
@@ -48,9 +48,10 @@ export default class PluginConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
parsed = {
|
parsed = {
|
||||||
|
...(parsed ? parsed : {}),
|
||||||
activeAdapter: this.defaultPlugin.id,
|
activeAdapter: this.defaultPlugin.id,
|
||||||
adapterConfig: defaultConfig,
|
|
||||||
}
|
}
|
||||||
|
parsed.adapters[this.defaultPlugin.id] = defaultConfig
|
||||||
|
|
||||||
await this.write(parsed)
|
await this.write(parsed)
|
||||||
} else {
|
} else {
|
||||||
@@ -16,6 +16,11 @@ export default class PluginRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
list() {
|
list() {
|
||||||
return Array.from(this.plugins.values())
|
return Array.from(this.plugins.values()).map((plugin) => ({
|
||||||
|
id: plugin.id,
|
||||||
|
name: plugin.name,
|
||||||
|
description: plugin.description,
|
||||||
|
configSchema: plugin.configSchema,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
|||||||
import filesystemPlugin from '@takerofnotes/plugin-filesystem'
|
import filesystemPlugin from '@takerofnotes/plugin-filesystem'
|
||||||
import supabasePlugin from '@takerofnotes/plugin-supabase'
|
import supabasePlugin from '@takerofnotes/plugin-supabase'
|
||||||
import PluginRegistry from './core/PluginRegistry.js'
|
import PluginRegistry from './core/PluginRegistry.js'
|
||||||
import PluginConfig from './core/PluginConfig.js'
|
import Config from './core/Config.js'
|
||||||
import NotesAPI from './core/NotesAPI.js'
|
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
|
||||||
const preloadPath = join(__dirname, '../preload/index.mjs')
|
const preloadPath = join(__dirname, '../preload/index.mjs')
|
||||||
@@ -79,23 +78,51 @@ app.whenReady().then(async () => {
|
|||||||
registry.register(supabasePlugin)
|
registry.register(supabasePlugin)
|
||||||
|
|
||||||
// Pull plugin config
|
// Pull plugin config
|
||||||
const config = await new PluginConfig(filesystemPlugin).load()
|
const config = new Config(filesystemPlugin)
|
||||||
|
const initialConfig = await config.load()
|
||||||
|
|
||||||
// Create instance of active adapter
|
const setActivePlugin = async (pluginId) => {
|
||||||
const plugin = registry.get(config.activeAdapter)
|
const currentConfig = await config.load()
|
||||||
// const plugin = registry.get(supabasePlugin.id)
|
await config.write({ ...currentConfig, activeAdapter: pluginId })
|
||||||
const adapter = plugin.createAdapter(config.adapterConfig)
|
|
||||||
// const adapter = plugin.createAdapter({
|
|
||||||
// supabaseKey: process.env.SUPABASE_KEY,
|
|
||||||
// supabaseUrl: process.env.SUPABASE_URL,
|
|
||||||
// })
|
|
||||||
|
|
||||||
// Init Notes API
|
const plugin = registry.get(pluginId)
|
||||||
const notesAPI = new NotesAPI(
|
const adapterConfig = currentConfig.adapters[pluginId] || {}
|
||||||
adapter,
|
const adapter = plugin.createAdapter(adapterConfig)
|
||||||
'729a0d21d783654c68f1a0123e2a0e986350de536b5324f1f35876ea12ffeaf5',
|
|
||||||
)
|
// Initialize adapter
|
||||||
await notesAPI.init()
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set active plugin
|
||||||
|
await setActivePlugin(initialConfig.activeAdapter)
|
||||||
|
|
||||||
|
// Get/set config
|
||||||
|
ipcMain.handle('getConfig', async () => {
|
||||||
|
return await config.load()
|
||||||
|
})
|
||||||
|
ipcMain.handle('setConfig', async (_, newConfig) => {
|
||||||
|
await config.write(newConfig)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get/set plugins
|
||||||
|
ipcMain.handle('listPlugins', async () => {
|
||||||
|
return registry.list()
|
||||||
|
})
|
||||||
|
ipcMain.handle('setActivePlugin', async (_, pluginId) => {
|
||||||
|
return await setActivePlugin(pluginId)
|
||||||
|
})
|
||||||
|
|
||||||
// Broadcast note changes to all windows
|
// Broadcast note changes to all windows
|
||||||
const broadcastNoteChange = (event, data) => {
|
const broadcastNoteChange = (event, data) => {
|
||||||
@@ -104,26 +131,9 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Notes API
|
// Handle note change events from renderer
|
||||||
ipcMain.handle('notesAPI:call', async (_, method, args) => {
|
ipcMain.on('note-changed', (_, event, data) => {
|
||||||
if (!notesAPI[method]) {
|
broadcastNoteChange(event, data)
|
||||||
throw new Error('Invalid method')
|
|
||||||
}
|
|
||||||
|
|
||||||
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] })
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
})
|
||||||
|
|
||||||
electronApp.setAppUserModelId('com.electron')
|
electronApp.setAppUserModelId('com.electron')
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { contextBridge, ipcRenderer } from 'electron'
|
|||||||
|
|
||||||
// Custom APIs for renderer
|
// Custom APIs for renderer
|
||||||
const api = {
|
const api = {
|
||||||
|
getConfig: () => ipcRenderer.invoke('getConfig'),
|
||||||
|
setConfig: (config) => ipcRenderer.invoke('setConfig', config),
|
||||||
|
listPlugins: () => ipcRenderer.invoke('listPlugins'),
|
||||||
|
setActivePlugin: (pluginId) =>
|
||||||
|
ipcRenderer.invoke('setActivePlugin', pluginId),
|
||||||
openNoteWindow: (noteId) => {
|
openNoteWindow: (noteId) => {
|
||||||
ipcRenderer.send('open-note-window', noteId)
|
ipcRenderer.send('open-note-window', noteId)
|
||||||
},
|
},
|
||||||
@@ -14,21 +19,24 @@ const api = {
|
|||||||
onNoteDeleted: (callback) => {
|
onNoteDeleted: (callback) => {
|
||||||
ipcRenderer.on('note-deleted', (_, data) => callback(data))
|
ipcRenderer.on('note-deleted', (_, data) => callback(data))
|
||||||
},
|
},
|
||||||
|
notifyNoteChanged: (event, data) => {
|
||||||
|
ipcRenderer.send('note-changed', event, data)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implement notes API
|
// Implement adapter API - communicates with plugin adapter in main process
|
||||||
const notesAPI = {
|
const adapter = {
|
||||||
call: (method, ...args) =>
|
call: (method, ...args) => ipcRenderer.invoke('adapter:call', method, args),
|
||||||
ipcRenderer.invoke('notesAPI:call', method, args),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.contextIsolated) {
|
if (process.contextIsolated) {
|
||||||
try {
|
try {
|
||||||
contextBridge.exposeInMainWorld('api', api)
|
contextBridge.exposeInMainWorld('api', api)
|
||||||
contextBridge.exposeInMainWorld('notesAPI', notesAPI)
|
contextBridge.exposeInMainWorld('adapter', adapter)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.api = api
|
window.api = api
|
||||||
|
window.adapter = adapter
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,18 +16,18 @@ import loadFonts from '@fuzzco/font-loader'
|
|||||||
import { useWindowSize } from '@vueuse/core'
|
import { useWindowSize } from '@vueuse/core'
|
||||||
import Menu from '@/components/Menu.vue'
|
import Menu from '@/components/Menu.vue'
|
||||||
import Nav from '@/components/Nav.vue'
|
import Nav from '@/components/Nav.vue'
|
||||||
import useState from '@/composables/useState'
|
|
||||||
import ScrollBar from '@/components/ScrollBar.vue'
|
import ScrollBar from '@/components/ScrollBar.vue'
|
||||||
|
import useConfig from '@/composables/useConfig'
|
||||||
|
|
||||||
const { height } = useWindowSize()
|
const { height } = useWindowSize()
|
||||||
|
|
||||||
// Theme state
|
// Theme state
|
||||||
const { theme } = useState()
|
const { config } = useConfig()
|
||||||
|
|
||||||
const classes = computed(() => [
|
const classes = computed(() => [
|
||||||
'container',
|
'container',
|
||||||
{ 'fonts-ready': !fontsLoading.value },
|
{ 'fonts-ready': !fontsLoading.value },
|
||||||
`theme-${theme.value}`,
|
`theme-${config.value?.theme || 'dark'}`,
|
||||||
])
|
])
|
||||||
|
|
||||||
const fontsLoading = ref(true)
|
const fontsLoading = ref(true)
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="theme-switcher">
|
<div v-if="config" class="theme-switcher">
|
||||||
<span>Change Theme</span>
|
<span>Change Theme</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-for="(value, key) in themes"
|
v-for="(value, key) in themes"
|
||||||
:class="[`theme-${key}`, { active: theme === key }]"
|
:class="[`theme-${key}`, { active: config.theme === key }]"
|
||||||
@click="setTheme(key)"
|
@click="setTheme(key)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import useConfig from '@/composables/useConfig'
|
||||||
import { themes } from '@/libs/theme'
|
import { themes } from '@/libs/theme'
|
||||||
import useState from '@/composables/useState'
|
|
||||||
|
|
||||||
const { theme } = useState()
|
const { config } = useConfig()
|
||||||
|
|
||||||
const setTheme = (value) => {
|
const setTheme = (value) => {
|
||||||
theme.value = value
|
if (!config.value) return
|
||||||
|
config.value.theme = value
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -29,36 +29,30 @@ const props = defineProps({
|
|||||||
|
|
||||||
const editor = shallowRef()
|
const editor = shallowRef()
|
||||||
|
|
||||||
const { loadNote, updateNoteContent, updateNoteMetadata } = useNotes()
|
const { loadNote, updateNote } = useNotes()
|
||||||
|
|
||||||
const updateNote = _debounce(async ({ editor }) => {
|
const onUpdate = _debounce(async ({ editor }) => {
|
||||||
const json = editor.getJSON()
|
const json = editor.getJSON()
|
||||||
const text = editor.getText()
|
const text = editor.getText()
|
||||||
|
|
||||||
await updateNoteContent(props.id, json, text)
|
// Get doc title
|
||||||
|
let title
|
||||||
updateTitle(editor)
|
|
||||||
}, 300)
|
|
||||||
|
|
||||||
// Track title updates for file
|
|
||||||
let lastTitle
|
|
||||||
const updateTitle = _debounce(async (editor) => {
|
|
||||||
const doc = editor.state.doc
|
const doc = editor.state.doc
|
||||||
|
|
||||||
const firstNode = doc.firstChild
|
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
|
await updateNote(props.id, {
|
||||||
lastTitle = newTitle
|
title,
|
||||||
|
content: json,
|
||||||
await updateNoteMetadata(props.id, { title: newTitle })
|
plainText: text,
|
||||||
|
})
|
||||||
}, 300)
|
}, 300)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const note = await loadNote(props.id)
|
const note = await loadNote(props.id)
|
||||||
lastTitle = note.title
|
|
||||||
|
|
||||||
// Lowlight setup
|
// Lowlight setup
|
||||||
const lowlight = createLowlight(all)
|
const lowlight = createLowlight(all)
|
||||||
@@ -94,7 +88,7 @@ onMounted(async () => {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
content: note.content || [],
|
content: note.content || [],
|
||||||
onUpdate: updateNote,
|
onUpdate: onUpdate,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
36
src/renderer/src/composables/useConfig.js
Normal file
36
src/renderer/src/composables/useConfig.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
ensureConfig,
|
||||||
|
}
|
||||||
|
}
|
||||||
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,103 +1,121 @@
|
|||||||
|
import _omit from 'lodash/omit'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { getNotesAPI } from '@/libs/core/getNotesAPI'
|
||||||
|
|
||||||
const categories = ref([])
|
const categories = ref([])
|
||||||
const searchResults = ref([])
|
const searchResults = ref([])
|
||||||
const notesChangeCount = ref(0)
|
const notesChangeCount = ref(0)
|
||||||
|
|
||||||
let initialized = false
|
let listenersInitialized = false
|
||||||
|
|
||||||
function setupListeners() {
|
const setupListeners = () => {
|
||||||
if (initialized || typeof window === 'undefined') return
|
if (listenersInitialized || typeof window === 'undefined') return
|
||||||
initialized = true
|
listenersInitialized = true
|
||||||
|
|
||||||
|
const updateCacheCount = async (note) => {
|
||||||
|
const api = await getNotesAPI()
|
||||||
|
await api.updateNote(
|
||||||
|
note.id,
|
||||||
|
_omit(note, ['id', 'createdAt', 'updatedAt']),
|
||||||
|
)
|
||||||
|
|
||||||
window.api.onNoteCreated(() => {
|
|
||||||
notesChangeCount.value++
|
notesChangeCount.value++
|
||||||
})
|
}
|
||||||
|
|
||||||
window.api.onNoteUpdated(() => {
|
window.api.onNoteCreated(updateCacheCount)
|
||||||
notesChangeCount.value++
|
window.api.onNoteUpdated(updateCacheCount)
|
||||||
})
|
|
||||||
|
|
||||||
|
// Todo update cache
|
||||||
window.api.onNoteDeleted(() => {
|
window.api.onNoteDeleted(() => {
|
||||||
notesChangeCount.value++
|
notesChangeCount.value++
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default () => {
|
const broadcastChange = (event, data) => {
|
||||||
setupListeners()
|
window.api.notifyNoteChanged(event, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
setupListeners()
|
||||||
|
|
||||||
|
export default () => {
|
||||||
/* -------------------------
|
/* -------------------------
|
||||||
Initialization
|
Initialization
|
||||||
--------------------------*/
|
--------------------------*/
|
||||||
async function loadCategories() {
|
const loadCategories = async () => {
|
||||||
categories.value = await window.notesAPI.call('getCategories')
|
const api = await getNotesAPI()
|
||||||
|
categories.value = api.getCategories()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCategoryNotes(category = null) {
|
const loadCategoryNotes = async (category = null) => {
|
||||||
return await window.notesAPI.call('getCategoryNotes', category)
|
const api = await getNotesAPI()
|
||||||
|
return api.getCategoryNotes(category)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadNote(id) {
|
const loadNote = async (id) => {
|
||||||
return await window.notesAPI.call('getNote', id)
|
const api = await getNotesAPI()
|
||||||
|
return api.getNote(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------
|
/* -------------------------
|
||||||
Create
|
Create
|
||||||
--------------------------*/
|
--------------------------*/
|
||||||
async function createNote(metadata, content, plainText = '') {
|
const createNote = async (metadata, content, plainText = '') => {
|
||||||
const note = await window.notesAPI.call(
|
const api = await getNotesAPI()
|
||||||
'createNote',
|
const note = await api.createNote(metadata, content, plainText)
|
||||||
metadata,
|
|
||||||
content,
|
|
||||||
plainText,
|
|
||||||
)
|
|
||||||
await loadCategories()
|
await loadCategories()
|
||||||
|
broadcastChange('note-created', note)
|
||||||
return note
|
return note
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------
|
/* -------------------------
|
||||||
Update
|
Update
|
||||||
--------------------------*/
|
--------------------------*/
|
||||||
async function updateNoteContent(id, content, plainText = '') {
|
const updateNote = async (id, updates) => {
|
||||||
const note = await window.notesAPI.call(
|
const api = await getNotesAPI()
|
||||||
'updateNote',
|
|
||||||
id,
|
|
||||||
content,
|
|
||||||
plainText,
|
|
||||||
)
|
|
||||||
return note
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateNoteMetadata(id, updates) {
|
const note = await api.updateNote(id, updates)
|
||||||
const note = await window.notesAPI.call(
|
|
||||||
'updateNoteMetadata',
|
if (updates.category !== undefined || updates.title !== undefined) {
|
||||||
id,
|
|
||||||
updates,
|
|
||||||
)
|
|
||||||
await loadCategories()
|
await loadCategories()
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastChange('note-updated', note)
|
||||||
|
|
||||||
return note
|
return note
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateCategory(category, update) {
|
const updateCategory = async (category, update) => {
|
||||||
const notes = await loadCategoryNotes(category)
|
const notes = await loadCategoryNotes(category)
|
||||||
|
|
||||||
notes.forEach(async (note) => {
|
for (const note of notes) {
|
||||||
await updateNoteMetadata(note.id, { category: update })
|
await updateNote(note.id, { category: update })
|
||||||
})
|
}
|
||||||
|
|
||||||
await loadCategories()
|
await loadCategories()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------------
|
||||||
|
Delete
|
||||||
|
--------------------------*/
|
||||||
|
const deleteNote = async (id) => {
|
||||||
|
const api = await getNotesAPI()
|
||||||
|
await api.deleteNote(id)
|
||||||
|
await loadCategories()
|
||||||
|
broadcastChange('note-deleted', { id })
|
||||||
|
}
|
||||||
|
|
||||||
/* -------------------------
|
/* -------------------------
|
||||||
Search
|
Search
|
||||||
--------------------------*/
|
--------------------------*/
|
||||||
async function search(query) {
|
const search = async (query) => {
|
||||||
|
const api = await getNotesAPI()
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
searchResults.value = []
|
searchResults.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
searchResults.value = await window.notesAPI.call('search', query)
|
searchResults.value = api.search(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -110,9 +128,9 @@ export default () => {
|
|||||||
loadNote,
|
loadNote,
|
||||||
|
|
||||||
createNote,
|
createNote,
|
||||||
updateNoteContent,
|
updateNote,
|
||||||
updateNoteMetadata,
|
|
||||||
updateCategory,
|
updateCategory,
|
||||||
|
deleteNote,
|
||||||
|
|
||||||
search,
|
search,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useEnvironment, ENVIRONMENTS } from './useEnvironment'
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -6,13 +7,9 @@ export default () => {
|
|||||||
function openNote(noteId, options = {}) {
|
function openNote(noteId, options = {}) {
|
||||||
const { newWindow = true } = options
|
const { newWindow = true } = options
|
||||||
|
|
||||||
// Electron environment check
|
const environment = useEnvironment()
|
||||||
const isElectron =
|
|
||||||
typeof window !== 'undefined' &&
|
|
||||||
window.api &&
|
|
||||||
typeof window.api.openNoteWindow === 'function'
|
|
||||||
|
|
||||||
if (newWindow && isElectron) {
|
if (newWindow && environment === ENVIRONMENTS.ELECTRON) {
|
||||||
window.api.openNoteWindow(noteId)
|
window.api.openNoteWindow(noteId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/renderer/src/composables/usePlugins.js
Normal file
18
src/renderer/src/composables/usePlugins.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const plugins = ref([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
plugins.value = await window.api.listPlugins()
|
||||||
|
})
|
||||||
|
|
||||||
|
const setActivePlugin = async (pluginId) => {
|
||||||
|
await window.api.setActivePlugin(pluginId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins,
|
||||||
|
setActivePlugin,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { createGlobalState, useStorage } from '@vueuse/core'
|
|
||||||
|
|
||||||
export default createGlobalState(() => {
|
|
||||||
const theme = useStorage('app-theme', 'dark')
|
|
||||||
|
|
||||||
return { theme }
|
|
||||||
})
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Instructions
|
|
||||||
|
|
||||||
This will be the instructions for the application.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
1. Install the application by running `npm install` in the project directory.
|
|
||||||
2. Start the application by running `npm start` in the project directory.
|
|
||||||
3. Open the application in your browser at `http://localhost:3000`.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
1. Create a new note by clicking the "New Note" button.
|
|
||||||
2. Edit an existing note by clicking on it.
|
|
||||||
3. Delete a note by clicking the "Delete" button.
|
|
||||||
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 sodium from 'libsodium-wrappers'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import { Index } from 'flexsearch'
|
||||||
|
import * as uint from '@/libs/uint'
|
||||||
|
|
||||||
export default class NotesAPI {
|
export default class NotesAPI {
|
||||||
constructor(adapter, encryptionKey = null) {
|
constructor(adapter, encryptionKey = null) {
|
||||||
@@ -10,7 +11,7 @@ export default class NotesAPI {
|
|||||||
|
|
||||||
this.adapter = adapter
|
this.adapter = adapter
|
||||||
this.notesCache = new Map()
|
this.notesCache = new Map()
|
||||||
this.encryptionKey = encryptionKey || process.env.NOTES_ENCRYPTION_KEY
|
this.encryptionKey = encryptionKey
|
||||||
this._sodiumReady = false
|
this._sodiumReady = false
|
||||||
|
|
||||||
this.index = new Index({
|
this.index = new Index({
|
||||||
@@ -30,7 +31,7 @@ export default class NotesAPI {
|
|||||||
throw new Error('Encryption key not set')
|
throw new Error('Encryption key not set')
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = Buffer.from(this.encryptionKey, 'hex')
|
const key = uint.hexToUint8Array(this.encryptionKey)
|
||||||
if (key.length !== 32) {
|
if (key.length !== 32) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Encryption key must be 64 hex characters (32 bytes)',
|
'Encryption key must be 64 hex characters (32 bytes)',
|
||||||
@@ -41,13 +42,13 @@ export default class NotesAPI {
|
|||||||
const message = JSON.stringify(note)
|
const message = JSON.stringify(note)
|
||||||
|
|
||||||
const ciphertext = sodium.crypto_secretbox_easy(
|
const ciphertext = sodium.crypto_secretbox_easy(
|
||||||
Buffer.from(message),
|
new TextEncoder().encode(message),
|
||||||
nonce,
|
nonce,
|
||||||
key,
|
key,
|
||||||
)
|
)
|
||||||
|
|
||||||
const combined = Buffer.concat([nonce, ciphertext])
|
const combined = uint.concatUint8Arrays(nonce, ciphertext)
|
||||||
return combined.toString('base64')
|
return uint.uint8ArrayToBase64(combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
_decrypt(encryptedData) {
|
_decrypt(encryptedData) {
|
||||||
@@ -55,7 +56,7 @@ export default class NotesAPI {
|
|||||||
throw new Error('Encryption key not set')
|
throw new Error('Encryption key not set')
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = Buffer.from(this.encryptionKey, 'hex')
|
const key = uint.hexToUint8Array(this.encryptionKey)
|
||||||
if (key.length !== 32) {
|
if (key.length !== 32) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Encryption key must be 64 hex characters (32 bytes)',
|
'Encryption key must be 64 hex characters (32 bytes)',
|
||||||
@@ -64,7 +65,7 @@ export default class NotesAPI {
|
|||||||
|
|
||||||
let combined
|
let combined
|
||||||
try {
|
try {
|
||||||
combined = Buffer.from(encryptedData, 'base64')
|
combined = uint.base64ToUint8Array(encryptedData)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error('Invalid encrypted data: not valid base64')
|
throw new Error('Invalid encrypted data: not valid base64')
|
||||||
}
|
}
|
||||||
@@ -95,7 +96,7 @@ export default class NotesAPI {
|
|||||||
throw new Error('Decryption failed: no data returned')
|
throw new Error('Decryption failed: no data returned')
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptedStr = Buffer.from(decrypted).toString('utf8')
|
const decryptedStr = new TextDecoder().decode(decrypted)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(decryptedStr)
|
return JSON.parse(decryptedStr)
|
||||||
@@ -154,18 +155,20 @@ export default class NotesAPI {
|
|||||||
return Array.from(categories).sort()
|
return Array.from(categories).sort()
|
||||||
}
|
}
|
||||||
|
|
||||||
getCategoryNotes(categoryName) {
|
getCategoryNotes(categoryName = null) {
|
||||||
return Array.from(this.notesCache.values())
|
return Array.from(this.notesCache.values())
|
||||||
.filter((n) => n.category === categoryName)
|
.filter((n) => n.category === categoryName)
|
||||||
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
|
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
|
||||||
|
.map((n) => ({ ...n }))
|
||||||
}
|
}
|
||||||
|
|
||||||
getNote(id) {
|
getNote(id) {
|
||||||
return this.notesCache.get(id) ?? null
|
const note = this.notesCache.get(id)
|
||||||
|
return note ? { ...note } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
async createNote(metadata = {}, content = '', plainText = '') {
|
async createNote(metadata = {}, content = '', plainText = '') {
|
||||||
const id = crypto.randomUUID()
|
const id = uuidv4()
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
const note = {
|
const note = {
|
||||||
@@ -198,62 +201,39 @@ export default class NotesAPI {
|
|||||||
this.index.remove(id)
|
this.index.remove(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateNote(id, content, plainText = '') {
|
async updateNote(id, updates = {}) {
|
||||||
const note = this.notesCache.get(id)
|
const note = this.notesCache.get(id)
|
||||||
if (!note) throw new Error('Note not found')
|
if (!note) throw new Error('Note not found')
|
||||||
|
|
||||||
note.content = content
|
const allowedFields = ['title', 'category', 'content', 'plainText']
|
||||||
note.plainText = plainText
|
|
||||||
note.updatedAt = new Date().toISOString()
|
|
||||||
|
|
||||||
const encryptedNote = {
|
|
||||||
id: note.id,
|
|
||||||
data: this._encrypt(note),
|
|
||||||
}
|
|
||||||
|
|
||||||
this.index.update(id, note.title + '\n' + plainText)
|
|
||||||
|
|
||||||
await this.adapter.update(encryptedNote)
|
|
||||||
|
|
||||||
return note
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateNoteMetadata(id, updates = {}) {
|
|
||||||
const note = this.notesCache.get(id)
|
|
||||||
if (!note) throw new Error('Note not found')
|
|
||||||
|
|
||||||
const allowedFields = ['title', 'category']
|
|
||||||
for (const key of Object.keys(updates)) {
|
for (const key of Object.keys(updates)) {
|
||||||
if (!allowedFields.includes(key)) {
|
if (!allowedFields.includes(key)) {
|
||||||
throw new Error(`Invalid metadata field: ${key}`)
|
throw new Error(`Invalid update field: ${key}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updates.title !== undefined) {
|
const updatedNote = {
|
||||||
note.title = updates.title
|
...note,
|
||||||
|
...updates,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updates.category !== undefined) {
|
|
||||||
note.category = updates.category
|
|
||||||
}
|
|
||||||
|
|
||||||
note.updatedAt = new Date().toISOString()
|
|
||||||
|
|
||||||
const encryptedNote = {
|
const encryptedNote = {
|
||||||
id: note.id,
|
id: updatedNote.id,
|
||||||
data: this._encrypt(note),
|
data: this._encrypt(updatedNote),
|
||||||
}
|
}
|
||||||
|
|
||||||
this.index.update(
|
this.notesCache.set(id, updatedNote)
|
||||||
id,
|
|
||||||
note.title +
|
const searchText =
|
||||||
'\n' +
|
updatedNote.plainText || this._extractPlainText(updatedNote.content)
|
||||||
(note.plainText || this._extractPlainText(note.content)),
|
|
||||||
)
|
this.index.update(id, updatedNote.title + '\n' + searchText)
|
||||||
|
|
||||||
await this.adapter.update(encryptedNote)
|
await this.adapter.update(encryptedNote)
|
||||||
|
|
||||||
return note
|
return updatedNote
|
||||||
}
|
}
|
||||||
|
|
||||||
search(query) {
|
search(query) {
|
||||||
48
src/renderer/src/libs/core/getNotesAPI.js
Normal file
48
src/renderer/src/libs/core/getNotesAPI.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
@@ -12,11 +12,24 @@
|
|||||||
<div class="notes">
|
<div class="notes">
|
||||||
<note-row v-for="note in notes" :note="note" :key="note.id" />
|
<note-row v-for="note in notes" :note="note" :key="note.id" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-for="plugin in plugins" :key="plugin.id">
|
||||||
|
<input
|
||||||
|
v-model="activePlugin"
|
||||||
|
type="radio"
|
||||||
|
name="plugins"
|
||||||
|
:value="plugin.id"
|
||||||
|
:id="plugin.id"
|
||||||
|
/>
|
||||||
|
<label :for="plugin.id">{{ plugin.name }}</label>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import useNotes from '@/composables/useNotes'
|
import useNotes from '@/composables/useNotes'
|
||||||
|
import usePlugins from '@/composables/usePlugins'
|
||||||
|
import useConfig from '@/composables/useConfig'
|
||||||
import { onMounted, ref, watch } from 'vue'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
import CategoryRow from '@/components/CategoryRow.vue'
|
import CategoryRow from '@/components/CategoryRow.vue'
|
||||||
import NoteRow from '@/components/NoteRow.vue'
|
import NoteRow from '@/components/NoteRow.vue'
|
||||||
@@ -24,6 +37,11 @@ import NoteRow from '@/components/NoteRow.vue'
|
|||||||
const { categories, loadCategories, loadCategoryNotes, notesChangeCount } =
|
const { categories, loadCategories, loadCategoryNotes, notesChangeCount } =
|
||||||
useNotes()
|
useNotes()
|
||||||
|
|
||||||
|
const { config } = useConfig()
|
||||||
|
const { plugins, setActivePlugin } = usePlugins()
|
||||||
|
|
||||||
|
const activePlugin = ref(config.value?.activeAdapter)
|
||||||
|
|
||||||
const notes = ref()
|
const notes = ref()
|
||||||
|
|
||||||
async function refreshNotes() {
|
async function refreshNotes() {
|
||||||
@@ -38,6 +56,10 @@ onMounted(async () => {
|
|||||||
watch(notesChangeCount, async () => {
|
watch(notesChangeCount, async () => {
|
||||||
await refreshNotes()
|
await refreshNotes()
|
||||||
})
|
})
|
||||||
|
watch(activePlugin, async (pluginId) => {
|
||||||
|
await setActivePlugin(pluginId)
|
||||||
|
await refreshNotes()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@@ -45,6 +67,18 @@ main.directory {
|
|||||||
padding-top: 18px;
|
padding-top: 18px;
|
||||||
padding-bottom: 30px;
|
padding-bottom: 30px;
|
||||||
|
|
||||||
|
input[type='radio'] {
|
||||||
|
display: block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
margin-right: 10px;
|
||||||
|
border: 1px solid white;
|
||||||
|
|
||||||
|
&:checked {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin: 17px 0 24px;
|
margin: 17px 0 24px;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
|
@input="onInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import useNotes from '@/composables/useNotes'
|
import useNotes from '@/composables/useNotes'
|
||||||
import NoteRow from '@/components/NoteRow.vue'
|
import NoteRow from '@/components/NoteRow.vue'
|
||||||
|
import _debounce from 'lodash/debounce'
|
||||||
|
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const searchInput = ref()
|
const searchInput = ref()
|
||||||
@@ -41,6 +43,9 @@ onMounted(async () => {
|
|||||||
const onSearch = async () => {
|
const onSearch = async () => {
|
||||||
await search(query.value)
|
await search(query.value)
|
||||||
}
|
}
|
||||||
|
const onInput = _debounce(async () => {
|
||||||
|
await search(query.value)
|
||||||
|
}, 300)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|||||||
Reference in New Issue
Block a user