15 Commits

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

1
.gitignore vendored
View File

@@ -14,6 +14,7 @@ dist
dist-ssr
coverage
*.local
out
# Editor directories and files
.vscode/*

View File

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

View File

@@ -5,9 +5,6 @@ import filesystemPlugin from "@takerofnotes/plugin-filesystem";
import supabasePlugin from "@takerofnotes/plugin-supabase";
import 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,167 +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)");
}
const combined = Buffer.from(encryptedData, "base64");
const nonce = combined.slice(0, sodium.crypto_secretbox_NONCEBYTES);
const ciphertext = combined.slice(sodium.crypto_secretbox_NONCEBYTES);
const decrypted = sodium.crypto_secretbox_open_easy(
ciphertext,
nonce,
key
);
return JSON.parse(decrypted.toString());
}
async init() {
await this._initSodium();
await this.adapter.init();
const encryptedNotes = await this.adapter.getAll();
for (const encryptedNote of encryptedNotes) {
try {
const note = this._decrypt(encryptedNote.data || encryptedNote);
this.notesCache.set(note.id, note);
this.index.add(note.id, note.title + "\n" + note.content);
} catch (error) {
console.error("Failed to decrypt note:", error);
}
}
}
/* -----------------------
Public API
------------------------*/
getCategories() {
const categories = /* @__PURE__ */ new Set();
for (const note of this.notesCache.values()) {
if (note.category) {
categories.add(note.category);
}
}
return Array.from(categories).sort();
}
getCategoryNotes(categoryName) {
return Array.from(this.notesCache.values()).filter((n) => n.category === categoryName).sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
}
getNote(id) {
return this.notesCache.get(id) ?? null;
}
async createNote(metadata = {}, content = "") {
const id = crypto.randomUUID();
const now = (/* @__PURE__ */ new Date()).toISOString();
const note = {
id,
title: metadata.title || "Untitled",
category: metadata.category || null,
createdAt: now,
updatedAt: now,
content
};
const encryptedNote = {
id: note.id,
data: this._encrypt(note)
};
this.notesCache.set(id, note);
this.index.add(id, note.title + "\n" + content);
await this.adapter.create(encryptedNote);
return note;
}
async deleteNote(id) {
await this.adapter.delete(id);
this.notesCache.delete(id);
this.index.remove(id);
}
async updateNote(id, content) {
const note = this.notesCache.get(id);
if (!note) throw new Error("Note not found");
note.content = content;
note.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
const encryptedNote = {
id: note.id,
data: this._encrypt(note)
};
this.index.update(id, note.title + "\n" + content);
await this.adapter.update(encryptedNote);
return note;
}
async updateNoteMetadata(id, updates = {}) {
const note = this.notesCache.get(id);
if (!note) throw new Error("Note not found");
const allowedFields = ["title", "category"];
for (const key of Object.keys(updates)) {
if (!allowedFields.includes(key)) {
throw new Error(`Invalid metadata field: ${key}`);
}
}
if (updates.title !== void 0) {
note.title = updates.title;
}
if (updates.category !== void 0) {
note.category = updates.category;
}
note.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
const encryptedNote = {
id: note.id,
data: this._encrypt(note)
};
this.index.update(id, note.title + "\n" + note.content);
await this.adapter.update(encryptedNote);
return note;
}
search(query) {
const ids = this.index.search(query);
return ids.map((id) => this.notesCache.get(id));
}
}
const 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 mainWindow2 = new BrowserWindow({
width: 354,
height: 549,
const mainWindow = new BrowserWindow({
width: DEFAULT_WINDOW_SIZE.width,
height: DEFAULT_WINDOW_SIZE.height,
show: false,
autoHideMenuBar: true,
webPreferences: {
@@ -256,23 +108,23 @@ function createWindow() {
sandbox: false
}
});
mainWindow2.on("ready-to-show", () => {
mainWindow2.show();
mainWindow.on("ready-to-show", () => {
mainWindow.show();
});
mainWindow2.webContents.setWindowOpenHandler((details) => {
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url);
return { action: "deny" };
});
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
mainWindow2.loadURL(process.env["ELECTRON_RENDERER_URL"]);
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
} else {
mainWindow2.loadFile(rendererPath);
mainWindow.loadFile(rendererPath);
}
}
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,
@@ -283,11 +135,11 @@ function createNoteWindow(noteId) {
});
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
noteWindow.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}/note/${noteId}`
`${process.env["ELECTRON_RENDERER_URL"]}/#/note/${noteId}`
);
} else {
mainWindow.loadFile(rendererPath, {
path: `/notes/${noteId}`
noteWindow.loadFile(rendererPath, {
hash: `/note/${noteId}`
});
}
}
@@ -295,22 +147,68 @@ app.whenReady().then(async () => {
ipcMain.on("open-note-window", (_, noteId) => {
createNoteWindow(noteId);
});
const broadcastNoteChange = (event, data) => {
BrowserWindow.getAllWindows().forEach((win) => {
win.webContents.send(event, data);
});
};
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();
ipcMain.handle("notesAPI:call", (_, method, args) => {
if (!notesAPI[method]) {
throw new Error("Invalid method");
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}`);
}
return await adapter[method](...args);
});
broadcastNoteChange("plugin-changed", pluginId);
return true;
};
await setActivePlugin(initialConfig.activeAdapter);
ipcMain.handle("getConfig", async () => {
return await config.load();
});
ipcMain.handle("setConfig", async (_, newConfig) => {
await config.write(newConfig);
});
ipcMain.handle("listPlugins", async () => {
return registry.list();
});
ipcMain.handle("setActivePlugin", async (_, pluginId) => {
return await setActivePlugin(pluginId);
});
ipcMain.on("note-changed", (_, event, data) => {
broadcastNoteChange(event, data);
});
ipcMain.handle("move-opened", (_) => {
const activeWindow = BrowserWindow.getFocusedWindow();
const windowSize = activeWindow.getSize();
if (windowSize[0] < DEFAULT_MOVE_WINDOW_SIZE.width) {
activeWindow.setSize(
DEFAULT_MOVE_WINDOW_SIZE.width,
DEFAULT_MOVE_WINDOW_SIZE.height
);
}
});
ipcMain.handle("move-closed", (_) => {
const activeWindow = BrowserWindow.getFocusedWindow();
const windowSize = activeWindow.getSize();
if (windowSize[0] === 708) {
activeWindow.setSize(
DEFAULT_WINDOW_SIZE.width,
DEFAULT_WINDOW_SIZE.height
);
}
return notesAPI[method](...args);
});
electronApp.setAppUserModelId("com.electron");
app.on("browser-window-created", (_, window) => {

View File

@@ -1,19 +1,45 @@
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);
},
onNoteCreated: (callback) => {
ipcRenderer.on("note-created", (_, data) => callback(data));
},
onNoteUpdated: (callback) => {
ipcRenderer.on("note-updated", (_, data) => callback(data));
},
onNoteDeleted: (callback) => {
ipcRenderer.on("note-deleted", (_, data) => callback(data));
},
onPluginChanged: (callback) => {
ipcRenderer.on("plugin-changed", (_, data) => callback(data));
},
notifyNoteChanged: (event, data) => {
ipcRenderer.send("note-changed", event, data);
},
moveOpened: () => {
ipcRenderer.invoke("move-opened");
},
moveClosed: () => {
ipcRenderer.invoke("move-closed");
}
};
const notesAPI = {
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;
}

View File

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

View File

@@ -1,897 +0,0 @@
@charset "UTF-8";
/* Breakpoints */
@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;
}
}
:root {
--black: #181818;
--white: #D5D5D5;
--grey-100: #747474;
--green: #87FF5B;
--blue: #5B92FF;
}
:root .theme-light {
--theme-bg: #D5D5D5;
--theme-fg: #181818;
--theme-accent: #87FF5B;
--theme-link: #5B92FF;
}
:root .theme-dark {
--theme-bg: #181818;
--theme-fg: #D5D5D5;
--theme-accent: #87FF5B;
--theme-link: #5B92FF;
}
:root {
--ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53);
--ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19);
--ease-in-quart: cubic-bezier(0.895, 0.03, 0.685, 0.22);
--ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06);
--ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035);
--ease-in-circ: cubic-bezier(0.6, 0.04, 0.98, 0.335);
--ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
--ease-out-circ: cubic-bezier(0.075, 0.82, 0.165, 1);
--ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);
--ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1);
--ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1);
--ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1);
--ease-in-out-expo: cubic-bezier(1, 0, 0, 1);
--ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86);
--ease-custom: cubic-bezier(0.315, 0.365, 0.23, 0.985);
}
/***
The new CSS reset - version 1.9 (last updated 19.6.2023)
GitHub page: https://github.com/elad2412/the-new-css-reset
***/
/*
Remove all the styles of the "User-Agent-Stylesheet", except for the 'display' property
- The "symbol *" part is to solve Firefox SVG sprite bug
- The "html" element is excluded, otherwise a bug in Chrome breaks the CSS hyphens property (https://github.com/elad2412/the-new-css-reset/issues/36)
*/
*:where(:not(html, iframe, canvas, img, svg, video, audio):not(svg *, symbol *)) {
all: unset;
display: revert;
}
/* Preferred box-sizing value */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Reapply the pointer cursor for anchor tags */
a,
button {
cursor: revert;
}
/* For images to not be able to exceed their container */
img {
max-inline-size: 100%;
max-block-size: 100%;
}
/* removes spacing between cells in tables */
table {
border-collapse: collapse;
}
/* Safari - solving issue when using user-select:none on the <body> text input doesn't working */
input,
textarea {
-webkit-user-select: auto;
}
/* revert the 'white-space' property for textarea elements on Safari */
textarea {
white-space: revert;
}
/* minimum style to allow to style meter element */
meter {
-webkit-appearance: revert;
appearance: revert;
}
/* preformatted text - use only for this feature */
:where(pre) {
all: revert;
}
/* reset default text opacity of input placeholder */
::placeholder {
color: unset;
}
/* remove default dot (•) sign */
::marker {
content: initial;
}
/* fix the feature of 'hidden' attribute.
display:revert; revert to element instead of attribute */
:where([hidden]) {
display: none;
}
/* revert for bug in Chromium browsers
- fix for the content editable attribute will work properly.
- webkit-user-select: auto; added for Safari in case of using user-select:none on wrapper element*/
:where([contenteditable]:not([contenteditable=false])) {
-moz-user-modify: read-write;
-webkit-user-modify: read-write;
overflow-wrap: break-word;
-webkit-line-break: after-white-space;
-webkit-user-select: auto;
}
/* apply back the draggable feature - exist only in Chromium and Safari */
:where([draggable=true]) {
-webkit-user-drag: element;
}
/* Revert Modal native behavior */
:where(dialog:modal) {
all: revert;
}
.lily-cursor {
z-index: 20;
}
.site-header {
z-index: 10;
}
.full-width {
width: 100vw;
position: relative;
left: 50%;
right: 50%;
margin-left: -50vw;
margin-right: -50vw;
}
.overflow-hidden {
overflow: hidden;
}
.relative {
position: relative;
}
@media (min-width: 800px) {
.mobile-only {
display: none !important;
}
}
@media (max-width: 799px) {
.desktop-only {
display: none !important;
}
}
html:not(.has-scroll-smooth) .hide-on-native-scroll {
display: none;
}
html.has-scroll-smooth .hide-on-smooth-scroll {
display: none;
}
/*
Font Weights:
100 - Thin
200 - Extra Light (Ultra Light)
300 - Light
400 - Normal
500 - Medium
600 - Semi Bold (Demi Bold)
700 - Bold
800 - Extra Bold (Ultra Bold)
900 - Black (Heavy)
*/
/* Leibniz Fraktur */
@font-face {
font-family: "Leibniz Fraktur";
font-style: normal;
font-weight: 400;
src: url("./leibniz-fraktur-BqdMSuEI.woff2") format("woff2"), url("./leibniz-fraktur-CvnkUeBX.woff") format("woff");
}
/* Geist Mono */
@font-face {
font-family: "Geist Mono";
font-style: normal;
font-weight: 700;
src: url("./geist-mono-bold-CTLtpKvJ.woff2") format("woff2"), url("./geist-mono-bold-Bz_UliG4.woff") format("woff");
}
@font-face {
font-family: "Geist Mono";
font-style: normal;
font-weight: 400;
src: url("./geist-mono-BzrJhchg.woff2") format("woff2"), url("./geist-mono-OFKGen7b.woff") format("woff");
}
:root {
--font-display: 'Leibniz Fraktur', serif;
--font-mono: 'Geist Mono', monospace;
}
:root {
--layout-column-count: 5;
--layout-column-gap: 5.6497175141vw;
--layout-margin: 8.4745762712vw;
--layout-width: calc(100vw - (2 * var(--layout-margin)));
--layout-column-width: calc(
(
var(--layout-width) -
(
(var(--layout-column-count) - 1) *
var(--layout-column-gap)
)
) /
var(--layout-column-count)
);
}
.layout-block, .layout-grid {
max-width: var(--layout-width);
margin-left: auto;
margin-right: auto;
width: 100%;
}
.layout-block-inner, .layout-grid-inner {
padding-left: var(--layout-margin);
padding-right: var(--layout-margin);
width: 100%;
}
.layout-grid {
display: grid;
grid-template-columns: repeat(var(--layout-column-count), minmax(0, 1fr));
grid-gap: var(--layout-column-gap);
}
.layout-grid-inner {
display: grid;
grid-template-columns: repeat(var(--layout-column-count), minmax(0, 1fr));
grid-gap: var(--layout-column-gap);
}
html:not(.dev),
html:not(.dev) * {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
html:not(.dev)::-webkit-scrollbar,
html:not(.dev) *::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
}
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;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 400ms;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.quick-fade-enter-active,
.quick-fade-leave-active {
transition: opacity 100ms;
}
.quick-fade-enter-from,
.quick-fade-leave-to {
opacity: 0;
}
.slow-fade-enter-active,
.slow-fade-leave-active {
transition: opacity 600ms;
}
.slow-fade-enter-from,
.slow-fade-leave-to {
opacity: 0;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 400ms var(--ease-out-quad);
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(-100%);
}
.slide-left-enter-active,
.slide-left-leave-active {
transition: transform 400ms var(--ease-out-quad);
}
.slide-left-enter-from,
.slide-left-leave-to {
transform: translateX(-100%);
}
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
background: var(--black);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-height: 100vh;
}
.h1,
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-display);
font-weight: 400;
letter-spacing: -0.02em;
line-height: 1.3;
font-size: 8.4745762712vw;
}
.h1.mono,
h1.mono,
h2.mono,
h3.mono,
h4.mono,
h5.mono,
h6.mono {
font-family: var(--font-mono);
font-weight: 400;
line-height: 1;
font-size: 6.2146892655vw;
}
.p,
p,
a,
button,
input,
pre {
font-family: var(--font-mono);
font-weight: 400;
line-height: 1;
font-size: 3.3898305085vw;
}
.bold {
font-weight: 700;
}
#app {
min-height: 100vh;
}
::selection {
color: var(--theme-bg);
background: var(--theme-accent);
}
::-moz-selection {
color: var(--theme-bg);
background: var(--theme-accent);
}/* Breakpoints */
@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;
}
}
.container {
min-height: calc(100 * var(--vh));
max-width: 100vw;
overflow-x: clip;
background: var(--theme-bg);
color: var(--theme-fg);
transition: opacity 1000ms;
}
.container:not(.fonts-ready) {
opacity: 0;
}/* Breakpoints */
@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;
}
}
.category-row {
display: grid;
grid-template-columns: 7.3446327684vw 1fr;
width: 100%;
position: relative;
padding: 1.4124293785vw 0 4.2372881356vw;
cursor: pointer;
}
.category-row .index {
margin-top: 5.3672316384vw;
}
.category-row .title {
display: block;
width: 100%;
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
@media (max-width: 799px) {
.category-row .title {
-webkit-line-clamp: 2;
}
}
.category-row::after {
content: "----------------------------------------";
position: absolute;
bottom: 0;
left: 0;
}
.category-row.router-link-exact-active {
cursor: default;
}
.category-row:hover:not(.router-link-exact-active) {
color: var(--theme-accent);
}/* Breakpoints */
@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;
}
}
.note-row {
grid-template-columns: auto 1fr;
display: grid;
gap: 5.6497175141vw;
cursor: pointer;
}
.note-row .title {
width: 44.9152542373vw;
position: relative;
}
.note-row .title::after {
content: "(open)";
position: absolute;
bottom: 0;
right: 0;
transform: translateX(100%);
font-weight: 700;
opacity: 0;
}
.note-row:hover {
color: var(--theme-accent);
}
.note-row:hover .title::after {
opacity: 1;
}/* Breakpoints */
@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;
}
}
.directory {
padding-top: 5.0847457627vw;
}
.directory .label {
text-transform: uppercase;
margin: 4.802259887vw 0 6.7796610169vw;
font-family: var(--font-mono);
font-weight: 400;
line-height: 1;
font-size: 3.3898305085vw;
}
.directory .notes {
display: flex;
flex-direction: column;
gap: 3.9548022599vw;
}/* Breakpoints */
@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;
}
}
.editor {
padding-top: 2.2598870056vw;
padding-bottom: 5.6497175141vw;
}
.editor h1 {
font-weight: 700 !important;
font-family: var(--font-mono);
font-weight: 400;
line-height: 1;
font-size: 3.3898305085vw;
}
.editor h1:first-child {
font-family: var(--font-mono);
font-size: 3.3898305085vw;
font-weight: 400 !important;
}
.editor h1:first-child:first-child::first-letter {
font-family: var(--font-display);
font-size: 11.8644067797vw;
}
.editor h1.is-editor-empty:first-child::before {
color: var(--grey-100);
content: attr(data-placeholder);
pointer-events: none;
font-family: var(--font-mono);
font-size: 3.3898305085vw;
font-weight: 400 !important;
}
.editor h1.is-editor-empty:first-child::before:first-child::first-letter {
font-family: var(--font-display);
font-size: 11.8644067797vw;
}
.editor p strong {
font-weight: 700;
}
.editor p em {
font-style: italic;
}
.editor hr::before {
content: "----------------------------------------";
font-family: var(--font-mono);
font-weight: 400;
line-height: 1;
font-size: 3.3898305085vw;
}
.editor ul {
list-style-type: disc;
}
.editor ul li {
display: list-item;
margin-left: 1em;
}
.editor ol {
list-style-type: decimal;
}
.editor ol li {
display: list-item;
margin-left: 1.5em;
}
.editor ol li::marker {
font-family: var(--font-mono);
font-weight: 400;
line-height: 1;
font-size: 3.3898305085vw;
}
.editor a {
color: var(--theme-link);
cursor: pointer;
}
.editor .editor-wrap > div {
display: flex;
flex-direction: column;
gap: 5.6497175141vw;
}
.editor .editor-wrap > div:focus {
outline: none;
}
.editor .bubble-menu {
display: flex;
gap: 1.4124293785vw;
border: 1px solid var(--grey-100);
color: var(--grey-100);
border-radius: 0.2em;
background: var(--theme-bg);
}
.editor .bubble-menu button {
cursor: pointer;
padding: 0.2em;
border-radius: 0.2em;
}
.editor .bubble-menu button:hover {
background: var(--grey-100);
color: var(--theme-bg);
}
.editor .bubble-menu button.active {
background: var(--theme-fg);
color: var(--theme-bg);
}/* Breakpoints */
@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;
}
}
.category .back {
display: block;
opacity: 0.25;
margin-top: 2.5423728814vw;
}
.category .category-row {
margin-top: 1.1299435028vw;
}
.category .notes {
display: flex;
flex-direction: column;
gap: 3.9548022599vw;
margin-top: 2.5423728814vw;
}

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@@ -1,18 +1,18 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
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-Dv1qfmwr.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CZWw79gc.css">
</head>
<head>
<meta charset="UTF-8" />
<title>Electron</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
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-D2TWwJ08.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BFwBEQYI.css">
</head>
<body>
<div id="app"></div>
</body>
<body>
<div id="app"></div>
</body>
</html>

192
package-lock.json generated
View File

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

View File

@@ -30,10 +30,10 @@
"@fuzzco/font-loader": "^1.0.2",
"@takerofnotes/plugin-filesystem": "^0.2.0",
"@takerofnotes/plugin-supabase": "^0.1.0",
"@tiptap/extension-code-block-lowlight": "^3.20.0",
"@tiptap/extension-document": "^3.19.0",
"@tiptap/extension-image": "^3.19.0",
"@tiptap/extension-table": "^3.19.0",
"@tiptap/markdown": "^3.19.0",
"@tiptap/extension-highlight": "^3.20.0",
"@tiptap/extension-list": "^3.20.0",
"@tiptap/starter-kit": "^3.19.0",
"@tiptap/vue-3": "^3.19.0",
"@vueuse/core": "^14.2.1",
@@ -42,9 +42,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.

Binary file not shown.

View File

@@ -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(

View File

@@ -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,
}))
}
}

View File

@@ -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,
@@ -56,11 +58,11 @@ function createNoteWindow(noteId) {
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
noteWindow.loadURL(
`${process.env['ELECTRON_RENDERER_URL']}/note/${noteId}`,
`${process.env['ELECTRON_RENDERER_URL']}/#/note/${noteId}`,
)
} else {
mainWindow.loadFile(rendererPath, {
path: `/notes/${noteId}`,
noteWindow.loadFile(rendererPath, {
hash: `/note/${noteId}`,
})
}
}
@@ -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,30 +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)
// Handle Notes API
ipcMain.handle('notesAPI:call', (_, method, args) => {
if (!notesAPI[method]) {
throw new Error('Invalid method')
// 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
}
// Set active plugin
await setActivePlugin(initialConfig.activeAdapter)
// Get/set config
ipcMain.handle('getConfig', async () => {
return await config.load()
})
ipcMain.handle('setConfig', async (_, newConfig) => {
await config.write(newConfig)
})
// Get/set plugins
ipcMain.handle('listPlugins', async () => {
return registry.list()
})
ipcMain.handle('setActivePlugin', async (_, pluginId) => {
return await setActivePlugin(pluginId)
})
// Handle note change events from renderer
ipcMain.on('note-changed', (_, event, data) => {
broadcastNoteChange(event, data)
})
// Handle resizing for note "move" functionality
ipcMain.handle('move-opened', (_) => {
const activeWindow = BrowserWindow.getFocusedWindow()
const windowSize = activeWindow.getSize()
if (windowSize[0] < DEFAULT_MOVE_WINDOW_SIZE.width) {
activeWindow.setSize(
DEFAULT_MOVE_WINDOW_SIZE.width,
DEFAULT_MOVE_WINDOW_SIZE.height,
)
}
})
ipcMain.handle('move-closed', (_) => {
const activeWindow = BrowserWindow.getFocusedWindow()
const windowSize = activeWindow.getSize()
if (windowSize[0] === 708) {
activeWindow.setSize(
DEFAULT_WINDOW_SIZE.width,
DEFAULT_WINDOW_SIZE.height,
)
}
return notesAPI[method](...args)
})
electronApp.setAppUserModelId('com.electron')

View File

@@ -2,24 +2,50 @@ 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)
},
onNoteCreated: (callback) => {
ipcRenderer.on('note-created', (_, data) => callback(data))
},
onNoteUpdated: (callback) => {
ipcRenderer.on('note-updated', (_, data) => callback(data))
},
onNoteDeleted: (callback) => {
ipcRenderer.on('note-deleted', (_, data) => callback(data))
},
onPluginChanged: (callback) => {
ipcRenderer.on('plugin-changed', (_, data) => callback(data))
},
notifyNoteChanged: (event, data) => {
ipcRenderer.send('note-changed', event, data)
},
moveOpened: () => {
ipcRenderer.invoke('move-opened')
},
moveClosed: () => {
ipcRenderer.invoke('move-closed')
},
}
// Implement notes API
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
}

View File

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

View File

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

View File

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

View File

@@ -3,15 +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" />
<button class="menu-item">+ New Capitulum</button>
<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>
@@ -19,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'
@@ -58,12 +64,11 @@ const openNewCategory = () => {}
.menu-wrap {
display: flex;
flex-direction: column;
padding-top: size-vw(3px);
padding-bottom: size-vw(10px);
padding-top: 1.2em;
padding-bottom: 10px;
.menu-item {
display: block;
padding: size-vw(16px) 0;
padding: 16px 0;
text-align: center;
&:not(:last-child) {

View File

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

View File

@@ -1,12 +1,13 @@
<template>
<nav class="nav layout-block-inner">
<nav class="nav layout-block">
<button @click="toggleMenu">Menu</button>
<router-link to="/search">Search</router-link>
</nav>
</template>
<script setup>
import useMenu from '@/composables/useMenu'
import { onMounted } from 'vue'
const { menuOpen, closeMenu, openMenu } = useMenu()
@@ -17,19 +18,15 @@ 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;
align-items: center;
gap: 10px;
padding-top: 9px;
color: var(--grey-100);
}
</style>

View File

@@ -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>
</button>
<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>

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,74 +1,142 @@
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 listenersInitialized = false
const setupListeners = () => {
if (listenersInitialized || typeof window === 'undefined') return
listenersInitialized = true
const updateCacheCount = async (note) => {
const api = await getNotesAPI()
await api.updateNote(
note.id,
_omit(note, ['id', 'createdAt', 'updatedAt']),
)
notesChangeCount.value++
}
window.api.onNoteCreated(updateCacheCount)
window.api.onNoteUpdated(updateCacheCount)
window.api.onPluginChanged(async () => {
const api = await getNotesAPI()
await api.init()
notesChangeCount.value++
})
// Todo update cache
window.api.onNoteDeleted(() => {
notesChangeCount.value++
})
}
const broadcastChange = (event, data) => {
window.api.notifyNoteChanged(event, data)
}
setupListeners()
export default () => {
/* -------------------------
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)
const updateNote = async (id, updates) => {
const api = await getNotesAPI()
const note = await api.updateNote(id, updates)
if (updates.category !== undefined || updates.title !== undefined) {
await loadCategories()
}
broadcastChange('note-updated', note)
return note
}
async function updateNoteMetadata(id, updates) {
const note = await window.notesAPI.call(
'updateNoteMetadata',
id,
updates,
)
const updateCategory = async (category, update) => {
const notes = await loadCategoryNotes(category)
for (const note of notes) {
await updateNote(note.id, { category: update })
}
await loadCategories()
return note
}
/* -------------------------
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 {
categories,
searchResults,
notesChangeCount,
loadCategories,
loadCategoryNotes,
loadNote,
createNote,
updateNoteContent,
updateNoteMetadata,
updateNote,
updateCategory,
deleteNote,
search,
}

View File

@@ -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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { Index } from 'flexsearch'
import crypto from 'crypto'
import sodium from 'libsodium-wrappers'
import { 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,22 +31,24 @@ 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)')
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),
new TextEncoder().encode(message),
nonce,
key
key,
)
const combined = Buffer.concat([nonce, ciphertext])
return combined.toString('base64')
const combined = uint.concatUint8Arrays(nonce, ciphertext)
return uint.uint8ArrayToBase64(combined)
}
_decrypt(encryptedData) {
@@ -54,41 +56,91 @@ 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)')
throw new Error(
'Encryption key must be 64 hex characters (32 bytes)',
)
}
let combined
try {
combined = uint.base64ToUint8Array(encryptedData)
} catch (e) {
throw new Error('Invalid encrypted data: not valid base64')
}
if (
combined.length <
sodium.crypto_secretbox_NONCEBYTES +
sodium.crypto_secretbox_MACBYTES
) {
throw new Error('Invalid encrypted data: too short')
}
const combined = Buffer.from(encryptedData, 'base64')
const nonce = combined.slice(0, sodium.crypto_secretbox_NONCEBYTES)
const ciphertext = combined.slice(sodium.crypto_secretbox_NONCEBYTES)
const decrypted = sodium.crypto_secretbox_open_easy(
ciphertext,
nonce,
key
)
let decrypted
try {
decrypted = sodium.crypto_secretbox_open_easy(
ciphertext,
nonce,
key,
)
} catch (e) {
throw new Error('Decryption failed: wrong key or corrupted data')
}
return JSON.parse(decrypted.toString())
if (!decrypted) {
throw new Error('Decryption failed: no data returned')
}
const decryptedStr = new TextDecoder().decode(decrypted)
try {
return JSON.parse(decryptedStr)
} catch (e) {
throw new Error(
`Decryption succeeded but invalid JSON: ${decryptedStr}`,
)
}
}
async init() {
await this._initSodium()
await this.adapter.init()
this.notesCache.clear()
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)
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
------------------------*/
@@ -104,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 = {
@@ -125,6 +179,7 @@ export default class NotesAPI {
createdAt: now,
updatedAt: now,
content,
plainText,
}
const encryptedNote = {
@@ -133,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)
@@ -147,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))
}
}

View File

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

View File

@@ -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,
}

