new category flow
This commit is contained in:
@@ -33,7 +33,9 @@ export default class NotesAPI {
|
||||
|
||||
const key = Buffer.from(this.encryptionKey, 'hex')
|
||||
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)
|
||||
@@ -42,7 +44,7 @@ export default class NotesAPI {
|
||||
const ciphertext = sodium.crypto_secretbox_easy(
|
||||
Buffer.from(message),
|
||||
nonce,
|
||||
key
|
||||
key,
|
||||
)
|
||||
|
||||
const combined = Buffer.concat([nonce, ciphertext])
|
||||
@@ -56,20 +58,53 @@ export default class NotesAPI {
|
||||
|
||||
const key = Buffer.from(this.encryptionKey, 'hex')
|
||||
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 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 = Buffer.from(decrypted).toString('utf8')
|
||||
|
||||
try {
|
||||
return JSON.parse(decryptedStr)
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Decryption succeeded but invalid JSON: ${decryptedStr}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
@@ -81,6 +116,7 @@ export default class NotesAPI {
|
||||
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) {
|
||||
|
||||
@@ -1,18 +1,84 @@
|
||||
<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,
|
||||
},
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
<style lang="scss">
|
||||
.category-row {
|
||||
display: grid;
|
||||
grid-template-columns: size-vw(26px) 1fr;
|
||||
grid-template-columns: size-vw(26px) 1fr auto;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding: size-vw(5px) 0 size-vw(15px);
|
||||
@@ -20,22 +86,52 @@ const props = defineProps({ index: Number, category: String })
|
||||
|
||||
.index {
|
||||
margin-top: size-vw(19px);
|
||||
@include p;
|
||||
}
|
||||
.title {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@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 {
|
||||
content: '----------------------------------------';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
@include p;
|
||||
}
|
||||
&.router-link-exact-active {
|
||||
&.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,14 @@
|
||||
<Nav />
|
||||
|
||||
<div class="menu-wrap layout-block-inner">
|
||||
<new-note class="menu-item" @noteOpened="closeMenu" />
|
||||
<button class="menu-item">+ New Capitulum</button>
|
||||
<new-note
|
||||
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>
|
||||
<router-link class="menu-item" to="/instructions"
|
||||
>Instructio</router-link
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -46,6 +46,16 @@ export default () => {
|
||||
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
|
||||
--------------------------*/
|
||||
@@ -69,6 +79,7 @@ export default () => {
|
||||
createNote,
|
||||
updateNoteContent,
|
||||
updateNoteMetadata,
|
||||
updateCategory,
|
||||
|
||||
search,
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import Directory from '@/views/Directory.vue'
|
||||
import Editor from '@/views/Editor.vue'
|
||||
import CreateCategory from '@/views/CreateCategory.vue'
|
||||
import Category from '@/views/Category.vue'
|
||||
import Instructions from '@/views/Instructions.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', name: 'directory', component: Directory },
|
||||
{ path: '/note/:id', name: 'note', component: Editor },
|
||||
{ path: '/category', name: 'create-category', component: CreateCategory },
|
||||
{ path: '/category/:id', name: 'category', component: Category },
|
||||
{ path: '/instructions', name: 'instructions', component: Instructions },
|
||||
]
|
||||
|
||||
@@ -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" />
|
||||
@@ -14,25 +19,34 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
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 } = useNotes()
|
||||
|
||||
const notes = ref()
|
||||
|
||||
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(() => {
|
||||
return categories.value?.findIndex((category) => category === id) || 0
|
||||
return categories.value?.findIndex((category) => category === id) || 1
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
21
src/renderer/src/views/CreateCategory.vue
Normal file
21
src/renderer/src/views/CreateCategory.vue
Normal 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>
|
||||
@@ -14,6 +14,12 @@
|
||||
>
|
||||
Italic
|
||||
</button>
|
||||
<button
|
||||
@click="editor.chain().focus().toggleHighlight().run()"
|
||||
:class="{ active: editor.isActive('highlight') }"
|
||||
>
|
||||
Highlight
|
||||
</button>
|
||||
</div>
|
||||
</bubble-menu>
|
||||
|
||||
@@ -24,12 +30,14 @@
|
||||
<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 { TaskList, TaskItem } from '@tiptap/extension-list'
|
||||
import { Highlight } from '@tiptap/extension-highlight'
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
|
||||
import { BubbleMenu } from '@tiptap/vue-3/menus'
|
||||
import { all, createLowlight } from 'lowlight'
|
||||
import useNotes from '@/composables/useNotes'
|
||||
import { useRoute } from 'vue-router'
|
||||
import _debounce from 'lodash/debounce'
|
||||
@@ -46,9 +54,9 @@ const CustomDocument = Document.extend({
|
||||
const editor = shallowRef()
|
||||
|
||||
const updateNote = _debounce(async ({ editor }) => {
|
||||
const markdown = editor.getMarkdown()
|
||||
const json = editor.getJSON()
|
||||
|
||||
await updateNoteContent(id, markdown)
|
||||
await updateNoteContent(id, json)
|
||||
|
||||
updateTitle(editor)
|
||||
}, 300)
|
||||
@@ -71,9 +79,11 @@ const updateTitle = _debounce(async (editor) => {
|
||||
|
||||
onMounted(async () => {
|
||||
const note = await loadNote(id)
|
||||
console.log(note)
|
||||
lastTitle = note.title
|
||||
|
||||
// Lowlight setup
|
||||
const lowlight = createLowlight(all)
|
||||
|
||||
editor.value = new Editor({
|
||||
extensions: [
|
||||
CustomDocument,
|
||||
@@ -91,9 +101,15 @@ onMounted(async () => {
|
||||
}
|
||||
},
|
||||
}),
|
||||
Image,
|
||||
TaskList,
|
||||
TaskItem,
|
||||
Highlight,
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
enableTabIndentation: true,
|
||||
}),
|
||||
],
|
||||
content: note.content,
|
||||
content: note.content || [],
|
||||
onUpdate: updateNote,
|
||||
})
|
||||
})
|
||||
@@ -155,6 +171,94 @@ main.editor {
|
||||
color: var(--theme-link);
|
||||
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 {
|
||||
> div {
|
||||
|
||||
Reference in New Issue
Block a user