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

This commit is contained in:
nicwands
2026-03-12 13:25:56 -04:00
parent 93edf204ce
commit c93fc2cc58
24 changed files with 10210 additions and 2518 deletions

View File

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

View File

@@ -91,12 +91,14 @@ class Config {
); );
} }
} }
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 preloadPath = join(__dirname, "../preload/index.mjs");
const rendererPath = join(__dirname, "../renderer/index.html"); const rendererPath = join(__dirname, "../renderer/index.html");
function createWindow() { function createWindow() {
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 354, width: DEFAULT_WINDOW_SIZE.width,
height: 549, height: DEFAULT_WINDOW_SIZE.height,
show: false, show: false,
autoHideMenuBar: true, autoHideMenuBar: true,
webPreferences: { webPreferences: {
@@ -119,8 +121,8 @@ function createWindow() {
} }
function createNoteWindow(noteId) { function createNoteWindow(noteId) {
const noteWindow = new BrowserWindow({ const noteWindow = new BrowserWindow({
width: 354, width: DEFAULT_WINDOW_SIZE.width,
height: 549, height: DEFAULT_WINDOW_SIZE.height,
autoHideMenuBar: true, autoHideMenuBar: true,
webPreferences: { webPreferences: {
preload: preloadPath, preload: preloadPath,
@@ -186,6 +188,26 @@ app.whenReady().then(async () => {
ipcMain.on("note-changed", (_, event, data) => { ipcMain.on("note-changed", (_, event, data) => {
broadcastNoteChange(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
);
}
});
electronApp.setAppUserModelId("com.electron"); electronApp.setAppUserModelId("com.electron");
app.on("browser-window-created", (_, window) => { app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window); optimizer.watchWindowShortcuts(window);

View File

@@ -21,6 +21,12 @@ const api = {
}, },
notifyNoteChanged: (event, data) => { notifyNoteChanged: (event, data) => {
ipcRenderer.send("note-changed", event, data); ipcRenderer.send("note-changed", event, data);
},
moveOpened: () => {
ipcRenderer.invoke("move-opened");
},
moveClosed: () => {
ipcRenderer.invoke("move-closed");
} }
}; };
const adapter = { const adapter = {

File diff suppressed because one or more lines are too long

View File

@@ -337,7 +337,9 @@ a,
button, button,
input, input,
pre, pre,
span { span,
label,
li {
font-family: var(--font-mono); font-family: var(--font-mono);
font-weight: 400; font-weight: 400;
line-height: 1; line-height: 1;
@@ -603,7 +605,8 @@ main.directory .notes {
font-weight: 700; font-weight: 700;
} }
.note-editor p em { .note-editor p em {
font-style: italic; /* font-style: italic; */
color: var(--grey-100);
} }
.note-editor hr { .note-editor hr {
border: 1px dashed currentColor; border: 1px dashed currentColor;
@@ -789,6 +792,14 @@ main.category .new-note {
display: block; display: block;
margin-top: 9px; margin-top: 9px;
margin-bottom: 14px; margin-bottom: 14px;
}
main.instructions .content {
display: flex;
flex-direction: column;
gap: 20px;
}
main.instructions .content hr {
border-bottom: 1px dashed currentColor;
}main.search .back { }main.search .back {
display: block; display: block;
opacity: 0.25; opacity: 0.25;
@@ -823,4 +834,74 @@ main.search .results {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
}.preferences {
padding-top: 8px;
padding-bottom: 60px;
}
.preferences .back {
opacity: 0.25;
display: block;
margin-top: 9px;
margin-bottom: 14px;
}
.preferences h1 {
margin-bottom: 20px;
}
.preferences .plugin {
display: flex;
margin-bottom: 16px;
}
.preferences input[type=radio] {
display: block;
flex-shrink: 0;
width: 10px;
height: 10px;
margin-right: 10px;
border: 1px solid white;
cursor: pointer;
}
.preferences input[type=radio]:checked {
background-color: white;
}
.preferences .info .description {
color: var(--grey-100);
margin-top: 6px;
}
.preferences .config {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 16px;
}
.preferences .config-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.preferences .config-field input {
width: 100%;
border: 1px solid var(--grey-100);
border-radius: 0.2em;
padding: 0.2em 0.5em;
}
.preferences .error {
color: red;
margin-top: 16px;
}
.preferences .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);
}
.preferences .save-btn .svg-spinner {
width: 1em;
height: 1em;
}
.preferences .save-btn:hover {
color: var(--theme-accent);
} }

View File

@@ -8,8 +8,8 @@
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/> />
<script type="module" crossorigin src="./assets/index-CoqDP7Z2.js"></script> <script type="module" crossorigin src="./assets/index-CzxWU9vx.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CVyE7-c9.css"> <link rel="stylesheet" crossorigin href="./assets/index-NYhAwsHy.css">
</head> </head>
<body> <body>

View File

@@ -7,14 +7,17 @@ import PluginRegistry from './core/PluginRegistry.js'
import Config from './core/Config.js' import Config from './core/Config.js'
import { join } from 'path' 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 preloadPath = join(__dirname, '../preload/index.mjs')
const rendererPath = join(__dirname, '../renderer/index.html') const rendererPath = join(__dirname, '../renderer/index.html')
// Main window // Main window
function createWindow() { function createWindow() {
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 354, width: DEFAULT_WINDOW_SIZE.width,
height: 549, height: DEFAULT_WINDOW_SIZE.height,
show: false, show: false,
autoHideMenuBar: true, autoHideMenuBar: true,
webPreferences: { webPreferences: {
@@ -42,8 +45,8 @@ function createWindow() {
// Open note in new window // Open note in new window
function createNoteWindow(noteId) { function createNoteWindow(noteId) {
const noteWindow = new BrowserWindow({ const noteWindow = new BrowserWindow({
width: 354, width: DEFAULT_WINDOW_SIZE.width,
height: 549, height: DEFAULT_WINDOW_SIZE.height,
autoHideMenuBar: true, autoHideMenuBar: true,
webPreferences: { webPreferences: {
preload: preloadPath, preload: preloadPath,
@@ -138,6 +141,30 @@ app.whenReady().then(async () => {
broadcastNoteChange(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,
)
}
})
electronApp.setAppUserModelId('com.electron') electronApp.setAppUserModelId('com.electron')
app.on('browser-window-created', (_, window) => { app.on('browser-window-created', (_, window) => {

View File

@@ -25,6 +25,12 @@ const api = {
notifyNoteChanged: (event, data) => { notifyNoteChanged: (event, data) => {
ipcRenderer.send('note-changed', event, data) ipcRenderer.send('note-changed', event, data)
}, },
moveOpened: () => {
ipcRenderer.invoke('move-opened')
},
moveClosed: () => {
ipcRenderer.invoke('move-closed')
},
} }
// Implement adapter API - communicates with plugin adapter in main process // Implement adapter API - communicates with plugin adapter in main process

View File

@@ -3,7 +3,11 @@
<Nav /> <Nav />
<Suspense> <Suspense>
<div class="layout-container">
<router-view :key="$route.fullPath" /> <router-view :key="$route.fullPath" />
<MoveMenu />
</div>
</Suspense> </Suspense>
<Menu /> <Menu />
@@ -18,6 +22,7 @@ import loadFonts from '@fuzzco/font-loader'
import { useWindowSize } from '@vueuse/core' import { useWindowSize } from '@vueuse/core'
import Menu from '@/components/Menu.vue' import Menu from '@/components/Menu.vue'
import Nav from '@/components/Nav.vue' import Nav from '@/components/Nav.vue'
import MoveMenu from '@/components/MoveMenu.vue'
import ScrollBar from '@/components/ScrollBar.vue' import ScrollBar from '@/components/ScrollBar.vue'
import useConfig from '@/composables/useConfig' import useConfig from '@/composables/useConfig'
@@ -67,6 +72,12 @@ const styles = computed(() => ({
color: var(--theme-fg); color: var(--theme-fg);
transition: opacity 400ms; transition: opacity 400ms;
.layout-container {
display: grid;
grid-template-columns: 1fr auto;
min-height: calc(100 * var(--vh));
}
&:not(.fonts-ready) { &:not(.fonts-ready) {
opacity: 0; opacity: 0;
} }

View File

@@ -39,6 +39,7 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: () => false, default: () => false,
}, },
wrapper: String,
}) })
const emit = defineEmits(['edited']) const emit = defineEmits(['edited'])
@@ -70,7 +71,7 @@ const onSave = async () => {
} }
const wrapper = computed(() => { const wrapper = computed(() => {
return props.editable ? 'div' : RouterLink return props.wrapper || (props.editable ? 'div' : RouterLink)
}) })
</script> </script>

View File

@@ -64,7 +64,7 @@ const openNewCategory = () => {}
.menu-wrap { .menu-wrap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-top: 3px; padding-top: 1.2em;
padding-bottom: 10px; padding-bottom: 10px;
.menu-item { .menu-item {

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

@@ -8,7 +8,6 @@
<script setup> <script setup>
import useMenu from '@/composables/useMenu' import useMenu from '@/composables/useMenu'
import { onMounted } from 'vue'
const { menuOpen, closeMenu, openMenu } = useMenu() const { menuOpen, closeMenu, openMenu } = useMenu()
@@ -19,18 +18,11 @@ const toggleMenu = () => {
openMenu() 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> </script>
<style lang="scss"> <style lang="scss">
.nav { .nav {
position: absolute;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;

View File

@@ -1,12 +1,26 @@
<template> <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="date">{{ formatDate(note.createdAt) }}</span>
<span class="title bold">{{ note.title }}</span> <div class="title-actions">
<button class="title bold" @click="openNote(note.id)">
{{ note.title }}
</button> </button>
<button class="action bold" @click="openNote(note.id)">
(open)
</button>
<button class="action bold move" @click="onMoveOpened">
(move)
</button>
</div>
</div>
</template> </template>
<script setup> <script setup>
import useOpenNote from '@/composables/useOpenNote' import useOpenNote from '@/composables/useOpenNote'
import useState from '@/composables/useState'
import { useRoute, useRouter } from 'vue-router'
import { computed } from 'vue'
import { format } from 'fecha' import { format } from 'fecha'
const props = defineProps({ note: Object }) const props = defineProps({ note: Object })
@@ -17,6 +31,21 @@ const formatDate = (date) => {
const d = new Date(date) const d = new Date(date)
return format(d, 'MM/DD/YYYY') 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> </script>
<style lang="scss"> <style lang="scss">
@@ -25,28 +54,33 @@ const formatDate = (date) => {
display: grid; display: grid;
width: 100%; width: 100%;
gap: 20px; gap: 20px;
cursor: pointer;
.title { .title-actions {
width: calc(100% - 43.2px); display: grid;
position: relative; grid-template-columns: 1fr auto auto;
align-items: flex-start;
gap: 2px;
&::after { .action {
content: '(open)';
position: absolute;
bottom: 0;
right: 0;
transform: translateX(100%);
font-weight: 700;
opacity: 0; opacity: 0;
&:not(:hover) {
color: var(--grey-100);
} }
} }
&:hover { }
&:hover,
&.move-active {
color: var(--theme-accent); color: var(--theme-accent);
.title::after { .title-actions .action {
opacity: 1; opacity: 1;
} }
} }
&.move-active {
.title-actions .move {
color: var(--theme-accent);
}
}
} }
</style> </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,8 @@
import { createGlobalState } from '@vueuse/core'
import { ref } from 'vue'
export default createGlobalState(() => {
const moveMenuOpen = ref(false)
return { moveMenuOpen }
})

View File

@@ -61,6 +61,8 @@ const categoryIndex = computed(() => {
<style lang="scss"> <style lang="scss">
main.category { main.category {
padding-top: 1.24em;
.back { .back {
display: block; display: block;
opacity: 0.25; opacity: 0.25;

View File

@@ -14,3 +14,9 @@ const onCategoryEdited = (name) => {
router.push({ name: 'category', params: { id: name } }) router.push({ name: 'category', params: { id: name } })
} }
</script> </script>
<style lang="scss">
.create-category {
padding-top: 1.2em;
}
</style>

View File

@@ -7,7 +7,7 @@
:key="category" :key="category"
/> />
<h2 class="label">Summarium</h2> <h2 v-if="notes?.length" class="label">Summarium</h2>
<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" />
@@ -46,7 +46,7 @@ watch(notesChangeCount, async () => {
<style lang="scss"> <style lang="scss">
main.directory { main.directory {
padding-top: 18px; padding-top: 26px;
padding-bottom: 30px; padding-bottom: 30px;
.label { .label {

View File

@@ -16,6 +16,8 @@ const renderedContent = md.render(content)
<style lang="scss"> <style lang="scss">
main.instructions { main.instructions {
padding-top: 1.2em;
.back-link { .back-link {
opacity: 0.25; opacity: 0.25;
display: block; display: block;

View File

@@ -23,7 +23,7 @@ watchEffect(() => {
<style lang="scss"> <style lang="scss">
main.note { main.note {
padding-top: 8px; padding-top: 2.2em;
padding-bottom: 20px; padding-bottom: 20px;
} }
</style> </style>

View File

@@ -6,7 +6,7 @@
<div v-for="plugin in plugins" class="plugin" :key="plugin.id"> <div v-for="plugin in plugins" class="plugin" :key="plugin.id">
<input <input
v-model="activePluginId" v-model="selectedPluginId"
name="plugins" name="plugins"
type="radio" type="radio"
:id="plugin.id" :id="plugin.id"
@@ -16,14 +16,14 @@
<p class="name bold">{{ plugin.name }}</p> <p class="name bold">{{ plugin.name }}</p>
<p class="description">{{ plugin.description }}</p> <p class="description">{{ plugin.description }}</p>
<form v-if="plugin.configSchema.length" class="config"> <div v-if="plugin.configSchema.length" class="config">
<div <div
v-for="field in plugin.configSchema" v-for="field in plugin.configSchema"
class="config-field" class="config-field"
:key="field.key" :key="field.key"
> >
<label :for="field.key"> <label :for="field.key">
{{ field.label }} {{ field.label }} {{ field.required ? '*' : '' }}
</label> </label>
<input <input
v-model="config.adapters[plugin.id][field.key]" v-model="config.adapters[plugin.id][field.key]"
@@ -33,31 +33,67 @@
:required="field.required" :required="field.required"
/> />
</div> </div>
</form>
</div> </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> </main>
</template> </template>
<script setup> <script setup>
import SvgSpinner from '@/components/svg/Spinner.vue'
import usePlugins from '@/composables/usePlugins' import usePlugins from '@/composables/usePlugins'
import useConfig from '@/composables/useConfig' import useConfig from '@/composables/useConfig'
import { ref, watch } from 'vue' import { ref, computed } from 'vue'
const { plugins, setActivePlugin } = await usePlugins() const { plugins, setActivePlugin } = await usePlugins()
const { config, ensureConfig } = useConfig() const { config, ensureConfig } = useConfig()
await ensureConfig() await ensureConfig()
const activePluginId = ref(config.value.activeAdapter) const selectedPluginId = ref(config.value.activeAdapter)
const validationError = ref('')
watch(activePluginId, async (id) => { const selectedPlugin = computed(() => {
await setActivePlugin(id) 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> </script>
<style lang="scss"> <style lang="scss">
.preferences { .preferences {
padding-top: 8px; padding-top: 1.2em;
padding-bottom: 60px;
.back { .back {
opacity: 0.25; opacity: 0.25;
@@ -114,5 +150,29 @@ watch(activePluginId, async (id) => {
padding: 0.2em 0.5em; 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> </style>

View File

@@ -54,6 +54,8 @@ const onInput = _debounce(async () => {
<style lang="scss"> <style lang="scss">
main.search { main.search {
padding-top: 1.2em;
.back { .back {
display: block; display: block;
opacity: 0.25; opacity: 0.25;