View File

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

View File

@@ -1,18 +1,24 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHashHistory } from 'vue-router'
import Directory from '@/views/Directory.vue'
import 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({
history: createWebHistory(),
history: createWebHashHistory(),
routes,
})

View File

@@ -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;
}

View File

@@ -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 */

View File

@@ -1,58 +1,3 @@
@use 'sass:math';
/* Breakpoints */
$mobile-breakpoint: get('breakpoints.mobile');
// Viewport Sizes
$desktop-width: get('viewports.desktop.width');
$desktop-height: get('viewports.desktop.height');
$mobile-width: get('viewports.mobile.width');
$mobile-height: get('viewports.mobile.height');
// Breakpoint
@mixin mobile {
@media (max-width: #{$mobile-breakpoint * 1px - 1px}) {
@content;
}
}
@mixin desktop {
@media (min-width: #{$mobile-breakpoint * 1px}) {
@content;
}
}
@function mobile-vw($pixels, $base-vw: $mobile-width) {
$px: math.div($pixels, $base-vw);
$perc: math.div($px, 1px);
@return calc($perc * 100vw);
}
@function mobile-vh($pixels, $base-vh: $mobile-height) {
$px: math.div($pixels, $base-vh);
$perc: math.div($px, 1px);
@return calc($perc * 100vh);
}
@function desktop-vw($pixels, $base-vw: $desktop-width) {
$px: math.div($pixels, $base-vw);
$perc: math.div($px, 1px);
@return calc($perc * 100vw);
}
@function desktop-vh($pixels, $base-vh: $desktop-height) {
$px: math.div($pixels, $base-vh);
$perc: math.div($px, 1px);
@return calc($perc * 100vh);
}
@function size-vw($pixels, $base-vw: 354) {
$px: math.div($pixels, $base-vw);
$perc: math.div($px, 1px);
@return calc($perc * 100vw);
}
@function columns($columns) {
@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;
}
&: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;
}
}

