new category flow

This commit is contained in:
nicwands
2026-03-03 17:09:29 -05:00
parent e843b7662d
commit e9e0abe380
12 changed files with 463 additions and 109 deletions

View File

@@ -114,7 +114,9 @@ class NotesAPI {
} }
const key = Buffer.from(this.encryptionKey, "hex"); const key = Buffer.from(this.encryptionKey, "hex");
if (key.length !== 32) { if (key.length !== 32) {
throw new Error("Encryption key must be 64 hex characters (32 bytes)"); throw new Error(
"Encryption key must be 64 hex characters (32 bytes)"
);
} }
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const message = JSON.stringify(note); const message = JSON.stringify(note);
@@ -132,17 +134,42 @@ class NotesAPI {
} }
const key = Buffer.from(this.encryptionKey, "hex"); const key = Buffer.from(this.encryptionKey, "hex");
if (key.length !== 32) { if (key.length !== 32) {
throw new Error("Encryption key must be 64 hex characters (32 bytes)"); throw new Error(
"Encryption key must be 64 hex characters (32 bytes)"
);
}
let combined;
try {
combined = Buffer.from(encryptedData, "base64");
} catch (e) {
throw new Error("Invalid encrypted data: not valid base64");
}
if (combined.length < sodium.crypto_secretbox_NONCEBYTES + sodium.crypto_secretbox_MACBYTES) {
throw new Error("Invalid encrypted data: too short");
} }
const combined = Buffer.from(encryptedData, "base64");
const nonce = combined.slice(0, sodium.crypto_secretbox_NONCEBYTES); const nonce = combined.slice(0, sodium.crypto_secretbox_NONCEBYTES);
const ciphertext = combined.slice(sodium.crypto_secretbox_NONCEBYTES); const ciphertext = combined.slice(sodium.crypto_secretbox_NONCEBYTES);
const decrypted = sodium.crypto_secretbox_open_easy( let decrypted;
ciphertext, try {
nonce, decrypted = sodium.crypto_secretbox_open_easy(
key ciphertext,
); nonce,
return JSON.parse(decrypted.toString()); key
);
} catch (e) {
throw new Error("Decryption failed: wrong key or corrupted data");
}
if (!decrypted) {
throw new Error("Decryption failed: no data returned");
}
const decryptedStr = Buffer.from(decrypted).toString("utf8");
try {
return JSON.parse(decryptedStr);
} catch (e) {
throw new Error(
`Decryption succeeded but invalid JSON: ${decryptedStr}`
);
}
} }
async init() { async init() {
await this._initSodium(); await this._initSodium();

164
package-lock.json generated
View File

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

View File

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

View File

@@ -33,7 +33,9 @@ export default class NotesAPI {
const key = Buffer.from(this.encryptionKey, 'hex') const key = Buffer.from(this.encryptionKey, 'hex')
if (key.length !== 32) { if (key.length !== 32) {
throw new Error('Encryption key must be 64 hex characters (32 bytes)') throw new Error(
'Encryption key must be 64 hex characters (32 bytes)',
)
} }
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES) const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES)
@@ -42,7 +44,7 @@ export default class NotesAPI {
const ciphertext = sodium.crypto_secretbox_easy( const ciphertext = sodium.crypto_secretbox_easy(
Buffer.from(message), Buffer.from(message),
nonce, nonce,
key key,
) )
const combined = Buffer.concat([nonce, ciphertext]) const combined = Buffer.concat([nonce, ciphertext])
@@ -56,20 +58,53 @@ export default class NotesAPI {
const key = Buffer.from(this.encryptionKey, 'hex') const key = Buffer.from(this.encryptionKey, 'hex')
if (key.length !== 32) { if (key.length !== 32) {
throw new Error('Encryption key must be 64 hex characters (32 bytes)') throw new Error(
'Encryption key must be 64 hex characters (32 bytes)',
)
}
let combined
try {
combined = Buffer.from(encryptedData, 'base64')
} catch (e) {
throw new Error('Invalid encrypted data: not valid base64')
}
if (
combined.length <
sodium.crypto_secretbox_NONCEBYTES +
sodium.crypto_secretbox_MACBYTES
) {
throw new Error('Invalid encrypted data: too short')
} }
const combined = Buffer.from(encryptedData, 'base64')
const nonce = combined.slice(0, sodium.crypto_secretbox_NONCEBYTES) const nonce = combined.slice(0, sodium.crypto_secretbox_NONCEBYTES)
const ciphertext = combined.slice(sodium.crypto_secretbox_NONCEBYTES) const ciphertext = combined.slice(sodium.crypto_secretbox_NONCEBYTES)
const decrypted = sodium.crypto_secretbox_open_easy( let decrypted
ciphertext, try {
nonce, decrypted = sodium.crypto_secretbox_open_easy(
key ciphertext,
) nonce,
key,
)
} catch (e) {
throw new Error('Decryption failed: wrong key or corrupted data')
}
return JSON.parse(decrypted.toString()) if (!decrypted) {
throw new Error('Decryption failed: no data returned')
}
const decryptedStr = Buffer.from(decrypted).toString('utf8')
try {
return JSON.parse(decryptedStr)
} catch (e) {
throw new Error(
`Decryption succeeded but invalid JSON: ${decryptedStr}`,
)
}
} }
async init() { async init() {
@@ -81,6 +116,7 @@ export default class NotesAPI {
for (const encryptedNote of encryptedNotes) { for (const encryptedNote of encryptedNotes) {
try { try {
const note = this._decrypt(encryptedNote.data || encryptedNote) const note = this._decrypt(encryptedNote.data || encryptedNote)
this.notesCache.set(note.id, note) this.notesCache.set(note.id, note)
this.index.add(note.id, note.title + '\n' + note.content) this.index.add(note.id, note.title + '\n' + note.content)
} catch (error) { } catch (error) {

View File

@@ -1,18 +1,84 @@
<template> <template>
<router-link class="category-row" :to="`/category/${category}`"> <component
:class="['category-row', { editable }]"
:to="`/category/${category}`"
:is="wrapper"
>
<span class="index">{{ String(index + 1).padStart(2, '0') }}.</span> <span class="index">{{ String(index + 1).padStart(2, '0') }}.</span>
<span class="title h1">{{ category }}</span> <form v-if="isEditing" @submit.prevent="onSave">
</router-link> <input
v-model="categoryInput"
class="category-input"
type="text"
ref="input"
@blur="onSave"
/>
</form>
<span v-else class="title h1">{{ categoryInput }}</span>
<button v-if="isEditing" class="save-button" @click="onSave">
Save
</button>
<button v-else-if="editable" class="edit-button" @click="onEdit">
Edit
</button>
</component>
</template> </template>
<script setup> <script setup>
const props = defineProps({ index: Number, category: String }) import { computed, ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
const props = defineProps({
index: Number,
category: {
type: String,
default: () => '',
},
editable: {
type: Boolean,
default: () => false,
},
})
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.editable ? 'div' : RouterLink
})
</script> </script>
<style lang="scss"> <style lang="scss">
.category-row { .category-row {
display: grid; display: grid;
grid-template-columns: size-vw(26px) 1fr; grid-template-columns: size-vw(26px) 1fr auto;
align-items: flex-start;
width: 100%; width: 100%;
position: relative; position: relative;
padding: size-vw(5px) 0 size-vw(15px); padding: size-vw(5px) 0 size-vw(15px);
@@ -20,22 +86,52 @@ const props = defineProps({ index: Number, category: String })
.index { .index {
margin-top: size-vw(19px); margin-top: size-vw(19px);
@include p;
} }
.title { .title {
display: block; display: block;
width: 100%; width: 100%;
@include line-clamp(2); @include line-clamp(2);
} }
.category-input {
display: block;
width: 100%;
@include h1;
&:focus {
outline: none;
}
}
.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;
}
}
&::after { &::after {
content: '----------------------------------------'; content: '----------------------------------------';
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 0; left: 0;
@include p;
} }
&.router-link-exact-active { &.router-link-exact-active,
&.editable {
cursor: default; cursor: default;
} }
&:hover:not(.router-link-exact-active) { &:hover:not(.router-link-exact-active):not(.editable) {
color: var(--theme-accent); color: var(--theme-accent);
} }
} }

View File

@@ -4,8 +4,14 @@
<Nav /> <Nav />
<div class="menu-wrap layout-block-inner"> <div class="menu-wrap layout-block-inner">
<new-note class="menu-item" @noteOpened="closeMenu" /> <new-note
<button class="menu-item">+ New Capitulum</button> class="menu-item"
category="Special Delivery"
@noteOpened="closeMenu"
/>
<router-link class="menu-item" to="/category"
>+ New Capitulum</router-link
>
<button class="menu-item">Change Theme</button> <button class="menu-item">Change Theme</button>
<router-link class="menu-item" to="/instructions" <router-link class="menu-item" to="/instructions"
>Instructio</router-link >Instructio</router-link

View File

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

View File

@@ -46,6 +46,16 @@ export default () => {
return note return note
} }
async function updateCategory(category, update) {
const notes = await loadCategoryNotes(category)
notes.forEach(async (note) => {
await updateNoteMetadata(note.id, { category: update })
})
await loadCategories()
}
/* ------------------------- /* -------------------------
Search Search
--------------------------*/ --------------------------*/
@@ -69,6 +79,7 @@ export default () => {
createNote, createNote,
updateNoteContent, updateNoteContent,
updateNoteMetadata, updateNoteMetadata,
updateCategory,
search, search,
} }

View File

@@ -2,12 +2,14 @@ import { createRouter, createWebHistory } from 'vue-router'
import Directory from '@/views/Directory.vue' import Directory from '@/views/Directory.vue'
import Editor from '@/views/Editor.vue' import Editor from '@/views/Editor.vue'
import CreateCategory from '@/views/CreateCategory.vue'
import Category from '@/views/Category.vue' import Category from '@/views/Category.vue'
import Instructions from '@/views/Instructions.vue' import Instructions from '@/views/Instructions.vue'
const routes = [ const routes = [
{ path: '/', name: 'directory', component: Directory }, { path: '/', name: 'directory', component: Directory },
{ path: '/note/:id', name: 'note', component: Editor }, { path: '/note/:id', name: 'note', component: Editor },
{ path: '/category', name: 'create-category', component: CreateCategory },
{ path: '/category/:id', name: 'category', component: Category }, { path: '/category/:id', name: 'category', component: Category },
{ path: '/instructions', name: 'instructions', component: Instructions }, { path: '/instructions', name: 'instructions', component: Instructions },
] ]

View File

@@ -2,7 +2,12 @@
<main class="category layout-block"> <main class="category layout-block">
<router-link class="back" to="/"><- Go Back</router-link> <router-link class="back" to="/"><- Go Back</router-link>
<category-row :index="categoryIndex" :category="id" /> <category-row
:index="categoryIndex"
:category="id"
editable
@edited="onCategoryEdited"
/>
<div class="notes"> <div class="notes">
<note-row v-for="note in notes" :note="note" :key="note.id" /> <note-row v-for="note in notes" :note="note" :key="note.id" />
@@ -14,25 +19,34 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import useNotes from '@/composables/useNotes' import useNotes from '@/composables/useNotes'
import NoteRow from '@/components/NoteRow.vue' import NoteRow from '@/components/NoteRow.vue'
import CategoryRow from '@/components/CategoryRow.vue' import CategoryRow from '@/components/CategoryRow.vue'
import NewNote from '@/components/NewNote.vue' import NewNote from '@/components/NewNote.vue'
const route = useRoute() const route = useRoute()
const id = route.params.id const id = route.params?.id
const router = useRouter()
const { categories, loadCategoryNotes } = useNotes() const { categories, loadCategoryNotes, updateCategory } = useNotes()
const notes = ref() const notes = ref()
onMounted(async () => { onMounted(async () => {
notes.value = await loadCategoryNotes(id) if (id) {
notes.value = await loadCategoryNotes(id)
}
}) })
const onCategoryEdited = async (editedCategory) => {
await updateCategory(id, editedCategory)
router.push({ name: 'category', params: { id: editedCategory } })
}
const categoryIndex = computed(() => { const categoryIndex = computed(() => {
return categories.value?.findIndex((category) => category === id) || 0 return categories.value?.findIndex((category) => category === id) || 1
}) })
</script> </script>

View File

@@ -0,0 +1,21 @@
<template>
<main class="create-category layout-block-inner">
<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">
main.create-category {
}
</style>

View File

@@ -14,6 +14,12 @@
> >
Italic Italic
</button> </button>
<button
@click="editor.chain().focus().toggleHighlight().run()"
:class="{ active: editor.isActive('highlight') }"
>
Highlight
</button>
</div> </div>
</bubble-menu> </bubble-menu>
@@ -24,12 +30,14 @@
<script setup> <script setup>
import { onBeforeUnmount, onMounted, shallowRef } from 'vue' import { onBeforeUnmount, onMounted, shallowRef } from 'vue'
import { Editor, EditorContent } from '@tiptap/vue-3' 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 Document from '@tiptap/extension-document'
import { Placeholder } from '@tiptap/extensions' import { Placeholder } from '@tiptap/extensions'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import { TaskList, TaskItem } from '@tiptap/extension-list'
import { Highlight } from '@tiptap/extension-highlight'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import { BubbleMenu } from '@tiptap/vue-3/menus' import { BubbleMenu } from '@tiptap/vue-3/menus'
import { all, createLowlight } from 'lowlight'
import useNotes from '@/composables/useNotes' import useNotes from '@/composables/useNotes'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import _debounce from 'lodash/debounce' import _debounce from 'lodash/debounce'
@@ -46,9 +54,9 @@ const CustomDocument = Document.extend({
const editor = shallowRef() const editor = shallowRef()
const updateNote = _debounce(async ({ editor }) => { const updateNote = _debounce(async ({ editor }) => {
const markdown = editor.getMarkdown() const json = editor.getJSON()
await updateNoteContent(id, markdown) await updateNoteContent(id, json)
updateTitle(editor) updateTitle(editor)
}, 300) }, 300)
@@ -71,9 +79,11 @@ const updateTitle = _debounce(async (editor) => {
onMounted(async () => { onMounted(async () => {
const note = await loadNote(id) const note = await loadNote(id)
console.log(note)
lastTitle = note.title lastTitle = note.title
// Lowlight setup
const lowlight = createLowlight(all)
editor.value = new Editor({ editor.value = new Editor({
extensions: [ extensions: [
CustomDocument, CustomDocument,
@@ -91,9 +101,15 @@ onMounted(async () => {
} }
}, },
}), }),
Image, TaskList,
TaskItem,
Highlight,
CodeBlockLowlight.configure({
lowlight,
enableTabIndentation: true,
}),
], ],
content: note.content, content: note.content || [],
onUpdate: updateNote, onUpdate: updateNote,
}) })
}) })
@@ -155,6 +171,94 @@ main.editor {
color: var(--theme-link); color: var(--theme-link);
cursor: pointer; cursor: pointer;
} }
code {
border: 1px solid var(--grey-100);
color: var(--theme-accent);
padding: 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: size-vw(1px);
background: currentColor;
}
}
mark {
background: var(--theme-accent);
color: var(--theme-bg);
padding: 0.2em;
border-radius: 0.2em;
}
.editor-wrap { .editor-wrap {
> div { > div {