View File

@@ -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);
}

View File

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

View File

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

View File

@@ -17,26 +17,8 @@
position: relative;
}
.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;
}
}

View File

@@ -46,7 +46,10 @@ p,
a,
button,
input,
pre {
pre,
span,
label,
li {
@include p;
}
.bold {
@@ -64,9 +67,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);
}

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
:key="category"
/>
<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" />
@@ -17,33 +17,47 @@
<script setup>
import useNotes from '@/composables/useNotes'
import { onMounted, ref } from 'vue'
import usePlugins from '@/composables/usePlugins'
import useConfig from '@/composables/useConfig'
import { onMounted, ref, watch } from 'vue'
import CategoryRow from '@/components/CategoryRow.vue'
import NoteRow from '@/components/NoteRow.vue'
const { categories, loadCategories, loadCategoryNotes } = useNotes()
const { categories, loadCategories, loadCategoryNotes, notesChangeCount } =
useNotes()
const { config } = useConfig()
const notes = ref()
onMounted(async () => {
async function refreshNotes() {
await loadCategories()
notes.value = await loadCategoryNotes()
}
onMounted(async () => {
await refreshNotes()
})
watch(notesChangeCount, async () => {
await refreshNotes()
})
</script>
<style lang="scss">
main.directory {
padding-top: size-vw(18px);
padding-top: 26px;
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>

View File

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

View File

@@ -1,74 +1,38 @@
<template>
<main class="instructions layout-block-inner">
<main class="instructions layout-block">
<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>
<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 {
padding-top: 1.2em;
.back-link {
opacity: 0.25;
display: block;
margin-top: size-vw(9px);
margin-bottom: size-vw(14px);
margin-top: 9px;
margin-bottom: 14px;
}
.content {
display: flex;
flex-direction: column;
gap: 20px;
hr {
border-bottom: 1px dashed currentColor;
}
}
}
</style>

View File

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

View File

@@ -0,0 +1,198 @@
<template>
<main class="preferences layout-block">
<router-link to="/" class="back"><- Back</router-link>
<h1 class="mono">Storage Plugin</h1>
<div v-for="plugin in plugins" class="plugin" :key="plugin.id">
<input
v-model="selectedPluginId"
name="plugins"
type="radio"
:id="plugin.id"
:value="plugin.id"
/>
<div class="info">
<p class="name bold">{{ plugin.name }}</p>
<p class="description">{{ plugin.description }}</p>
<div v-if="plugin.configSchema.length" class="config">
<div
v-for="field in plugin.configSchema"
class="config-field"
:key="field.key"
>
<label :for="field.key">
{{ field.label }} {{ field.required ? '*' : '' }}
</label>
<input
v-model="config.adapters[plugin.id][field.key]"
:id="field.key"
:type="field.type"
:placeholder="field.default"
:required="field.required"
/>
</div>
</div>
</div>
</div>
<p v-if="validationError" class="error">{{ validationError }}</p>
<button @click="save" class="save-btn">
<svg-spinner v-if="saving" />
<span v-else-if="saved">Saved</span>
<span v-else>Save</span>
</button>
</main>
</template>
<script setup>
import SvgSpinner from '@/components/svg/Spinner.vue'
import usePlugins from '@/composables/usePlugins'
import useConfig from '@/composables/useConfig'
import { ref, computed } from 'vue'
const { plugins, setActivePlugin } = await usePlugins()
const { config, ensureConfig } = useConfig()
await ensureConfig()
const 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: 1.2em;
padding-bottom: 60px;
.back {
opacity: 0.25;
display: block;
margin-top: 9px;
margin-bottom: 14px;
}
h1 {
margin-bottom: 20px;
}
.plugin {
display: flex;
margin-bottom: 16px;
}
input[type='radio'] {
display: block;
flex-shrink: 0;
width: 10px;
height: 10px;
margin-right: 10px;
border: 1px solid 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>

View File

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