├── frontend ├── src │ ├── version.txt │ ├── vite-env.d.ts │ ├── components │ │ ├── NoteEditor │ │ │ ├── types.ts │ │ │ ├── fonts.ts │ │ │ ├── ToolbarBtn.tsx │ │ │ ├── hooks │ │ │ │ └── useDebounce.ts │ │ │ ├── StatusBar.tsx │ │ │ └── Toolbar.tsx │ │ ├── Layout.tsx │ │ ├── update │ │ │ ├── UpdateNotification.tsx │ │ │ └── AutoUpdate.tsx │ │ ├── modals │ │ │ └── MainModal.tsx │ │ ├── Header.tsx │ │ ├── utils │ │ │ └── tiptapExtensions.ts │ │ ├── Settings.tsx │ │ └── NoteEditor.tsx │ ├── App.tsx │ ├── assets │ │ ├── sidebar-left.svg │ │ └── sidebar-right.svg │ ├── main.tsx │ ├── contexts │ │ ├── WindowContext.tsx │ │ ├── SettingsContext.tsx │ │ └── NotesContext.tsx │ ├── pages │ │ ├── Main.tsx │ │ └── Sidebar.tsx │ ├── App.css │ └── index.css ├── package.json.md5 ├── postcss.config.cjs ├── tsconfig.node.json ├── vite.config.ts ├── tsconfig.json ├── index.html ├── package.json └── tailwind.config.cjs ├── scripts ├── build.sh ├── build-macos-intel.sh ├── build-macos-arm.sh ├── build-macos.sh ├── build-windows.sh ├── install-wails-cli.sh ├── postinstall ├── scripts │ └── postinstall ├── create-dmg.sh └── dmg-background.svg ├── .vscode └── settings.json ├── wails.json ├── .gitignore ├── LICENSE ├── go.mod ├── dockerfile ├── main.go ├── README.md ├── CODE_OF_CONDUCT.md ├── go.sum └── app.go /frontend/src/version.txt: -------------------------------------------------------------------------------- 1 | v1.0.5 -------------------------------------------------------------------------------- /frontend/package.json.md5: -------------------------------------------------------------------------------- 1 | aa6426b98ee8b068a4fe474314a58a39 -------------------------------------------------------------------------------- /frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/src/components/NoteEditor/types.ts: -------------------------------------------------------------------------------- 1 | export interface FontOption { 2 | label: string; 3 | value: string; 4 | } -------------------------------------------------------------------------------- /frontend/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | echo -e "Start running the script..." 4 | cd ../ 5 | 6 | echo -e "Start building the app..." 7 | wails build --clean 8 | 9 | echo -e "End running the script!" 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.git": true, 4 | "**/.svn": true, 5 | "**/.hg": true, 6 | "**/.DS_Store": true, 7 | "**/Thumbs.db": true 8 | }, 9 | "hide-files.files": [] 10 | } -------------------------------------------------------------------------------- /frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "nodenext", 5 | "moduleResolution": "nodenext", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /scripts/build-macos-intel.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | echo -e "Start running the script..." 4 | cd ../ 5 | 6 | echo -e "Start building the app for macos platform..." 7 | wails build --clean --platform darwin 8 | 9 | echo -e "End running the script!" 10 | -------------------------------------------------------------------------------- /scripts/build-macos-arm.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | echo -e "Start running the script..." 4 | cd ../ 5 | 6 | echo -e "Start building the app for macos platform..." 7 | wails build --clean --platform darwin/arm64 8 | 9 | echo -e "End running the script!" 10 | -------------------------------------------------------------------------------- /scripts/build-macos.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | echo -e "Start running the script..." 4 | cd ../ 5 | 6 | echo -e "Start building the app for macos platform..." 7 | wails build --clean --platform darwin/universal 8 | 9 | echo -e "End running the script!" 10 | -------------------------------------------------------------------------------- /scripts/build-windows.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | echo -e "Start running the script..." 4 | cd ../ 5 | 6 | echo -e "Start building the app for windows platform..." 7 | wails build --clean --platform windows/amd64 8 | 9 | echo -e "End running the script!" 10 | -------------------------------------------------------------------------------- /frontend/src/components/NoteEditor/fonts.ts: -------------------------------------------------------------------------------- 1 | import { FontOption } from './types.js'; 2 | 3 | export const fontFamilyOptions: FontOption[] = [ 4 | { label: 'Default', value: 'Inter' }, 5 | { label: 'Serif', value: 'Georgia' }, 6 | { label: 'Mono', value: 'MonoLisa' }, 7 | { label: 'Comic', value: 'Comic Sans MS' }, 8 | ]; -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import path from 'path' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | '@': path.resolve(__dirname, './src'), 11 | }, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /scripts/install-wails-cli.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | echo -e "Start running the script..." 4 | cd ../ 5 | 6 | echo -e "Current Go version: \c" 7 | go version 8 | 9 | echo -e "Install the Wails command line tool..." 10 | go install github.com/wailsapp/wails/v2/cmd/wails@latest 11 | 12 | echo -e "Successful installation!" 13 | 14 | echo -e "End running the script!" 15 | -------------------------------------------------------------------------------- /wails.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "voidnotes", 3 | "outputfilename": "voidnotes", 4 | "frontend:install": "bun install", 5 | "frontend:build": "bun run build", 6 | "frontend:dev:watcher": "bun run dev", 7 | "frontend:dev:serverUrl": "auto", 8 | "author": { 9 | "name": "xptea", 10 | "email": "80608102+xptea@users.noreply.github.com" 11 | }, 12 | "obfuscate": true 13 | } 14 | -------------------------------------------------------------------------------- /scripts/postinstall: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Sleep for a moment to ensure the package installation is complete 4 | sleep 1 5 | 6 | # Define paths 7 | APP_NAME="VoidNotes" 8 | TEMP_APP_PATH="/private/tmp/VoidNotes.app" 9 | FINAL_APP_PATH="/Applications/VoidNotes.app" 10 | 11 | # Remove existing app if it exists 12 | rm -rf "$FINAL_APP_PATH" 13 | 14 | # Move the app to Applications folder 15 | mv "$TEMP_APP_PATH" "$FINAL_APP_PATH" 16 | 17 | # Fix permissions and signing 18 | chown -R $USER:staff "$FINAL_APP_PATH" 19 | chmod -R 755 "$FINAL_APP_PATH" 20 | xattr -cr "$FINAL_APP_PATH" 21 | codesign --force --deep --sign - "$FINAL_APP_PATH" 22 | 23 | # Open the app 24 | open "$FINAL_APP_PATH" 25 | 26 | exit 0 27 | -------------------------------------------------------------------------------- /scripts/scripts/postinstall: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Sleep for a moment to ensure the package installation is complete 4 | sleep 1 5 | 6 | # Define paths 7 | APP_NAME="VoidNotes" 8 | TEMP_APP_PATH="/private/tmp/VoidNotes.app" 9 | FINAL_APP_PATH="/Applications/VoidNotes.app" 10 | 11 | # Remove existing app if it exists 12 | rm -rf "$FINAL_APP_PATH" 13 | 14 | # Move the app to Applications folder 15 | mv "$TEMP_APP_PATH" "$FINAL_APP_PATH" 16 | 17 | # Fix permissions and signing 18 | chown -R $USER:staff "$FINAL_APP_PATH" 19 | chmod -R 755 "$FINAL_APP_PATH" 20 | xattr -cr "$FINAL_APP_PATH" 21 | codesign --force --deep --sign - "$FINAL_APP_PATH" 22 | 23 | # Open the app 24 | open "$FINAL_APP_PATH" 25 | 26 | exit 0 27 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "nodenext", 13 | "moduleResolution": "nodenext", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": ".", 19 | "paths": { 20 | "@/*": ["src/*"] 21 | } 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import './App.css'; 2 | import Layout from './components/Layout.js'; 3 | import { WindowProvider } from './contexts/WindowContext.js'; 4 | import { NotesProvider } from './contexts/NotesContext.js'; 5 | import { SettingsProvider } from './contexts/SettingsContext.js'; 6 | 7 | function App() { 8 | return ( 9 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | ); 24 | } 25 | 26 | export default App; 27 | -------------------------------------------------------------------------------- /frontend/src/components/NoteEditor/ToolbarBtn.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface ToolbarButtonProps { 4 | onClick: () => void; 5 | active?: boolean; 6 | disabled?: boolean; 7 | title: string; 8 | children: React.ReactNode; 9 | } 10 | 11 | export const ToolbarButton: React.FC = ({ onClick, active = false, disabled = false, title, children }) => ( 12 | 23 | ); -------------------------------------------------------------------------------- /frontend/src/assets/sidebar-left.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/assets/sidebar-right.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/components/NoteEditor/hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react'; 2 | 3 | export function useDebounce any>( 4 | func: T, 5 | delay: number 6 | ): (...args: Parameters) => void { 7 | const [timerId, setTimerId] = useState | null>(null); 8 | 9 | useEffect(() => { 10 | return () => { 11 | if (timerId) clearTimeout(timerId); 12 | }; 13 | }, [timerId]); 14 | 15 | const debouncedFunction = useCallback( 16 | (...args: Parameters) => { 17 | if (timerId) clearTimeout(timerId); 18 | 19 | const id = setTimeout(() => { 20 | func(...args); 21 | }, delay); 22 | 23 | setTimerId(id); 24 | }, 25 | [func, delay, timerId] 26 | ); 27 | 28 | return debouncedFunction; 29 | } -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import App from './App.js' 4 | import './index.css' 5 | import { NotesProvider } from './contexts/NotesContext.js' 6 | import { SettingsProvider } from './contexts/SettingsContext.js' 7 | import { WindowProvider } from './contexts/WindowContext.js' 8 | import { useAutoUpdate } from './components/update/AutoUpdate.js' 9 | 10 | const initAutoUpdate = () => { 11 | const { checkForUpdates } = useAutoUpdate(); 12 | checkForUpdates(); 13 | setInterval(checkForUpdates, 12 * 60 * 60 * 1000); 14 | }; 15 | 16 | const container = document.getElementById('root') as HTMLElement; 17 | const root = createRoot(container); 18 | root.render( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | 30 | initAutoUpdate(); 31 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | VoidNotes 7 | 27 | 28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | build/bin/ 8 | 9 | # Test binary, built with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | # Dependency directories (remove the comment below to include it) 16 | vendor/ 17 | 18 | # Go workspace file 19 | go.work 20 | 21 | # Wails generated files 22 | frontend/wailsjs/ 23 | 24 | # Frontend dependencies 25 | frontend/node_modules/ 26 | frontend/.pnp 27 | frontend/.pnp.js 28 | 29 | # Frontend testing 30 | frontend/coverage/ 31 | 32 | # Frontend production build 33 | frontend/dist/ 34 | frontend/build/ 35 | 36 | # Misc 37 | .DS_Store 38 | .env.local 39 | .env.development.local 40 | .env.test.local 41 | .env.production.local 42 | 43 | npm-debug.log* 44 | yarn-debug.log* 45 | yarn-error.log* 46 | *.log 47 | 48 | # Editor directories and files 49 | .idea/ 50 | .vscode/* 51 | !.vscode/extensions.json 52 | !.vscode/settings.json 53 | *.suo 54 | *.ntvs* 55 | *.njsproj 56 | *.sln 57 | *.sw? 58 | frontend/package.json.md5 59 | frontend/package-lock.json 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Void 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import Sidebar from '../pages/Sidebar.js'; 2 | import MainContent from '../pages/Main.js'; 3 | import Header from './Header.js'; 4 | import Settings from './Settings.js'; 5 | import { useEffect, useState } from 'react'; 6 | import { Environment } from '../../wailsjs/runtime/runtime.js'; 7 | 8 | interface LayoutProps { 9 | children?: React.ReactNode; 10 | } 11 | 12 | const Layout: React.FC = ({ children }) => { 13 | const [isMac, setIsMac] = useState(false); 14 | 15 | useEffect(() => { 16 | Environment().then((env) => { 17 | setIsMac(env.platform === 'darwin'); 18 | }); 19 | }, []); 20 | 21 | return ( 22 |
23 | {!isMac &&
} 24 |
25 | 26 |
27 | {children || } 28 |
29 |
30 | 31 |
32 | ); 33 | }; 34 | 35 | export default Layout; -------------------------------------------------------------------------------- /frontend/src/contexts/WindowContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useEffect, useState } from 'react'; 2 | import { Environment } from '../../wailsjs/runtime/runtime.js'; 3 | 4 | interface WindowContextType { 5 | isMac: boolean; 6 | isWindows: boolean; 7 | } 8 | 9 | const WindowContext = createContext({ 10 | isMac: false, 11 | isWindows: true, 12 | }); 13 | 14 | export const WindowProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { 15 | const [isMac, setIsMac] = useState(false); 16 | const [isWindows, setIsWindows] = useState(true); 17 | 18 | useEffect(() => { 19 | Environment().then((env) => { 20 | const mac = env.platform === 'darwin'; 21 | const windows = env.platform === 'windows'; 22 | setIsMac(mac); 23 | setIsWindows(windows); 24 | }).catch(err => { 25 | console.error('Failed to detect environment:', err); 26 | }); 27 | }, []); 28 | 29 | return ( 30 | 31 | {isMac && ( 32 |
36 | )} 37 | {children} 38 | 39 | ); 40 | }; 41 | 42 | export const useWindow = () => useContext(WindowContext); -------------------------------------------------------------------------------- /frontend/src/pages/Main.tsx: -------------------------------------------------------------------------------- 1 | import { useWindow } from '../contexts/WindowContext.js'; 2 | import { useSettings } from '../contexts/SettingsContext.js'; 3 | import NoteEditor from '../components/NoteEditor.js'; 4 | 5 | interface MainContentProps { 6 | className?: string; 7 | } 8 | 9 | const MainContent: React.FC = ({ className }) => { 10 | const { isMac } = useWindow(); 11 | const { getMainAreaStyle } = useSettings(); 12 | 13 | return ( 14 |
22 |
29 |
30 | 31 |
35 | 36 |
37 |
38 | ); 39 | }; 40 | 41 | export default MainContent; 42 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module testing-ui-go 2 | 3 | go 1.22.0 4 | 5 | toolchain go1.24.1 6 | 7 | require github.com/wailsapp/wails/v2 v2.10.1 8 | 9 | require ( 10 | github.com/bep/debounce v1.2.1 // indirect 11 | github.com/go-ole/go-ole v1.3.0 // indirect 12 | github.com/godbus/dbus/v5 v5.1.0 // indirect 13 | github.com/google/uuid v1.6.0 // indirect 14 | github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect 15 | github.com/labstack/echo/v4 v4.13.3 // indirect 16 | github.com/labstack/gommon v0.4.2 // indirect 17 | github.com/leaanthony/go-ansi-parser v1.6.1 // indirect 18 | github.com/leaanthony/gosod v1.0.4 // indirect 19 | github.com/leaanthony/slicer v1.6.0 // indirect 20 | github.com/leaanthony/u v1.1.1 // indirect 21 | github.com/mattn/go-colorable v0.1.13 // indirect 22 | github.com/mattn/go-isatty v0.0.20 // indirect 23 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect 24 | github.com/pkg/errors v0.9.1 // indirect 25 | github.com/rivo/uniseg v0.4.7 // indirect 26 | github.com/samber/lo v1.49.1 // indirect 27 | github.com/tkrajina/go-reflector v0.5.8 // indirect 28 | github.com/valyala/bytebufferpool v1.0.0 // indirect 29 | github.com/valyala/fasttemplate v1.2.2 // indirect 30 | github.com/wailsapp/go-webview2 v1.0.19 // indirect 31 | github.com/wailsapp/mimetype v1.4.1 // indirect 32 | golang.org/x/crypto v0.33.0 // indirect 33 | golang.org/x/net v0.35.0 // indirect 34 | golang.org/x/sys v0.30.0 // indirect 35 | golang.org/x/text v0.22.0 // indirect 36 | ) 37 | -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-bullseye 2 | 3 | WORKDIR /usr/src/app 4 | 5 | # Install base build tools 6 | RUN apt-get update && apt-get install -y --fix-missing \ 7 | build-essential \ 8 | unzip \ 9 | curl 10 | 11 | # Install GTK and WebKit for AMD64 12 | RUN apt-get install -y --fix-missing \ 13 | libgtk-3-dev \ 14 | libwebkit2gtk-4.0-dev 15 | 16 | # Install additional dependencies for AMD64 17 | RUN apt-get install -y --fix-missing \ 18 | libpango-1.0-0 \ 19 | libpangocairo-1.0-0 \ 20 | libharfbuzz0b \ 21 | libatk1.0-0 \ 22 | libcairo2 \ 23 | libcairo-gobject2 \ 24 | libgdk-pixbuf-2.0-0 \ 25 | libsoup2.4-1 \ 26 | libglib2.0-0 \ 27 | libglib2.0-dev \ 28 | libjavascriptcoregtk-4.0-18 29 | 30 | # Install X11 libraries 31 | RUN apt-get install -y --fix-missing \ 32 | x11proto-dev \ 33 | libx11-dev \ 34 | libxrender-dev \ 35 | libxext-dev 36 | 37 | # Clear cache 38 | RUN rm -rf /var/lib/apt/lists/* 39 | 40 | # Install required tooling 41 | RUN go install github.com/wailsapp/wails/v2/cmd/wails@latest 42 | RUN curl -fsSL https://bun.sh/install | bash 43 | ENV BUN_INSTALL="/root/.bun" 44 | ENV PATH="${BUN_INSTALL}/bin:${PATH}" 45 | 46 | # Copy project files 47 | COPY . . 48 | 49 | # Install frontend dependencies 50 | WORKDIR /usr/src/app/frontend 51 | RUN bun install 52 | WORKDIR /usr/src/app 53 | 54 | # Build for Linux AMD64 55 | RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 wails build -platform linux/amd64 56 | 57 | FROM scratch AS extract 58 | COPY --from=0 /usr/src/app/build/bin/ / 59 | 60 | 61 | # run this build it. docker build --target extract --output type=local,dest=./build/bin . -------------------------------------------------------------------------------- /frontend/src/components/update/UpdateNotification.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useAutoUpdate } from './AutoUpdate.js'; 3 | 4 | interface UpdateNotificationProps { 5 | className?: string; 6 | showVersionOnly?: boolean; 7 | } 8 | 9 | const UpdateNotification: React.FC = ({ className = '', showVersionOnly = false }) => { 10 | const { 11 | currentVersion, 12 | latestVersion, 13 | isUpdateAvailable, 14 | isChecking, 15 | checkForUpdates, 16 | handleUpdate 17 | } = useAutoUpdate(); 18 | 19 | if (showVersionOnly) { 20 | return {currentVersion}; 21 | } 22 | 23 | return ( 24 |
25 |
26 | Version: {currentVersion} 27 | {isUpdateAvailable && ( 28 | 35 | )} 36 |
37 | {isUpdateAvailable && ( 38 |
New version available: {latestVersion}
39 | )} 40 | 47 |
48 | ); 49 | }; 50 | 51 | export default UpdateNotification; -------------------------------------------------------------------------------- /scripts/create-dmg.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 3 | PROJECT_ROOT="$SCRIPT_DIR/.." 4 | APP_NAME="VoidNotes" 5 | APP_PATH="$PROJECT_ROOT/build/bin/voidnotes.app" 6 | PKG_PATH="$PROJECT_ROOT/build/bin/${APP_NAME}_installer.pkg" 7 | 8 | # Post-install script creation 9 | POSTINSTALL_SCRIPT="$SCRIPT_DIR/postinstall" 10 | cat << EOF > "$POSTINSTALL_SCRIPT" 11 | #!/bin/bash 12 | 13 | # Sleep for a moment to ensure the package installation is complete 14 | sleep 1 15 | 16 | # Define paths 17 | APP_NAME="VoidNotes" 18 | TEMP_APP_PATH="/private/tmp/$APP_NAME.app" 19 | FINAL_APP_PATH="/Applications/$APP_NAME.app" 20 | 21 | # Remove existing app if it exists 22 | rm -rf "\$FINAL_APP_PATH" 23 | 24 | # Move the app to Applications folder 25 | mv "\$TEMP_APP_PATH" "\$FINAL_APP_PATH" 26 | 27 | # Fix permissions and signing 28 | chown -R \$USER:staff "\$FINAL_APP_PATH" 29 | chmod -R 755 "\$FINAL_APP_PATH" 30 | xattr -cr "\$FINAL_APP_PATH" 31 | codesign --force --deep --sign - "\$FINAL_APP_PATH" 32 | 33 | # Open the app 34 | open "\$FINAL_APP_PATH" 35 | 36 | exit 0 37 | EOF 38 | 39 | chmod +x "$POSTINSTALL_SCRIPT" 40 | 41 | # Build the .pkg 42 | mkdir -p "$SCRIPT_DIR/scripts" 43 | cp "$POSTINSTALL_SCRIPT" "$SCRIPT_DIR/scripts/postinstall" 44 | 45 | pkgbuild --root "$APP_PATH" \ 46 | --identifier com.void.voidnotes \ 47 | --version 1.0 \ 48 | --install-location "/private/tmp/$APP_NAME.app" \ 49 | --scripts "$SCRIPT_DIR/scripts" \ 50 | "$PKG_PATH" 51 | 52 | if [ -f "$PKG_PATH" ]; then 53 | echo "Installer package created successfully at $PKG_PATH" 54 | echo "Users can now install by running the .pkg file." 55 | else 56 | echo "Failed to create installer package" 57 | exit 1 58 | fi 59 | 60 | # Run this command to build the app and create the PKG after you wail build 61 | # cd /Users/void/docs/GitHub/VoidNotes && ./scripts/create-pkg.sh 62 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | * { 2 | border: none !important; 3 | box-shadow: none !important; 4 | } 5 | 6 | .h-full { 7 | height: 100%; 8 | } 9 | 10 | .vertical-text { 11 | writing-mode: vertical-rl; 12 | text-orientation: mixed; 13 | transform: rotate(180deg); 14 | white-space: nowrap; 15 | } 16 | 17 | ::-webkit-scrollbar { 18 | width: 8px; 19 | height: 8px; 20 | } 21 | 22 | ::-webkit-scrollbar-track { 23 | background: rgba(125, 50, 95, 0.1); 24 | border-radius: 4px; 25 | } 26 | 27 | ::-webkit-scrollbar-thumb { 28 | background: rgba(125, 50, 95, 0.4); 29 | border-radius: 4px; 30 | } 31 | 32 | ::-webkit-scrollbar-thumb:hover { 33 | background: rgba(125, 50, 95, 0.6); 34 | } 35 | 36 | .scrollbar-custom::-webkit-scrollbar { 37 | width: 8px; 38 | height: 8px; 39 | } 40 | 41 | .scrollbar-custom::-webkit-scrollbar-track { 42 | background: var(--scrollbar-track-color, transparent); 43 | border-radius: 4px; 44 | } 45 | 46 | .scrollbar-custom::-webkit-scrollbar-thumb { 47 | background: var(--scrollbar-thumb-color, rgba(100, 40, 80, 0.8)); 48 | border-radius: 4px; 49 | } 50 | 51 | .scrollbar-custom::-webkit-scrollbar-thumb:hover { 52 | background: var(--scrollbar-thumb-color, rgba(100, 40, 80, 1)); 53 | opacity: 1; 54 | } 55 | 56 | .scrollbar-custom { 57 | scrollbar-width: thin; 58 | scrollbar-color: var(--scrollbar-thumb-color, rgba(100, 40, 80, 0.8)) var(--scrollbar-track-color, transparent); 59 | } 60 | 61 | .app { 62 | height: 100vh; 63 | width: 100vw; 64 | background: transparent !important; 65 | border: none !important; 66 | box-shadow: none !important; 67 | } 68 | 69 | body, html { 70 | margin: 0; 71 | padding: 0; 72 | background: transparent !important; 73 | border: none !important; 74 | box-shadow: none !important; 75 | } 76 | 77 | #root { 78 | height: 100vh; 79 | width: 100vw; 80 | background: transparent !important; 81 | overflow: hidden; 82 | border: none !important; 83 | box-shadow: none !important; 84 | } 85 | 86 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "testing-ui-go", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "setup": "bun install" 11 | }, 12 | "dependencies": { 13 | "@tiptap/extension-blockquote": "^3.0.0", 14 | "@tiptap/extension-bullet-list": "^3.0.0", 15 | "@tiptap/extension-code-block": "^3.0.0", 16 | "@tiptap/extension-code-block-lowlight": "^3.0.0", 17 | "@tiptap/extension-color": "^3.0.0", 18 | "@tiptap/extension-dropcursor": "^3.0.0", 19 | "@tiptap/extension-font-family": "^3.0.0", 20 | "@tiptap/extension-heading": "^3.0.0", 21 | "@tiptap/extension-highlight": "^3.0.0", 22 | "@tiptap/extension-horizontal-rule": "^3.0.0", 23 | "@tiptap/extension-image": "^3.0.0", 24 | "@tiptap/extension-link": "^3.0.0", 25 | "@tiptap/extension-ordered-list": "^3.0.0", 26 | "@tiptap/extension-placeholder": "^3.0.0", 27 | "@tiptap/extension-subscript": "^3.0.0", 28 | "@tiptap/extension-superscript": "^3.0.0", 29 | "@tiptap/extension-table": "^3.0.0", 30 | "@tiptap/extension-table-cell": "^3.0.0", 31 | "@tiptap/extension-table-header": "^3.0.0", 32 | "@tiptap/extension-table-row": "^3.0.0", 33 | "@tiptap/extension-task-item": "^3.0.0", 34 | "@tiptap/extension-task-list": "^3.0.0", 35 | "@tiptap/extension-text-style": "^3.0.0", 36 | "@tiptap/extension-underline": "^3.0.0", 37 | "@tiptap/react": "^3.0.0", 38 | "@tiptap/starter-kit": "^3.0.0", 39 | "@uiw/react-md-editor": "^4.0.5", 40 | "highlight.js": "^11.11.1", 41 | "lowlight": "^3.3.0", 42 | "react": "^19.0.0", 43 | "react-dom": "^19.0.0", 44 | "react-markdown": "^10.1.0", 45 | "refractor": "^5.0.0", 46 | "rehype-sanitize": "^6.0.0", 47 | "remark-gfm": "^4.0.1" 48 | }, 49 | "devDependencies": { 50 | "@tailwindcss/typography": "^0.5.16", 51 | "@types/react": "^19.0.11", 52 | "@types/react-dom": "^19.0.4", 53 | "@vitejs/plugin-react": "^4.3.4", 54 | "autoprefixer": "^10.4.21", 55 | "postcss": "^8.5.3", 56 | "tailwindcss": "^3.4.17", 57 | "typescript": "^5.8.2", 58 | "vite": "^6.2.2" 59 | }, 60 | "author": "xptea" 61 | } -------------------------------------------------------------------------------- /frontend/src/components/update/AutoUpdate.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import localVersion from '../../version.txt?raw'; 3 | 4 | interface AutoUpdateHookProps { 5 | onUpdate?: () => void; 6 | } 7 | 8 | export const useAutoUpdate = ({ onUpdate }: AutoUpdateHookProps = {}) => { 9 | const [currentVersion] = useState(localVersion.trim()); 10 | const [latestVersion, setLatestVersion] = useState(localVersion.trim()); 11 | const [isUpdateAvailable, setIsUpdateAvailable] = useState(false); 12 | const [isChecking, setIsChecking] = useState(false); 13 | const [lastChecked, setLastChecked] = useState(null); 14 | 15 | const fetchLatestVersion = async () => { 16 | try { 17 | setIsChecking(true); 18 | const response = await fetch('https://raw.githubusercontent.com/xptea/VoidNotes/refs/heads/main/frontend/src/version.txt', { cache: 'no-store' }); 19 | if (response.ok) { 20 | const remoteVersion = await response.text(); 21 | setLatestVersion(remoteVersion.trim()); 22 | 23 | const isNewer = compareVersions(remoteVersion.trim(), currentVersion); 24 | setIsUpdateAvailable(isNewer); 25 | } 26 | } catch (error) { 27 | console.error('Failed to check for updates:', error); 28 | } finally { 29 | setIsChecking(false); 30 | setLastChecked(new Date()); 31 | } 32 | }; 33 | 34 | const compareVersions = (v1: string, v2: string): boolean => { 35 | const v1Parts = v1.replace('v', '').split('.').map(Number); 36 | const v2Parts = v2.replace('v', '').split('.').map(Number); 37 | 38 | for (let i = 0; i < v1Parts.length; i++) { 39 | if (v1Parts[i] > v2Parts[i]) return true; 40 | if (v1Parts[i] < v2Parts[i]) return false; 41 | } 42 | 43 | return false; 44 | }; 45 | 46 | useEffect(() => { 47 | fetchLatestVersion(); 48 | 49 | const interval = setInterval(fetchLatestVersion, 60 * 60 * 1000); 50 | 51 | return () => clearInterval(interval); 52 | }, []); 53 | 54 | const handleUpdate = () => { 55 | if (onUpdate) { 56 | onUpdate(); 57 | } else { 58 | window.open('https://github.com/xptea/VoidNotes/releases', '_blank'); 59 | } 60 | }; 61 | 62 | return { 63 | currentVersion, 64 | latestVersion, 65 | isUpdateAvailable, 66 | isChecking, 67 | lastChecked, 68 | checkForUpdates: fetchLatestVersion, 69 | handleUpdate 70 | }; 71 | }; 72 | 73 | export default useAutoUpdate; -------------------------------------------------------------------------------- /scripts/dmg-background.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 44 | Drag VoidNotes into the Applications folder to install 45 | 46 | 47 | 48 | 49 | 52 | 56 | 57 | 58 | 59 | 60 | 64 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "log" 6 | "runtime" 7 | 8 | "github.com/wailsapp/wails/v2" 9 | "github.com/wailsapp/wails/v2/pkg/logger" 10 | "github.com/wailsapp/wails/v2/pkg/options" 11 | "github.com/wailsapp/wails/v2/pkg/options/assetserver" 12 | "github.com/wailsapp/wails/v2/pkg/options/mac" 13 | "github.com/wailsapp/wails/v2/pkg/options/windows" 14 | ) 15 | 16 | //go:embed all:frontend/dist 17 | var assets embed.FS 18 | 19 | //go:embed build/appicon.png 20 | var icon []byte 21 | 22 | func main() { 23 | app := NewApp() 24 | 25 | err := wails.Run(&options.App{ 26 | Title: "VoidNotes", 27 | Width: 1200, 28 | Height: 800, 29 | MinWidth: 500, 30 | MinHeight: 500, 31 | DisableResize: false, 32 | Fullscreen: false, 33 | Frameless: runtime.GOOS == "windows", 34 | StartHidden: false, 35 | HideWindowOnClose: false, 36 | BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 0}, 37 | AssetServer: &assetserver.Options{ 38 | Assets: assets, 39 | }, 40 | Menu: nil, 41 | Logger: nil, 42 | LogLevel: logger.DEBUG, 43 | OnStartup: app.startup, 44 | OnDomReady: app.domReady, 45 | OnBeforeClose: app.beforeClose, 46 | OnShutdown: app.shutdown, 47 | WindowStartState: options.Normal, 48 | Bind: []interface{}{ 49 | app, 50 | }, 51 | Windows: &windows.Options{ 52 | WebviewIsTransparent: true, 53 | WindowIsTranslucent: true, 54 | DisableWindowIcon: false, 55 | DisableFramelessWindowDecorations: false, 56 | BackdropType: windows.None, 57 | Theme: windows.SystemDefault, 58 | CustomTheme: &windows.ThemeSettings{ 59 | DarkModeTitleBar: windows.RGB(0, 0, 0), 60 | DarkModeTitleText: windows.RGB(255, 255, 255), 61 | DarkModeBorder: windows.RGB(0, 0, 0), 62 | LightModeTitleBar: windows.RGB(0, 0, 0), 63 | LightModeTitleText: windows.RGB(255, 255, 255), 64 | LightModeBorder: windows.RGB(0, 0, 0), 65 | }, 66 | }, 67 | Mac: &mac.Options{ 68 | TitleBar: &mac.TitleBar{ 69 | TitlebarAppearsTransparent: true, 70 | HideTitle: true, 71 | HideTitleBar: false, 72 | FullSizeContent: true, 73 | UseToolbar: false, 74 | HideToolbarSeparator: true, 75 | }, 76 | Appearance: mac.NSAppearanceNameDarkAqua, 77 | WebviewIsTransparent: true, 78 | WindowIsTranslucent: true, 79 | About: &mac.AboutInfo{ 80 | Title: "VoidNotes", 81 | Message: "A simple note taking app", 82 | Icon: icon, 83 | }, 84 | }, 85 | }) 86 | 87 | if err != nil { 88 | log.Fatal(err) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /frontend/src/components/modals/MainModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | export interface InputModalProps { 4 | isOpen: boolean; 5 | onClose: () => void; 6 | onConfirm: (value: string) => void; 7 | title: string; 8 | message?: string; 9 | placeholder?: string; 10 | confirmText?: string; 11 | initialValue?: string; 12 | inputType?: 'text' | 'url'; 13 | showInput?: boolean; 14 | cancelText?: string; 15 | } 16 | 17 | const InputModal: React.FC = ({ 18 | isOpen, 19 | onClose, 20 | onConfirm, 21 | title, 22 | message, 23 | placeholder = '', 24 | confirmText = 'Confirm', 25 | initialValue = '', 26 | inputType = 'text', 27 | showInput = true, 28 | cancelText = 'Cancel' 29 | }) => { 30 | const [value, setValue] = useState(initialValue); 31 | 32 | if (!isOpen) return null; 33 | 34 | const handleSubmit = (e: React.FormEvent) => { 35 | e.preventDefault(); 36 | if (showInput) { 37 | if (value.trim()) { 38 | onConfirm(value.trim()); 39 | setValue(''); 40 | } 41 | } else { 42 | onConfirm(''); 43 | } 44 | }; 45 | 46 | const handleKeyDown = (e: React.KeyboardEvent) => { 47 | if (e.key === 'Escape') { 48 | onClose(); 49 | } 50 | }; 51 | 52 | return ( 53 |
54 |
55 |

{title}

56 | 57 | {message && ( 58 |

{message}

59 | )} 60 | 61 | {showInput && ( 62 | setValue(e.target.value)} 66 | onKeyDown={handleKeyDown} 67 | className="w-full bg-white/15 border border-white/20 rounded-md px-3 py-2 text-sm text-white placeholder-white/50 focus:outline-none focus:ring-1 focus:ring-white/50 mb-6" 68 | placeholder={placeholder} 69 | autoFocus 70 | /> 71 | )} 72 | 73 |
74 | 81 | 87 |
88 |
89 |
90 | ); 91 | }; 92 | 93 | export default InputModal; -------------------------------------------------------------------------------- /frontend/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | 2 | /** @type {import('tailwindcss').Config} */ 3 | module.exports = { 4 | content: [ 5 | "./src/**/*.{js,jsx,ts,tsx}", 6 | ], 7 | theme: { 8 | extend: { 9 | typography: (theme) => ({ 10 | invert: { 11 | css: { 12 | '--tw-prose-body': theme('colors.white / 90'), 13 | '--tw-prose-headings': theme('colors.white'), 14 | '--tw-prose-links': theme('colors.blue.400'), 15 | '--tw-prose-bold': theme('colors.white'), 16 | '--tw-prose-quotes': theme('colors.white / 90'), 17 | '--tw-prose-code': theme('colors.white'), 18 | '--tw-prose-hr': theme('colors.white / 20'), 19 | '--tw-prose-th-borders': theme('colors.white / 20'), 20 | '--tw-prose-td-borders': theme('colors.white / 10'), 21 | 'H1': { 22 | marginTop: '0.1em', 23 | 24 | marginBottom: '1rem', 25 | }, 26 | 'H2': { 27 | marginTop: '0.1em', 28 | 29 | marginBottom: '1rem', 30 | }, 31 | 'H3': { 32 | marginTop: '0.1em', 33 | 34 | marginBottom: '1rem', 35 | }, 36 | 'p': { 37 | marginTop: '0.1em', 38 | marginBottom: '0.1em', 39 | }, 40 | 'table': { 41 | backgroundColor: 'rgba(255, 255, 255, 0.05)', 42 | borderRadius: '0.5rem', 43 | overflow: 'hidden', 44 | }, 45 | 'th': { 46 | backgroundColor: 'rgba(255, 255, 255, 0.1)', 47 | padding: '0.75rem', 48 | }, 49 | 'td': { 50 | padding: '0.75rem', 51 | }, 52 | 'ul[data-type="taskList"]': { 53 | listStyle: 'none', 54 | padding: 0, 55 | }, 56 | 'ul[data-type="taskList"] li': { 57 | display: 'flex', 58 | alignItems: 'center', 59 | gap: '0.5rem', 60 | }, 61 | 'ul[data-type="taskList"] input[type="checkbox"]': { 62 | width: '1rem', 63 | height: '1rem', 64 | borderRadius: '0.25rem', 65 | backgroundColor: 'rgba(255, 255, 255, 0.1)', 66 | border: '1px solid rgba(255, 255, 255, 0.2)', 67 | }, 68 | 'blockquote': { 69 | borderLeftColor: theme('colors.blue.500'), 70 | backgroundColor: 'rgba(255, 255, 255, 0.05)', 71 | borderRadius: '0.25rem', 72 | }, 73 | 'img': { 74 | borderRadius: '0.5rem', 75 | maxHeight: '20rem', 76 | objectFit: 'contain', 77 | }, 78 | 'hr': { 79 | margin: '2rem 0', 80 | } 81 | } 82 | } 83 | }) 84 | }, 85 | }, 86 | plugins: [ 87 | require('@tailwindcss/typography'), 88 | ], 89 | } 90 | -------------------------------------------------------------------------------- /frontend/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { WindowMinimise, WindowToggleMaximise, Quit, EventsOn, EventsOff } from '../../wailsjs/runtime/runtime.js'; 2 | import { useEffect, useState } from 'react'; 3 | import { useSettings } from '../contexts/SettingsContext.js'; 4 | 5 | interface HeaderProps { 6 | title?: string; 7 | } 8 | 9 | const Header: React.FC = ({ title = "VoidWorks" }) => { 10 | const [isMaximized, setIsMaximized] = useState(false); 11 | const { getHeaderStyle } = useSettings(); 12 | 13 | useEffect(() => { 14 | const unsubscribeMaximize = EventsOn('wails:window-maximised', () => { 15 | setIsMaximized(true); 16 | }); 17 | 18 | const unsubscribeRestore = EventsOn('wails:window-normal', () => { 19 | setIsMaximized(false); 20 | }); 21 | 22 | return () => { 23 | unsubscribeMaximize(); 24 | unsubscribeRestore(); 25 | EventsOff('wails:window-maximised'); 26 | EventsOff('wails:window-normal'); 27 | }; 28 | }, []); 29 | 30 | const handleMinimize = () => { 31 | WindowMinimise(); 32 | }; 33 | 34 | const handleMaximize = () => { 35 | WindowToggleMaximise(); 36 | }; 37 | 38 | const handleClose = () => { 39 | Quit(); 40 | }; 41 | 42 | return ( 43 |
54 |
{title}
55 |
56 | 64 | 65 | 80 | 81 | 89 |
90 |
91 | ); 92 | }; 93 | 94 | export default Header; 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VoidNotes 2 | https://notes.voidworks.xyz/ 3 | 4 | A modern, lightweight, and customizable note-taking application built with Go and React. VoidNotes combines elegant design with powerful features to help you organize your thoughts efficiently. 5 | 6 | ![image](https://github.com/user-attachments/assets/509d40c9-e06b-486b-94fa-64c60b62e65c) 7 | 8 | ## Features 9 | 10 | - 📝 Rich Text Editor 11 | - 🏷️ Note organization with tags 12 | - 🔍 Full-text search across notes and tags 13 | - 🎨 Fully customizable UI themes 14 | - 💨 Lightweight and fast performance 15 | - 🖥️ Cross-platform support (Windows & MacOS) 16 | - ⚡ Real-time saving 17 | - 📊 Word and character count 18 | - 📋 Task lists and tables support 19 | 20 | ## Installation 21 | 22 | 1. Go to the [releases page](https://github.com/xptea/VoidNotes/releases) 23 | 24 | ### Windows 25 | - Download and run the `voidnotes-amd64-installer.exe`, and then open it. 26 | 27 | ### Mac 28 | - Download and open the `VoidNotes_installer.pkg`. 29 | For Mac, since the installer is not signed, when you first open the file, you will have to click "Done". 30 | 31 | ![image](https://github.com/user-attachments/assets/143294cb-ca55-4ca3-9116-612cdbf1690e) 32 | 33 | After that, go to **Settings**, then **Privacy & Security**, and scroll all the way to the bottom. You'll find a pop-up like this: 34 | 35 | ![image](https://github.com/user-attachments/assets/354bd61a-0f19-4cd0-b77d-5306603b2c67) 36 | 37 | Click **Open Anyway** 38 | 39 | ![image](https://github.com/user-attachments/assets/ff8c589b-bfe1-4792-8870-4c4e2af1dfed) 40 | Lastly, click **Open Anyway** to continue to the installer 41 | - Alternatively, you can rebuild and compile the project yourself from source 42 | 43 | 44 | ## Usage 45 | 46 | ### Basic Navigation 47 | - Create new notes using the + button 48 | - Search notes using the search bar 49 | - Tag notes for better organization 50 | - Customize the UI colors in settings 51 | 52 | ### Editor Features 53 | - Rich text formatting 54 | - Code blocks with syntax highlighting 55 | - Task lists 56 | - Tables 57 | - Image embedding 58 | - Links 59 | 60 | ## Customization 61 | 62 | VoidNotes offers extensive UI customization options: 63 | 64 | - Sidebar color and opacity 65 | - Main area color and opacity 66 | - Header appearance 67 | - Custom scrollbars 68 | - Light/Dark theme support 69 | 70 | ![image](https://github.com/user-attachments/assets/ace0e33c-99fc-444b-b836-8dece14afb55) 71 | ![image](https://github.com/user-attachments/assets/0e1a6066-e1f4-43c4-b26e-600558c9d8bc) 72 | 73 | ## Roadmap 74 | 75 | ### 2025 76 | - [x] Auto Update 77 | - [ ] Search text in note 78 | - [ ] Image embedding support 79 | - [ ] Cloud sync support 80 | - [ ] Enhanced text styling options 81 | - [ ] Note templates 82 | - [ ] Export options (PDF, HTML, Txt) 83 | - [ ] Custom themes marketplace 84 | - [ ] Note version history 85 | - [ ] Note encryption 86 | 87 | We welcome contributions! If you have suggestions or want to contribute to the code: 88 | 89 | 1. Fork the repository 90 | 2. Create your feature branch 91 | 3. Commit your changes 92 | 4. Push to the branch 93 | 5. Create a Pull Request 94 | 95 | ## Support 96 | 97 | If you encounter any issues or have questions: 98 | 99 | - Open an issue on GitHub 100 | - Email support: void@terradream.games 101 | 102 | ## License 103 | 104 | VoidNotes is released under the MIT License. See the LICENSE file for more details. 105 | 106 | ## Credits 107 | 108 | Built with ❤️ by VoidWorks 109 | 110 | - [Wails](https://wails.io/) 111 | - [React](https://reactjs.org/) 112 | - [TipTap](https://tiptap.dev/) 113 | - [Tailwind CSS](https://tailwindcss.com/) 114 | 115 | --- 116 | 117 | **Note**: This is a continuously evolving project. Check back regularly for updates and new features! 118 | -------------------------------------------------------------------------------- /frontend/src/components/NoteEditor/StatusBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Note } from '../../contexts/NotesContext.js'; 3 | 4 | interface StatusBarProps { 5 | note: Note; 6 | wordCount: number; 7 | charCount: number; 8 | isSaving: boolean; 9 | appDataDir?: string; 10 | } 11 | 12 | export const StatusBar: React.FC = ({ wordCount, charCount, isSaving, appDataDir }) => { 13 | const [showSaved, setShowSaved] = useState(false); 14 | const [isHovering, setIsHovering] = useState(false); 15 | 16 | useEffect(() => { 17 | let timer: ReturnType | null = null; 18 | 19 | if (!isSaving && !showSaved) { 20 | setShowSaved(true); 21 | timer = setTimeout(() => { 22 | setShowSaved(false); 23 | }, 1500); 24 | } 25 | 26 | return () => { 27 | if (timer) clearTimeout(timer); 28 | }; 29 | }, [isSaving]); 30 | 31 | return ( 32 |
33 |
setIsHovering(true)} 38 | onMouseLeave={() => setIsHovering(false)} 39 | > 40 | {isSaving ? ( 41 | <> 42 | 43 | 44 | 45 | 46 | Saving... 47 | 48 | ) : showSaved || isHovering ? ( 49 | <> 50 | 51 | 52 | 53 | {showSaved || isHovering ? Saved : null} 54 | 55 | ) : ( 56 | 57 | 58 | 59 | )} 60 | 61 | {/* Storage location tooltip - positioned outside the parent element */} 62 | {isHovering && appDataDir && ( 63 |
64 |
Notes are stored at:
65 |
{appDataDir}/notes
66 |
67 | )} 68 |
69 | 70 |
71 |
72 | 73 | 74 | W 75 | 76 | 77 | {wordCount} 78 |
79 | 80 |
81 | 82 | 83 | C 84 | 85 | 86 | {charCount} 87 |
88 |
89 |
90 | ); 91 | }; -------------------------------------------------------------------------------- /frontend/src/contexts/SettingsContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState, useEffect, CSSProperties} from 'react'; 2 | 3 | 4 | export interface CustomCSSProperties extends CSSProperties { 5 | '--scrollbar-thumb-color'?: string; 6 | '--scrollbar-track-color'?: string; 7 | } 8 | interface AppSettings { 9 | sidebarColor: string; 10 | sidebarOpacity: number; 11 | mainAreaColor: string; 12 | mainAreaOpacity: number; 13 | headerColor: string; 14 | headerOpacity: number; 15 | } 16 | 17 | interface SettingsContextType { 18 | settings: AppSettings; 19 | updateSettings: (settings: Partial) => void; 20 | isSettingsOpen: boolean; 21 | toggleSettings: () => void; 22 | getSidebarStyle: () => React.CSSProperties; 23 | getMainAreaStyle: () => React.CSSProperties; 24 | getHeaderStyle: () => React.CSSProperties; 25 | getScrollbarStyle: () => CustomCSSProperties; 26 | } 27 | 28 | const defaultSettings: AppSettings = { 29 | sidebarColor: "125, 50, 95", 30 | sidebarOpacity: 0.85, 31 | mainAreaColor: "125, 50, 95", 32 | mainAreaOpacity: 0.85, 33 | headerColor: "125, 50, 95", 34 | headerOpacity: 0.85, 35 | }; 36 | 37 | const SettingsContext = createContext({ 38 | settings: defaultSettings, 39 | updateSettings: () => {}, 40 | isSettingsOpen: false, 41 | toggleSettings: () => {}, 42 | getSidebarStyle: () => ({}), 43 | getMainAreaStyle: () => ({}), 44 | getHeaderStyle: () => ({}), 45 | getScrollbarStyle: () => ({} as CustomCSSProperties), 46 | }); 47 | 48 | export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { 49 | const [settings, setSettings] = useState(() => { 50 | const savedSettings = localStorage.getItem('voidnotes-settings'); 51 | return savedSettings ? JSON.parse(savedSettings) : defaultSettings; 52 | }); 53 | 54 | const [isSettingsOpen, setIsSettingsOpen] = useState(false); 55 | 56 | useEffect(() => { 57 | localStorage.setItem('voidnotes-settings', JSON.stringify(settings)); 58 | }, [settings]); 59 | 60 | const updateSettings = (newSettings: Partial) => { 61 | setSettings(prev => ({ ...prev, ...newSettings })); 62 | }; 63 | 64 | const toggleSettings = () => { 65 | setIsSettingsOpen(prev => !prev); 66 | }; 67 | 68 | const getSidebarStyle = (): React.CSSProperties => { 69 | return { 70 | backgroundColor: `rgba(${settings.sidebarColor}, ${settings.sidebarOpacity})`, 71 | backdropFilter: "blur(20px)", 72 | WebkitBackdropFilter: "blur(20px)", 73 | background: `rgba(${settings.sidebarColor}, ${settings.sidebarOpacity})`, 74 | }; 75 | }; 76 | 77 | const getMainAreaStyle = (): React.CSSProperties => { 78 | return { 79 | backgroundColor: `rgba(${settings.mainAreaColor}, ${settings.mainAreaOpacity})`, 80 | backdropFilter: "blur(20px)", 81 | WebkitBackdropFilter: "blur(20px)", 82 | background: `rgba(${settings.mainAreaColor}, ${settings.mainAreaOpacity})`, 83 | }; 84 | }; 85 | 86 | const getHeaderStyle = (): React.CSSProperties => { 87 | return { 88 | backgroundColor: `rgba(${settings.headerColor}, ${settings.headerOpacity})`, 89 | backdropFilter: "blur(20px)", 90 | WebkitBackdropFilter: "blur(20px)", 91 | background: `rgba(${settings.headerColor}, ${settings.headerOpacity})`, 92 | }; 93 | }; 94 | 95 | const darkenColor = (rgb: string, factor: number = 0.7): string => { 96 | const [r, g, b] = rgb.split(',').map(val => Math.floor(Number(val) * factor)); 97 | return `${r}, ${g}, ${b}`; 98 | }; 99 | 100 | const getScrollbarStyle = (): CustomCSSProperties => { 101 | const darkerColor = darkenColor(settings.sidebarColor, 0.6); 102 | 103 | return { 104 | '--scrollbar-thumb-color': `rgba(${darkerColor}, 0.8)`, 105 | '--scrollbar-track-color': 'transparent' 106 | }; 107 | }; 108 | 109 | return ( 110 | 122 | {children} 123 | 124 | ); 125 | }; 126 | 127 | export const useSettings = () => useContext(SettingsContext); -------------------------------------------------------------------------------- /frontend/src/components/utils/tiptapExtensions.ts: -------------------------------------------------------------------------------- 1 | import BulletList from '@tiptap/extension-bullet-list'; 2 | import OrderedList from '@tiptap/extension-ordered-list'; 3 | import Link from '@tiptap/extension-link'; 4 | import Underline from '@tiptap/extension-underline'; 5 | import TextStyle from '@tiptap/extension-text-style'; 6 | import Color from '@tiptap/extension-color'; 7 | import Highlight from '@tiptap/extension-highlight'; 8 | import FontFamily from '@tiptap/extension-font-family'; 9 | import Superscript from '@tiptap/extension-superscript'; 10 | import Subscript from '@tiptap/extension-subscript'; 11 | import Table from '@tiptap/extension-table'; 12 | import TableRow from '@tiptap/extension-table-row'; 13 | import TableCell from '@tiptap/extension-table-cell'; 14 | import TableHeader from '@tiptap/extension-table-header'; 15 | import TaskList from '@tiptap/extension-task-list'; 16 | import TaskItem from '@tiptap/extension-task-item'; 17 | import Blockquote from '@tiptap/extension-blockquote'; 18 | import Dropcursor from '@tiptap/extension-dropcursor'; 19 | import Image from '@tiptap/extension-image'; 20 | import HorizontalRule from '@tiptap/extension-horizontal-rule'; 21 | import { Extension } from '@tiptap/core'; 22 | import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; 23 | 24 | import { common, createLowlight } from 'lowlight'; 25 | const lowlight = createLowlight(common); 26 | import javascript from 'highlight.js/lib/languages/javascript'; 27 | import typescript from 'highlight.js/lib/languages/typescript'; 28 | import python from 'highlight.js/lib/languages/python'; 29 | import css from 'highlight.js/lib/languages/css'; 30 | import xml from 'highlight.js/lib/languages/xml'; 31 | import json from 'highlight.js/lib/languages/json'; 32 | import bash from 'highlight.js/lib/languages/bash'; 33 | import go from 'highlight.js/lib/languages/go'; 34 | import rust from 'highlight.js/lib/languages/rust'; 35 | import cpp from 'highlight.js/lib/languages/cpp'; 36 | import java from 'highlight.js/lib/languages/java'; 37 | import csharp from 'highlight.js/lib/languages/csharp'; 38 | 39 | lowlight.register('javascript', javascript); 40 | lowlight.register('typescript', typescript); 41 | lowlight.register('python', python); 42 | lowlight.register('css', css); 43 | lowlight.register('html', xml); 44 | lowlight.register('json', json); 45 | lowlight.register('bash', bash); 46 | lowlight.register('go', go); 47 | lowlight.register('rust', rust); 48 | lowlight.register('cpp', cpp); 49 | lowlight.register('java', java); 50 | lowlight.register('csharp', csharp); 51 | 52 | const DisableSpellcheckInCode = Extension.create({ 53 | name: 'disableSpellcheckInCode', 54 | addGlobalAttributes() { 55 | return [ 56 | { 57 | types: ['codeBlock', 'code'], 58 | attributes: { 59 | spellcheck: { 60 | default: 'false', 61 | parseHTML: (element: { getAttribute: (arg0: string) => any; }) => element.getAttribute('spellcheck') || 'false', 62 | renderHTML: () => { 63 | return { 64 | spellcheck: 'false', 65 | } 66 | }, 67 | }, 68 | }, 69 | }, 70 | ] 71 | }, 72 | }); 73 | 74 | const CodeBlock = CodeBlockLowlight.configure({ 75 | lowlight, 76 | defaultLanguage: 'javascript', 77 | HTMLAttributes: { 78 | class: 'hljs', 79 | spellcheck: 'false', 80 | } 81 | }).extend({ 82 | addNodeView() { 83 | return ({ node }) => { 84 | const dom = document.createElement('pre'); 85 | 86 | dom.setAttribute('spellcheck', 'false'); 87 | 88 | dom.classList.add('hljs'); 89 | 90 | const content = document.createElement('code'); 91 | content.setAttribute('spellcheck', 'false'); 92 | 93 | if (node.attrs.language) { 94 | content.classList.add(`language-${node.attrs.language}`); 95 | } 96 | 97 | dom.append(content); 98 | 99 | return { 100 | dom, 101 | contentDOM: content, 102 | }; 103 | }; 104 | } 105 | }); 106 | 107 | export { 108 | BulletList, 109 | OrderedList, 110 | CodeBlock, 111 | DisableSpellcheckInCode, 112 | Link, 113 | Underline, 114 | TextStyle, 115 | Color, 116 | Highlight, 117 | FontFamily, 118 | Superscript, 119 | Subscript, 120 | Table, 121 | TableRow, 122 | TableCell, 123 | TableHeader, 124 | TaskList, 125 | TaskItem, 126 | Blockquote, 127 | Dropcursor, 128 | Image, 129 | HorizontalRule, 130 | }; 131 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, body { 6 | margin: 0; 7 | padding: 0; 8 | background: transparent !important; 9 | border: none !important; 10 | } 11 | 12 | #root { 13 | height: 100vh; 14 | width: 100vw; 15 | background: transparent !important; 16 | border: none !important; 17 | } 18 | 19 | * { 20 | box-sizing: border-box; 21 | } 22 | 23 | :root { 24 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 25 | line-height: 1.5; 26 | font-weight: 400; 27 | 28 | color-scheme: dark; 29 | background-color: rgb(15, 15, 15); 30 | font-synthesis: none; 31 | text-rendering: optimizeLegibility; 32 | -webkit-font-smoothing: antialiased; 33 | -moz-osx-font-smoothing: grayscale; 34 | background: transparent !important; 35 | border: none !important; 36 | } 37 | 38 | /* Custom Scrollbar Styles */ 39 | .scrollbar-custom::-webkit-scrollbar { 40 | width: 8px; 41 | height: 8px; 42 | } 43 | 44 | .scrollbar-custom::-webkit-scrollbar-track { 45 | background: var(--scrollbar-track-color); 46 | border-radius: 4px; 47 | } 48 | 49 | .scrollbar-custom::-webkit-scrollbar-thumb { 50 | background: var(--scrollbar-thumb-color); 51 | border-radius: 4px; 52 | } 53 | 54 | .scrollbar-custom::-webkit-scrollbar-corner { 55 | background: transparent; 56 | } 57 | 58 | /* TipTap Editor Styles */ 59 | .ProseMirror { 60 | height: 100%; 61 | overflow-y: auto; 62 | overflow-x: hidden; 63 | } 64 | 65 | .ProseMirror:focus { 66 | outline: none; 67 | } 68 | 69 | /* Table Styles */ 70 | .ProseMirror table { 71 | border-collapse: collapse; 72 | margin: 0; 73 | overflow: hidden; 74 | table-layout: fixed; 75 | width: 100%; 76 | } 77 | 78 | .ProseMirror td, 79 | .ProseMirror th { 80 | position: relative; 81 | min-width: 1em; 82 | border: 1px solid rgba(255, 255, 255, 0.1); 83 | margin: 0; 84 | padding: 3px 5px; 85 | vertical-align: top; 86 | box-sizing: border-box; 87 | position: relative; 88 | } 89 | 90 | .ProseMirror .selectedCell:after { 91 | z-index: 2; 92 | position: absolute; 93 | content: ""; 94 | left: 0; right: 0; top: 0; bottom: 0; 95 | background: rgba(200, 200, 255, 0.1); 96 | pointer-events: none; 97 | } 98 | 99 | /* Column resize handle */ 100 | .ProseMirror .column-resize-handle { 101 | position: absolute; 102 | right: -2px; 103 | top: 0; 104 | bottom: 0; 105 | width: 4px; 106 | background-color: rgba(255, 255, 255, 0.5); 107 | pointer-events: none; 108 | } 109 | 110 | .ProseMirror.resize-cursor { 111 | cursor: ew-resize; 112 | cursor: col-resize; 113 | } 114 | 115 | /* Task List Styles */ 116 | ul[data-type="taskList"] { 117 | list-style: none; 118 | padding: 0; 119 | } 120 | 121 | ul[data-type="taskList"] li { 122 | display: flex; 123 | align-items: flex-start; 124 | margin-bottom: 0.5em; 125 | } 126 | 127 | ul[data-type="taskList"] li > label { 128 | flex: 0 0 auto; 129 | margin-right: 0.5em; 130 | user-select: none; 131 | } 132 | 133 | ul[data-type="taskList"] li > div { 134 | flex: 1 1 auto; 135 | } 136 | 137 | /* Image Styles */ 138 | .ProseMirror img { 139 | max-width: 100%; 140 | height: auto; 141 | transition: filter 0.2s ease; 142 | } 143 | 144 | .ProseMirror img.ProseMirror-selectednode { 145 | outline: 2px solid #4299e1; 146 | filter: brightness(90%); 147 | } 148 | 149 | /* Placeholder Styles */ 150 | .ProseMirror p.is-editor-empty:first-child::before { 151 | color: rgba(255, 255, 255, 0.4); 152 | content: attr(data-placeholder); 153 | float: left; 154 | height: 0; 155 | pointer-events: none; 156 | } 157 | 158 | /* Custom Code Block Styling */ 159 | pre { 160 | padding: 1rem; 161 | border-radius: 0.5rem; 162 | background-color: rgba(22, 22, 30, 0.95) !important; 163 | overflow: auto; 164 | margin: 1rem 0; 165 | } 166 | 167 | pre, pre code { 168 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 169 | font-size: 0.9em; 170 | line-height: 1.5; 171 | } 172 | 173 | /* Force spellcheck to be disabled on code blocks */ 174 | pre, pre code { 175 | -webkit-spellcheck: false !important; 176 | } 177 | 178 | /* Ensure code block content is colored properly */ 179 | pre code:not(.hljs) { 180 | color: #e2e8f0; 181 | } 182 | 183 | /* Ensure code blocks have proper spacing */ 184 | .ProseMirror pre { 185 | margin: 1em 0; 186 | } 187 | 188 | /* Ensure code elements inherit colors from highlight.js */ 189 | .hljs { 190 | display: block; 191 | overflow-x: auto; 192 | padding: 1em; 193 | background: #1e1e1e !important; 194 | color: #dcdcdc; 195 | } 196 | 197 | /* Make sure padding is consistent */ 198 | .tiptap pre { 199 | padding: 1rem !important; 200 | } 201 | 202 | /* Ensure code blocks stand out in the editor */ 203 | .ProseMirror pre { 204 | background-color: rgba(22, 22, 30, 0.95) !important; 205 | border: 1px solid rgba(255, 255, 255, 0.1); 206 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /frontend/src/components/Settings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSettings } from '../contexts/SettingsContext.js'; 3 | import UpdateNotification from './update/UpdateNotification.js'; 4 | 5 | interface ColorOption { 6 | name: string; 7 | value: string; 8 | } 9 | 10 | const Settings: React.FC = () => { 11 | const { settings, updateSettings, isSettingsOpen, toggleSettings } = useSettings(); 12 | 13 | const colorOptions: ColorOption[] = [ 14 | { name: 'Purple', value: '125, 50, 95' }, 15 | { name: 'Blue', value: '50, 80, 125' }, 16 | { name: 'Green', value: '50, 125, 80' }, 17 | { name: 'Dark', value: '20, 20, 30' }, 18 | { name: 'Neutral', value: '60, 60, 75' } 19 | ]; 20 | 21 | if (!isSettingsOpen) { 22 | return null; 23 | } 24 | 25 | return ( 26 |
27 |
28 |
29 |
30 |

Settings

31 | 32 |
33 | 41 |
42 | 43 |
44 |
45 | 46 |
47 | {colorOptions.map((color) => ( 48 |
61 |
62 | 63 |
64 | 67 | updateSettings({ headerOpacity: parseInt(e.target.value) / 100 })} 73 | className="w-full bg-white/20 rounded-lg appearance-none h-2" 74 | /> 75 |
76 | 77 |
78 | 79 |
80 | {colorOptions.map((color) => ( 81 |
94 |
95 | 96 |
97 | 100 | updateSettings({ sidebarOpacity: parseInt(e.target.value) / 100 })} 106 | className="w-full bg-white/20 rounded-lg appearance-none h-2" 107 | /> 108 |
109 | 110 |
111 | 112 |
113 | {colorOptions.map((color) => ( 114 |
127 |
128 | 129 |
130 | 133 | updateSettings({ mainAreaOpacity: parseInt(e.target.value) / 100 })} 139 | className="w-full bg-white/20 rounded-lg appearance-none h-2" 140 | /> 141 |
142 |
143 |
144 |
145 | ); 146 | }; 147 | 148 | export default Settings; -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= 2 | github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= 6 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 7 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 8 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 9 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 10 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 11 | github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= 12 | github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= 13 | github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= 14 | github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= 15 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 16 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 17 | github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= 18 | github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= 19 | github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= 20 | github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= 21 | github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= 22 | github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= 23 | github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= 24 | github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= 25 | github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= 26 | github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= 27 | github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 28 | github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= 29 | github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 30 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 31 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 32 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 33 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 34 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 35 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= 36 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= 37 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 38 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 39 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 40 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 41 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 42 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 43 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 44 | github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= 45 | github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= 46 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 47 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 48 | github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= 49 | github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= 50 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 51 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 52 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 53 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 54 | github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU= 55 | github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= 56 | github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= 57 | github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= 58 | github.com/wailsapp/wails/v2 v2.10.1 h1:QWHvWMXII2nI/nXz77gpPG8P3ehl6zKe+u4su5BWIns= 59 | github.com/wailsapp/wails/v2 v2.10.1/go.mod h1:zrebnFV6MQf9kx8HI4iAv63vsR5v67oS7GTEZ7Pz1TY= 60 | golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= 61 | golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 62 | golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 63 | golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= 64 | golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 65 | golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 66 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 67 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 68 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 69 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 70 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 71 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 72 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 73 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 74 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 75 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 76 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 77 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 78 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 79 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 80 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "runtime" 11 | "time" 12 | ) 13 | 14 | type Note struct { 15 | ID string `json:"id"` 16 | Title string `json:"title"` 17 | Content string `json:"content"` 18 | CreatedAt string `json:"createdAt"` 19 | UpdatedAt string `json:"updatedAt"` 20 | Tags []string `json:"tags,omitempty"` 21 | } 22 | 23 | type App struct { 24 | ctx context.Context 25 | notesDir string 26 | appDataDir string 27 | logger *log.Logger 28 | } 29 | 30 | func NewApp() *App { 31 | return &App{} 32 | } 33 | 34 | func (a *App) startup(ctx context.Context) { 35 | a.ctx = ctx 36 | 37 | logFile, err := os.OpenFile(filepath.Join(os.TempDir(), "voidnotes.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) 38 | if err == nil { 39 | a.logger = log.New(logFile, "", log.LstdFlags) 40 | } else { 41 | a.logger = log.New(os.Stderr, "", log.LstdFlags) 42 | a.logger.Printf("Failed to open log file: %v", err) 43 | } 44 | 45 | a.logger.Println("App starting up...") 46 | 47 | if err := a.initAppDataDir(); err != nil { 48 | a.logger.Printf("Failed to initialize app data directory: %v", err) 49 | } else { 50 | a.logger.Printf("App data directory initialized: %s", a.appDataDir) 51 | a.logger.Printf("Notes directory: %s", a.notesDir) 52 | } 53 | } 54 | 55 | func (a *App) initAppDataDir() error { 56 | var appDataPath string 57 | 58 | if runtime.GOOS == "windows" { 59 | appDataPath = os.Getenv("LOCALAPPDATA") 60 | if appDataPath == "" { 61 | appDataPath = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local") 62 | } 63 | a.appDataDir = filepath.Join(appDataPath, "VoidNotes") 64 | } else if runtime.GOOS == "darwin" { 65 | homeDir, err := os.UserHomeDir() 66 | if err != nil { 67 | return err 68 | } 69 | a.appDataDir = filepath.Join(homeDir, "Library", "Application Support", "VoidNotes") 70 | } else { 71 | homeDir, err := os.UserHomeDir() 72 | if err != nil { 73 | return err 74 | } 75 | a.appDataDir = filepath.Join(homeDir, ".voidnotes") 76 | } 77 | 78 | if err := os.MkdirAll(a.appDataDir, 0755); err != nil { 79 | return err 80 | } 81 | 82 | a.notesDir = filepath.Join(a.appDataDir, "notes") 83 | if err := os.MkdirAll(a.notesDir, 0755); err != nil { 84 | return err 85 | } 86 | 87 | return nil 88 | } 89 | 90 | func (a *App) domReady(ctx context.Context) { 91 | a.logger.Println("DOM ready") 92 | } 93 | 94 | func (a *App) beforeClose(ctx context.Context) (prevent bool) { 95 | a.logger.Println("Application is closing") 96 | return false 97 | } 98 | 99 | func (a *App) shutdown(ctx context.Context) { 100 | a.logger.Println("Application shutting down") 101 | } 102 | 103 | func (a *App) Greet(name string) string { 104 | return fmt.Sprintf("Hello %s, It's show timee!", name) 105 | } 106 | 107 | func (a *App) GetAppDataDir() string { 108 | return a.appDataDir 109 | } 110 | 111 | func (a *App) GetNotesDir() string { 112 | return a.notesDir 113 | } 114 | 115 | func (a *App) SaveNote(noteData string) error { 116 | startTime := time.Now() 117 | if a.logger != nil { 118 | a.logger.Println("SaveNote called with data length:", len(noteData)) 119 | } 120 | 121 | var note Note 122 | if err := json.Unmarshal([]byte(noteData), ¬e); err != nil { 123 | if a.logger != nil { 124 | a.logger.Printf("Failed to unmarshal note: %v", err) 125 | } 126 | return fmt.Errorf("failed to unmarshal note: %w", err) 127 | } 128 | 129 | filename := filepath.Join(a.notesDir, note.ID+".json") 130 | if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil { 131 | if a.logger != nil { 132 | a.logger.Printf("Failed to create directory for note: %v", err) 133 | } 134 | return fmt.Errorf("failed to create directory: %w", err) 135 | } 136 | 137 | if a.logger != nil { 138 | a.logger.Printf("Saving note ID: %s, Title: %s, Content length: %d", 139 | note.ID, note.Title, len(note.Content)) 140 | a.logger.Printf("Writing to file: %s", filename) 141 | } 142 | 143 | tempFilename := filename + ".tmp" 144 | if err := os.WriteFile(tempFilename, []byte(noteData), 0644); err != nil { 145 | if a.logger != nil { 146 | a.logger.Printf("Failed to write temporary note file: %v", err) 147 | } 148 | return fmt.Errorf("failed to write temporary note file: %w", err) 149 | } 150 | 151 | data, err := os.ReadFile(tempFilename) 152 | if err != nil { 153 | if a.logger != nil { 154 | a.logger.Printf("Failed to read back temporary file: %v", err) 155 | } 156 | return fmt.Errorf("failed to verify temporary file: %w", err) 157 | } 158 | 159 | if len(data) != len(noteData) { 160 | if a.logger != nil { 161 | a.logger.Printf("Verification failed: file size mismatch. Expected %d, got %d", 162 | len(noteData), len(data)) 163 | } 164 | return fmt.Errorf("file size mismatch after writing") 165 | } 166 | 167 | if err := os.Rename(tempFilename, filename); err != nil { 168 | if a.logger != nil { 169 | a.logger.Printf("Failed to rename temporary file: %v", err) 170 | } 171 | return fmt.Errorf("failed to finalize note file: %w", err) 172 | } 173 | 174 | if a.logger != nil { 175 | a.logger.Printf("Note saved successfully. ID: %s, Time taken: %v", 176 | note.ID, time.Since(startTime)) 177 | } 178 | 179 | fi, err := os.Stat(filename) 180 | if err != nil { 181 | if a.logger != nil { 182 | a.logger.Printf("Failed to stat the saved file: %v", err) 183 | } 184 | return fmt.Errorf("saved file cannot be accessed: %w", err) 185 | } 186 | 187 | if fi.Size() != int64(len(noteData)) { 188 | if a.logger != nil { 189 | a.logger.Printf("Final verification failed: file size mismatch. Expected %d, got %d", 190 | len(noteData), fi.Size()) 191 | } 192 | return fmt.Errorf("final file size mismatch") 193 | } 194 | 195 | return nil 196 | } 197 | 198 | func (a *App) LoadNotes() (string, error) { 199 | startTime := time.Now() 200 | if a.logger != nil { 201 | a.logger.Println("LoadNotes called") 202 | } 203 | 204 | files, err := os.ReadDir(a.notesDir) 205 | if err != nil { 206 | if a.logger != nil { 207 | a.logger.Printf("Failed to read notes directory: %v", err) 208 | } 209 | return "", fmt.Errorf("failed to read notes directory: %w", err) 210 | } 211 | 212 | notes := []Note{} 213 | 214 | if a.logger != nil { 215 | a.logger.Printf("Found %d files in notes directory", len(files)) 216 | } 217 | 218 | for _, file := range files { 219 | if file.IsDir() || filepath.Ext(file.Name()) != ".json" { 220 | continue 221 | } 222 | 223 | path := filepath.Join(a.notesDir, file.Name()) 224 | content, err := os.ReadFile(path) 225 | if err != nil { 226 | if a.logger != nil { 227 | a.logger.Printf("Failed to read file %s: %v", file.Name(), err) 228 | } 229 | continue 230 | } 231 | 232 | var note Note 233 | if err := json.Unmarshal(content, ¬e); err != nil { 234 | if a.logger != nil { 235 | a.logger.Printf("Failed to parse note file %s: %v", file.Name(), err) 236 | } 237 | continue 238 | } 239 | 240 | if a.logger != nil { 241 | a.logger.Printf("Loaded note ID: %s, Title: %s", note.ID, note.Title) 242 | } 243 | 244 | notes = append(notes, note) 245 | } 246 | 247 | notesJSON, err := json.Marshal(notes) 248 | if err != nil { 249 | if a.logger != nil { 250 | a.logger.Printf("Failed to marshal notes: %v", err) 251 | } 252 | return "", fmt.Errorf("failed to marshal notes: %w", err) 253 | } 254 | 255 | if a.logger != nil { 256 | a.logger.Printf("Loaded %d notes. Time taken: %v", len(notes), time.Since(startTime)) 257 | } 258 | 259 | return string(notesJSON), nil 260 | } 261 | 262 | func (a *App) DeleteNote(noteID string) error { 263 | if a.logger != nil { 264 | a.logger.Printf("DeleteNote called for ID: %s", noteID) 265 | } 266 | 267 | filename := filepath.Join(a.notesDir, noteID+".json") 268 | 269 | if err := os.Remove(filename); err != nil { 270 | if a.logger != nil { 271 | a.logger.Printf("Failed to delete note %s: %v", noteID, err) 272 | } 273 | return fmt.Errorf("failed to delete note: %w", err) 274 | } 275 | 276 | if a.logger != nil { 277 | a.logger.Printf("Note %s deleted successfully", noteID) 278 | } 279 | 280 | return nil 281 | } 282 | -------------------------------------------------------------------------------- /frontend/src/contexts/NotesContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState, useEffect, useRef } from 'react'; 2 | import { LoadNotes, SaveNote, DeleteNote } from '../../wailsjs/go/main/App.js'; 3 | 4 | export interface Note { 5 | id: string; 6 | title: string; 7 | content: string; 8 | createdAt: Date; 9 | updatedAt: Date; 10 | tags?: string[]; 11 | } 12 | 13 | interface NotesContextType { 14 | notes: Note[]; 15 | filteredNotes: Note[]; 16 | activeNoteId: string | null; 17 | searchQuery: string; 18 | setSearchQuery: (query: string) => void; 19 | setActiveNoteId: (id: string | null) => void; 20 | addNote: () => void; 21 | updateNote: (note: Note) => Promise; 22 | deleteNote: (id: string) => void; 23 | getActiveNote: () => Note | null; 24 | isLoading: boolean; 25 | saveStatus: { 26 | isSaving: boolean; 27 | lastSavedId: string | null; 28 | error: string | null; 29 | }; 30 | } 31 | 32 | const NotesContext = createContext({ 33 | notes: [], 34 | filteredNotes: [], 35 | activeNoteId: null, 36 | searchQuery: '', 37 | setSearchQuery: () => {}, 38 | setActiveNoteId: () => {}, 39 | addNote: () => {}, 40 | updateNote: async () => false, 41 | deleteNote: () => {}, 42 | getActiveNote: () => null, 43 | isLoading: false, 44 | saveStatus: { 45 | isSaving: false, 46 | lastSavedId: null, 47 | error: null 48 | } 49 | }); 50 | 51 | const initialNotes: Note[] = [ 52 | { 53 | id: '1', 54 | title: 'Welcome to VoidNotes', 55 | content: '

Welcome to VoidNotes! Get started by creating your first note.

', 56 | createdAt: new Date(), 57 | updatedAt: new Date(), 58 | tags: ['welcome', 'info'] 59 | } 60 | ]; 61 | 62 | export const NotesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { 63 | const [notes, setNotes] = useState([]); 64 | const [isLoading, setIsLoading] = useState(true); 65 | const [searchQuery, setSearchQuery] = useState(''); 66 | const [activeNoteId, setActiveNoteId] = useState(null); 67 | const [saveStatus, setSaveStatus] = useState({ 68 | isSaving: false, 69 | lastSavedId: null as string | null, 70 | error: null as string | null 71 | }); 72 | 73 | const saveQueue = useRef>(new Map()); 74 | const isSaving = useRef(false); 75 | const saveRetryTimerRef = useRef(null); 76 | 77 | const processSaveQueue = async () => { 78 | if (isSaving.current || saveQueue.current.size === 0) return; 79 | 80 | isSaving.current = true; 81 | 82 | try { 83 | const [id, noteToSave] = Array.from(saveQueue.current.entries())[0]; 84 | 85 | setSaveStatus({ 86 | isSaving: true, 87 | lastSavedId: id, 88 | error: null 89 | }); 90 | 91 | console.log(`Processing save for note ${id} - Title: ${noteToSave.title}, Content length: ${noteToSave.content.length}`); 92 | 93 | const preparedNote = { 94 | ...noteToSave, 95 | createdAt: noteToSave.createdAt.toISOString(), 96 | updatedAt: noteToSave.updatedAt.toISOString() 97 | }; 98 | 99 | const contentPreview = noteToSave.content.length > 100 100 | ? `${noteToSave.content.substring(0, 100)}...` 101 | : noteToSave.content; 102 | console.log(`Note ${id} content preview: ${contentPreview}`); 103 | 104 | await SaveNote(JSON.stringify(preparedNote)); 105 | 106 | try { 107 | const notesData = await LoadNotes(); 108 | const parsedNotes = JSON.parse(notesData); 109 | const savedNote = parsedNotes.find((note: any) => note.id === id); 110 | 111 | if (!savedNote) { 112 | console.error(`Verification failed: Note ${id} not found after save`); 113 | throw new Error('Note was not found after saving'); 114 | } 115 | 116 | if (savedNote.content.length !== noteToSave.content.length) { 117 | console.error(`Verification failed: Content length mismatch, expected ${noteToSave.content.length}, got ${savedNote.content.length}`); 118 | throw new Error('Note content length mismatch after saving'); 119 | } 120 | 121 | console.log(`Verified note ${id} was saved correctly`); 122 | } catch (verifyErr) { 123 | console.error('Verification error:', verifyErr); 124 | } 125 | 126 | saveQueue.current.delete(id); 127 | 128 | console.log(`Note ${id} saved successfully!`); 129 | 130 | setSaveStatus({ 131 | isSaving: false, 132 | lastSavedId: id, 133 | error: null 134 | }); 135 | } catch (error) { 136 | console.error('Failed to save note:', error); 137 | 138 | const failedEntry = Array.from(saveQueue.current.entries())[0]; 139 | if (failedEntry) { 140 | const [id] = failedEntry; 141 | 142 | setSaveStatus({ 143 | isSaving: false, 144 | lastSavedId: null, 145 | error: `Failed to save note ${id}: ${String(error)}` 146 | }); 147 | 148 | console.log(`Will retry saving note ${id} in 2 seconds...`); 149 | 150 | if (saveRetryTimerRef.current) { 151 | window.clearTimeout(saveRetryTimerRef.current); 152 | } 153 | 154 | saveRetryTimerRef.current = window.setTimeout(() => { 155 | isSaving.current = false; 156 | processSaveQueue(); 157 | }, 2000); 158 | } 159 | } finally { 160 | if (!saveRetryTimerRef.current) { 161 | isSaving.current = false; 162 | } 163 | 164 | if (saveQueue.current.size > 0 && !saveRetryTimerRef.current) { 165 | setTimeout(processSaveQueue, 100); 166 | } 167 | } 168 | }; 169 | 170 | const queueSave = (note: Note): void => { 171 | console.log(`Queuing save for note ${note.id} - Title: ${note.title}`); 172 | saveQueue.current.set(note.id, note); 173 | processSaveQueue(); 174 | }; 175 | 176 | useEffect(() => { 177 | const loadNotesFromFilesystem = async () => { 178 | try { 179 | setIsLoading(true); 180 | console.log("Loading notes from filesystem..."); 181 | 182 | const notesData = await LoadNotes(); 183 | console.log(`Loaded notes data, length: ${notesData?.length || 0}`); 184 | 185 | if (notesData) { 186 | try { 187 | const parsedNotes = JSON.parse(notesData); 188 | if (Array.isArray(parsedNotes) && parsedNotes.length > 0) { 189 | console.log(`Parsed ${parsedNotes.length} notes`); 190 | 191 | const formattedNotes = parsedNotes.map((note: any) => ({ 192 | ...note, 193 | createdAt: new Date(note.createdAt), 194 | updatedAt: new Date(note.updatedAt) 195 | })); 196 | 197 | for (const note of formattedNotes) { 198 | console.log(`Loaded note: ${note.id} - ${note.title}, Content length: ${note.content.length}`); 199 | } 200 | 201 | setNotes(formattedNotes); 202 | 203 | if (formattedNotes.length > 0 && !activeNoteId) { 204 | setActiveNoteId(formattedNotes[0].id); 205 | } 206 | 207 | setIsLoading(false); 208 | return; 209 | } else { 210 | console.log("No notes found or empty array returned"); 211 | } 212 | } catch (parseError) { 213 | console.error('Failed to parse notes:', parseError); 214 | } 215 | } else { 216 | console.log("No notes data returned"); 217 | } 218 | 219 | console.log("Creating initial welcome note"); 220 | setNotes(initialNotes); 221 | 222 | const welcomeNote = { 223 | ...initialNotes[0], 224 | createdAt: initialNotes[0].createdAt.toISOString(), 225 | updatedAt: initialNotes[0].updatedAt.toISOString() 226 | }; 227 | 228 | await SaveNote(JSON.stringify(welcomeNote)); 229 | console.log("Welcome note saved"); 230 | 231 | setActiveNoteId(initialNotes[0].id); 232 | } catch (error) { 233 | console.error('Failed to load notes from filesystem', error); 234 | setNotes(initialNotes); 235 | setActiveNoteId(initialNotes[0].id); 236 | } finally { 237 | setIsLoading(false); 238 | } 239 | }; 240 | 241 | loadNotesFromFilesystem(); 242 | }, []); 243 | 244 | useEffect(() => { 245 | const handleBeforeUnload = async (e: BeforeUnloadEvent) => { 246 | if (saveQueue.current.size > 0) { 247 | console.log(`${saveQueue.current.size} notes pending save on unload, saving synchronously...`); 248 | e.preventDefault(); 249 | e.returnValue = ''; 250 | 251 | for (const [id, note] of saveQueue.current.entries()) { 252 | try { 253 | const preparedNote = { 254 | ...note, 255 | createdAt: note.createdAt.toISOString(), 256 | updatedAt: note.updatedAt.toISOString() 257 | }; 258 | 259 | await SaveNote(JSON.stringify(preparedNote)); 260 | console.log(`Note ${id} saved during unload!`); 261 | } catch (error) { 262 | console.error(`Failed to save note ${id} during unload:`, error); 263 | } 264 | } 265 | } 266 | }; 267 | 268 | window.addEventListener('beforeunload', handleBeforeUnload); 269 | return () => { 270 | window.removeEventListener('beforeunload', handleBeforeUnload); 271 | }; 272 | }, []); 273 | 274 | useEffect(() => { 275 | if (notes.length > 0 && !activeNoteId) { 276 | console.log(`No active note, selecting first note: ${notes[0].id}`); 277 | setActiveNoteId(notes[0].id); 278 | } 279 | }, [notes, activeNoteId]); 280 | 281 | const filteredNotes = notes.filter(note => { 282 | if (!searchQuery.trim()) return true; 283 | 284 | const query = searchQuery.toLowerCase(); 285 | const titleMatch = note.title.toLowerCase().includes(query); 286 | const contentMatch = note.content.toLowerCase().includes(query); 287 | const tagsMatch = note.tags?.some(tag => tag.toLowerCase().includes(query)) || false; 288 | 289 | return titleMatch || contentMatch || tagsMatch; 290 | }); 291 | 292 | const addNote = async () => { 293 | try { 294 | const newNote: Note = { 295 | id: Date.now().toString(), 296 | title: 'Untitled Note', 297 | content: '', 298 | createdAt: new Date(), 299 | updatedAt: new Date() 300 | }; 301 | 302 | console.log(`Creating new note: ${newNote.id}`); 303 | 304 | setNotes([newNote, ...notes]); 305 | setActiveNoteId(newNote.id); 306 | 307 | queueSave(newNote); 308 | } catch (error) { 309 | console.error('Failed to add note', error); 310 | } 311 | }; 312 | 313 | const updateNote = async (updatedNote: Note): Promise => { 314 | try { 315 | console.log(`Updating note: ${updatedNote.id} - ${updatedNote.title}`); 316 | 317 | const originalNote = notes.find(note => note.id === updatedNote.id); 318 | if (!originalNote) { 319 | console.error(`Cannot update note ${updatedNote.id}: Note not found`); 320 | return false; 321 | } 322 | 323 | const noteToUpdate = { 324 | ...originalNote, 325 | ...updatedNote, 326 | updatedAt: new Date(), 327 | createdAt: originalNote.createdAt 328 | }; 329 | 330 | setNotes(notes.map(note => 331 | note.id === noteToUpdate.id ? noteToUpdate : note 332 | )); 333 | 334 | queueSave(noteToUpdate); 335 | return true; 336 | } catch (error) { 337 | console.error('Failed to update note', error); 338 | return false; 339 | } 340 | }; 341 | 342 | const deleteNote = async (id: string) => { 343 | try { 344 | console.log(`Deleting note: ${id}`); 345 | 346 | await DeleteNote(id); 347 | console.log(`Note ${id} deleted from filesystem`); 348 | 349 | setNotes(notes.filter(note => note.id !== id)); 350 | 351 | if (saveQueue.current.has(id)) { 352 | console.log(`Removing note ${id} from save queue`); 353 | saveQueue.current.delete(id); 354 | } 355 | 356 | if (activeNoteId === id) { 357 | const nextNote = notes.length > 1 ? notes.find(note => note.id !== id)?.id || null : null; 358 | console.log(`Deleted active note, selecting next note: ${nextNote}`); 359 | setActiveNoteId(nextNote); 360 | } 361 | } catch (error) { 362 | console.error(`Failed to delete note ${id}:`, error); 363 | } 364 | }; 365 | 366 | const getActiveNote = () => { 367 | return notes.find(note => note.id === activeNoteId) || null; 368 | }; 369 | 370 | return ( 371 | 387 | {children} 388 | 389 | ); 390 | }; 391 | 392 | export const useNotes = () => useContext(NotesContext); 393 | -------------------------------------------------------------------------------- /frontend/src/components/NoteEditor/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { Editor } from '@tiptap/react'; 3 | import { fontFamilyOptions } from './fonts.js'; 4 | 5 | interface ToolbarProps { 6 | editor: Editor | null; 7 | onImageClick: () => void; 8 | onLinkClick: () => void; 9 | } 10 | 11 | interface ToolbarButtonProps { 12 | onClick: () => void; 13 | active?: boolean; 14 | disabled?: boolean; 15 | title: string; 16 | children: React.ReactNode; 17 | } 18 | 19 | export const ToolbarButton: React.FC = ({ onClick, active = false, disabled = false, title, children }) => ( 20 | 31 | ); 32 | 33 | export const Toolbar: React.FC = ({ editor, onImageClick, onLinkClick }) => { 34 | if (!editor) return null; 35 | 36 | const addTable = useCallback(() => { 37 | editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); 38 | }, [editor]); 39 | 40 | return ( 41 |
42 |
43 | editor.chain().focus().toggleBold().run()} 45 | active={editor.isActive('bold')} 46 | title="Bold" 47 | > 48 | 49 | 50 | 51 | 52 | editor.chain().focus().toggleItalic().run()} 54 | active={editor.isActive('italic')} 55 | title="Italic" 56 | > 57 | 58 | 59 | 60 | 61 | editor.chain().focus().toggleUnderline().run()} 63 | active={editor.isActive('underline')} 64 | title="Underline" 65 | > 66 | 67 | 68 | 69 | 70 | editor.chain().focus().toggleSuperscript().run()} 72 | active={editor.isActive('superscript')} 73 | title="Superscript" 74 | > 75 | x2 76 | 77 | editor.chain().focus().toggleSubscript().run()} 79 | active={editor.isActive('subscript')} 80 | title="Subscript" 81 | > 82 | x2 83 | 84 |
85 | 96 |
97 | 98 | 99 | 100 |
101 |
102 |
103 |
104 |
105 | editor.chain().focus().toggleHeading({ level: 1 }).run()} 107 | active={editor.isActive('heading', { level: 1 })} 108 | title="Heading 1" 109 | > 110 | H1 111 | 112 | editor.chain().focus().toggleHeading({ level: 2 }).run()} 114 | active={editor.isActive('heading', { level: 2 })} 115 | title="Heading 2" 116 | > 117 | H2 118 | 119 | editor.chain().focus().toggleHeading({ level: 3 }).run()} 121 | active={editor.isActive('heading', { level: 3 })} 122 | title="Heading 3" 123 | > 124 | H3 125 | 126 |
127 |
128 |
129 | editor.chain().focus().toggleBulletList().run()} 131 | active={editor.isActive('bulletList')} 132 | title="Bullet List" 133 | > 134 | • 135 | 136 | editor.chain().focus().toggleOrderedList().run()} 138 | active={editor.isActive('orderedList')} 139 | title="Numbered List" 140 | > 141 | 1. 142 | 143 | editor.chain().focus().toggleTaskList().run()} 145 | active={editor.isActive('taskList')} 146 | title="Task List" 147 | > 148 | 149 | 150 | 151 | 152 | 153 |
154 |
155 |
156 | editor.chain().focus().toggleBlockquote().run()} 158 | active={editor.isActive('blockquote')} 159 | title="Quote" 160 | > 161 | 162 | 163 | 164 | 165 | 166 | editor.chain().focus().toggleCodeBlock().run()} 168 | active={editor.isActive('codeBlock')} 169 | title="Code Block" 170 | > 171 | {''} 172 | 173 | editor.chain().focus().setHorizontalRule().run()} 175 | title="Horizontal Rule" 176 | > 177 | — 178 | 179 |
180 |
181 |
182 | 187 | 188 | 189 | 190 | 191 | 195 | 196 | 197 | 198 | 199 | 200 | 205 | 206 | 207 | 208 | 209 | 210 |
211 | {editor.isActive('table') && ( 212 | <> 213 |
214 |
215 | editor.chain().focus().addColumnBefore().run()} 217 | title="Add Column Before" 218 | > 219 | 220 | 221 | 222 | 223 | editor.chain().focus().addColumnAfter().run()} 225 | title="Add Column After" 226 | > 227 | 228 | 229 | 230 | 231 | editor.chain().focus().addRowBefore().run()} 233 | title="Add Row Before" 234 | > 235 | 236 | 237 | 238 | 239 | editor.chain().focus().addRowAfter().run()} 241 | title="Add Row After" 242 | > 243 | 244 | 245 | 246 | 247 | editor.chain().focus().deleteColumn().run()} 249 | title="Delete Column" 250 | > 251 | 252 | 253 | 254 | 255 | editor.chain().focus().deleteRow().run()} 257 | title="Delete Row" 258 | > 259 | 260 | 261 | 262 | 263 | editor.chain().focus().deleteTable().run()} 265 | title="Delete Table" 266 | > 267 | 268 | 269 | 270 | 271 |
272 | 273 | )} 274 |
275 | ); 276 | }; -------------------------------------------------------------------------------- /frontend/src/components/NoteEditor.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; 2 | import { useNotes, Note } from '../contexts/NotesContext.js'; 3 | import { useSettings } from '../contexts/SettingsContext.js'; 4 | import { useEditor, EditorContent, Editor } from '@tiptap/react'; 5 | import { EditorView } from 'prosemirror-view'; 6 | import { Transaction } from '@tiptap/pm/state'; 7 | 8 | import StarterKit from '@tiptap/starter-kit'; 9 | import Placeholder from '@tiptap/extension-placeholder'; 10 | import Heading from '@tiptap/extension-heading'; 11 | import { 12 | BulletList, OrderedList, CodeBlock, Link, Underline, TextStyle, 13 | Color, Highlight, FontFamily, Superscript, Subscript, Table, 14 | TableRow, TableCell, TableHeader, TaskList, TaskItem, 15 | Blockquote, Dropcursor, Image, HorizontalRule, DisableSpellcheckInCode 16 | } from './utils/tiptapExtensions.js'; 17 | 18 | import InputModal from './modals/MainModal.js'; 19 | import { StatusBar }from './NoteEditor/StatusBar.js'; 20 | import {useDebounce} from './NoteEditor/hooks/useDebounce.js'; 21 | import { Toolbar } from './NoteEditor/Toolbar.js'; 22 | import { GetAppDataDir } from '../../wailsjs/go/main/App.js'; 23 | 24 | const EDITOR_EXTENSIONS = [ 25 | StarterKit.configure({ 26 | heading: false, 27 | blockquote: false, 28 | codeBlock: false, 29 | }), 30 | Placeholder.configure({ placeholder: 'Start writing your note here...' }), 31 | Underline, 32 | Link.configure({ openOnClick: false }), 33 | Heading.configure({ levels: [1, 2, 3] }), 34 | BulletList, 35 | OrderedList, 36 | CodeBlock.configure({ 37 | HTMLAttributes: { 38 | class: 'hljs', 39 | spellcheck: 'false', 40 | }, 41 | }), 42 | DisableSpellcheckInCode, 43 | TextStyle, 44 | Color, 45 | Highlight.configure({ multicolor: true }), 46 | FontFamily.configure({ types: ['textStyle'] }), 47 | Superscript, 48 | Subscript, 49 | Table.configure({ resizable: true }), 50 | TableRow, 51 | TableHeader, 52 | TableCell, 53 | TaskList, 54 | TaskItem.configure({ nested: true }), 55 | Blockquote, 56 | Dropcursor, 57 | Image, 58 | HorizontalRule, 59 | ]; 60 | 61 | const NoteEditor: React.FC = () => { 62 | const { getActiveNote, updateNote, isLoading, saveStatus, activeNoteId, setActiveNoteId } = useNotes(); 63 | const { getScrollbarStyle } = useSettings(); 64 | const [note, setNote] = useState(null); 65 | const [appDataDir, setAppDataDir] = useState(""); 66 | const previousNoteIdRef = useRef(null); 67 | const editorContentRef = useRef(""); 68 | const lastSavedContentRef = useRef(""); 69 | const pendingSaveRef = useRef(false); 70 | const isTypingRef = useRef(false); 71 | const typingTimeoutRef = useRef | null>(null); 72 | 73 | const [modals, setModals] = useState({ 74 | image: false, 75 | link: false, 76 | unsavedChanges: false, 77 | }); 78 | 79 | const [nextNoteId, setNextNoteId] = useState(null); 80 | const [textStats, setTextStats] = useState({ 81 | wordCount: 0, 82 | charCount: 0, 83 | }); 84 | 85 | const scrollbarConfig = useMemo(() => { 86 | const style = getScrollbarStyle(); 87 | const darkerScrollbarColor = style['--scrollbar-thumb-color']?.replace( 88 | /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/, 89 | (_, r, g, b, a) => `rgba(${Math.max(0, Number(r) - 20)}, ${Math.max(0, Number(g) - 20)}, ${Math.max(0, Number(b) - 20)}, ${a || 1})` 90 | ); 91 | const darkerBackground = 'rgba(255, 255, 255, 0.03)'; 92 | 93 | return { 94 | class: "scrollbar-custom", 95 | style: { 96 | "--scrollbar-thumb-color": darkerScrollbarColor, 97 | "--scrollbar-track-color": darkerBackground, 98 | } as React.CSSProperties 99 | }; 100 | }, [getScrollbarStyle]); 101 | 102 | const editorConfig = useMemo(() => ({ 103 | extensions: EDITOR_EXTENSIONS, 104 | editorProps: { 105 | attributes: { 106 | class: `prose prose-invert max-w-none p-4 focus:outline-none overflow-y-auto ${scrollbarConfig.class}`, 107 | style: Object.entries(scrollbarConfig.style) 108 | .map(([key, value]) => `${key}: ${value}`) 109 | .join('; ') 110 | }, 111 | handlePaste: (view: EditorView, event: ClipboardEvent) => { 112 | return false; 113 | } 114 | } 115 | }), [scrollbarConfig]); 116 | 117 | const editor = useEditor(editorConfig as any); 118 | 119 | const markAsTyping = useCallback(() => { 120 | isTypingRef.current = true; 121 | 122 | if (typingTimeoutRef.current) { 123 | clearTimeout(typingTimeoutRef.current); 124 | } 125 | 126 | typingTimeoutRef.current = setTimeout(() => { 127 | isTypingRef.current = false; 128 | }, 500); 129 | }, []); 130 | 131 | const saveNoteContentImmediately = useCallback((content: string) => { 132 | if (!note) return; 133 | 134 | console.log(`IMMEDIATE SAVE for note: ${note.id}, content length: ${content.length}`); 135 | pendingSaveRef.current = true; 136 | 137 | try { 138 | updateNote({ 139 | ...note, 140 | content, 141 | updatedAt: new Date() 142 | }); 143 | lastSavedContentRef.current = content; 144 | console.log(`Immediate save completed for note ${note.id}`); 145 | } catch (err) { 146 | console.error("Failed to save note immediately:", err); 147 | } finally { 148 | pendingSaveRef.current = false; 149 | } 150 | }, [note, updateNote]); 151 | 152 | const debouncedSave = useDebounce((updatedContent: string) => { 153 | if (!note || pendingSaveRef.current) return; 154 | 155 | console.log(`Debounced save for note ${note.id}, content length: ${updatedContent.length}`); 156 | pendingSaveRef.current = true; 157 | 158 | try { 159 | updateNote({ 160 | ...note, 161 | content: updatedContent, 162 | updatedAt: new Date() 163 | }); 164 | lastSavedContentRef.current = updatedContent; 165 | console.log(`Debounced save completed for note ${note.id}`); 166 | } catch (err) { 167 | console.error("Failed in debounced save:", err); 168 | } finally { 169 | pendingSaveRef.current = false; 170 | } 171 | }, 1000); 172 | 173 | const handleEditorUpdate = useCallback(({ editor, transaction }: { editor: Editor; transaction: Transaction }) => { 174 | if (!note || !editor) return; 175 | 176 | markAsTyping(); 177 | 178 | const newContent = editor.getHTML(); 179 | editorContentRef.current = newContent; 180 | 181 | const text = editor.state.doc.textContent; 182 | setTextStats({ 183 | wordCount: text.trim() ? text.trim().split(/\s+/).length : 0, 184 | charCount: text.length 185 | }); 186 | 187 | debouncedSave(newContent); 188 | }, [note, debouncedSave, markAsTyping]); 189 | 190 | const forceSaveContent = useCallback(() => { 191 | if (!editor || !note || isTypingRef.current) return false; 192 | 193 | const currentContent = editor.getHTML(); 194 | 195 | if (currentContent !== lastSavedContentRef.current) { 196 | console.log(`Force saving note ${note.id} before switching`); 197 | saveNoteContentImmediately(currentContent); 198 | return true; 199 | } 200 | 201 | return false; 202 | }, [editor, note, saveNoteContentImmediately]); 203 | 204 | const switchToNote = useCallback((newNoteId: string | null) => { 205 | if (!editor || !note) { 206 | setActiveNoteId(newNoteId); 207 | return; 208 | } 209 | 210 | if (isTypingRef.current) { 211 | console.log("User is currently typing, delaying note switch"); 212 | setNextNoteId(newNoteId); 213 | setModals(prev => ({ ...prev, unsavedChanges: true })); 214 | return; 215 | } 216 | 217 | const currentContent = editor.getHTML(); 218 | 219 | if (currentContent !== lastSavedContentRef.current) { 220 | console.log("Unsaved changes detected, saving before switch"); 221 | saveNoteContentImmediately(currentContent); 222 | } 223 | 224 | setActiveNoteId(newNoteId); 225 | }, [editor, note, setActiveNoteId, saveNoteContentImmediately]); 226 | 227 | useEffect(() => { 228 | const loadNewNote = async () => { 229 | const currentNoteId = previousNoteIdRef.current; 230 | const newActiveNote = getActiveNote(); 231 | 232 | if (isTypingRef.current && currentNoteId && currentNoteId !== activeNoteId) { 233 | console.log(`User is typing, delaying switch from ${currentNoteId} to ${activeNoteId}`); 234 | setNextNoteId(activeNoteId); 235 | setModals(prev => ({ ...prev, unsavedChanges: true })); 236 | return; 237 | } 238 | 239 | console.log(`Note switching from ${currentNoteId} to ${activeNoteId}`); 240 | 241 | if (currentNoteId && editor && note) { 242 | const currentContent = editor.getHTML(); 243 | if (currentContent !== lastSavedContentRef.current) { 244 | console.log(`Saving note ${currentNoteId} before switching to ${activeNoteId}`); 245 | saveNoteContentImmediately(currentContent); 246 | } 247 | } 248 | 249 | previousNoteIdRef.current = activeNoteId; 250 | 251 | if (!newActiveNote) { 252 | setNote(null); 253 | return; 254 | } 255 | 256 | setNote(newActiveNote); 257 | 258 | if (editor && !editor.isDestroyed) { 259 | const contentToSet = newActiveNote.content || ''; 260 | 261 | if (editorContentRef.current !== contentToSet) { 262 | editor.commands.setContent(contentToSet, false); 263 | editorContentRef.current = contentToSet; 264 | lastSavedContentRef.current = contentToSet; 265 | } 266 | 267 | const text = editor.state.doc.textContent; 268 | setTextStats({ 269 | wordCount: text.trim() ? text.trim().split(/\s+/).length : 0, 270 | charCount: text.length 271 | }); 272 | 273 | console.log(`Loaded note ${newActiveNote.id}, content length: ${contentToSet.length}`); 274 | } 275 | }; 276 | 277 | loadNewNote(); 278 | }, [activeNoteId, editor, getActiveNote, saveNoteContentImmediately, note]); 279 | 280 | useEffect(() => { 281 | if (!editor) return; 282 | 283 | const handleKeyDown = () => { 284 | markAsTyping(); 285 | }; 286 | 287 | const updateHandler = (props: any) => { 288 | handleEditorUpdate(props); 289 | }; 290 | 291 | editor.on('update', updateHandler); 292 | 293 | const editorDOM = editor.view.dom; 294 | editorDOM.addEventListener('keydown', handleKeyDown); 295 | 296 | return () => { 297 | editor.off('update', updateHandler); 298 | editorDOM.removeEventListener('keydown', handleKeyDown); 299 | }; 300 | }, [editor, handleEditorUpdate, markAsTyping]); 301 | 302 | useEffect(() => { 303 | GetAppDataDir().then(dir => { 304 | setAppDataDir(dir); 305 | }).catch(err => { 306 | console.error("Failed to get app data directory:", err); 307 | }); 308 | }, []); 309 | 310 | useEffect(() => { 311 | return () => { 312 | if (!isTypingRef.current) { 313 | forceSaveContent(); 314 | } 315 | }; 316 | }, [forceSaveContent]); 317 | 318 | useEffect(() => { 319 | const intervalId = setInterval(() => { 320 | if (note && editor && !pendingSaveRef.current && !isTypingRef.current) { 321 | const currentContent = editor.getHTML(); 322 | if (currentContent !== lastSavedContentRef.current) { 323 | console.log(`Periodic save check - saving note ${note.id}`); 324 | saveNoteContentImmediately(currentContent); 325 | } 326 | } 327 | }, 3000); 328 | 329 | return () => { 330 | clearInterval(intervalId); 331 | }; 332 | }, [note, editor, saveNoteContentImmediately]); 333 | 334 | useEffect(() => { 335 | const handleBeforeUnload = (event: BeforeUnloadEvent) => { 336 | if (note && editor) { 337 | const currentContent = editor.getHTML(); 338 | if (currentContent !== lastSavedContentRef.current) { 339 | saveNoteContentImmediately(currentContent); 340 | } 341 | } 342 | }; 343 | 344 | window.addEventListener('beforeunload', handleBeforeUnload); 345 | return () => { 346 | window.removeEventListener('beforeunload', handleBeforeUnload); 347 | }; 348 | }, [note, editor, saveNoteContentImmediately]); 349 | 350 | const handleMedia = useCallback((url: string, type: 'image' | 'link') => { 351 | if (!editor) return; 352 | 353 | if (type === 'image') { 354 | editor.chain().focus().setImage({ src: url }).run(); 355 | setModals(prev => ({ ...prev, image: false })); 356 | } else { 357 | editor.chain().focus().setLink({ href: url }).run(); 358 | setModals(prev => ({ ...prev, link: false })); 359 | } 360 | }, [editor]); 361 | 362 | if (isLoading) { 363 | return ( 364 |
365 | 366 | 367 | 368 | 369 |

Loading Notes

370 |

Fetching notes from {appDataDir || "application directory"}...

371 |
372 | ); 373 | } 374 | 375 | if (!note) { 376 | return ( 377 |
378 | 379 | 380 | 381 |

No Note Selected

382 |

Select a note from the sidebar or create a new one.

383 |

Notes are saved to: {appDataDir || "application directory"}

384 |
385 | ); 386 | } 387 | 388 | return ( 389 |
390 |
391 | setModals(prev => ({ ...prev, image: true }))} 394 | onLinkClick={() => setModals(prev => ({ ...prev, link: true }))} 395 | /> 396 | 397 |
399 | 400 |
401 |
402 | 403 | 410 | 411 | setModals(prev => ({ ...prev, image: false }))} 414 | onConfirm={(url) => handleMedia(url, 'image')} 415 | title="Add Image" 416 | placeholder="Enter image URL" 417 | confirmText="Add Image" 418 | inputType="url" 419 | /> 420 | setModals(prev => ({ ...prev, link: false }))} 423 | onConfirm={(url) => handleMedia(url, 'link')} 424 | title="Add Link" 425 | placeholder="Enter URL" 426 | confirmText="Add Link" 427 | inputType="url" 428 | /> 429 | { 432 | setModals(prev => ({ ...prev, unsavedChanges: false })); 433 | setNextNoteId(null); 434 | }} 435 | onConfirm={() => { 436 | setModals(prev => ({ ...prev, unsavedChanges: false })); 437 | if (nextNoteId !== null) { 438 | forceSaveContent(); 439 | setActiveNoteId(nextNoteId); 440 | setNextNoteId(null); 441 | } 442 | }} 443 | title="Unsaved Changes" 444 | message="You have unsaved changes in this note. Save before switching?" 445 | confirmText="Save & Switch" 446 | showInput={false} 447 | cancelText="Discard Changes" 448 | /> 449 |
450 | ); 451 | }; 452 | 453 | export default NoteEditor; -------------------------------------------------------------------------------- /frontend/src/pages/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, CSSProperties} from 'react'; 2 | import { useWindow } from '../contexts/WindowContext.js'; 3 | import { useNotes } from '../contexts/NotesContext.js'; 4 | import { useSettings } from '../contexts/SettingsContext.js'; 5 | import sidebarLeftIcon from '../assets/sidebar-left.svg'; 6 | import sidebarRightIcon from '../assets/sidebar-right.svg'; 7 | import InputModal from '../components/modals/MainModal.js'; 8 | import UpdateNotification from '../components/update/UpdateNotification.js'; 9 | 10 | export interface CustomCSSProperties extends CSSProperties { 11 | '--scrollbar-thumb-color'?: string; 12 | '--scrollbar-track-color'?: string; 13 | } 14 | 15 | interface SidebarProps { 16 | className?: string; 17 | } 18 | 19 | const Sidebar: React.FC = ({ className }) => { 20 | const [collapsed, setCollapsed] = useState(false); 21 | const [isEditing, setIsEditing] = useState(false); 22 | const [editingTitle, setEditingTitle] = useState(''); 23 | const [editingTags, setEditingTags] = useState(''); 24 | const [deleteNoteId, setDeleteNoteId] = useState(null); 25 | const [deleteNoteTitle, setDeleteNoteTitle] = useState(''); 26 | const { isMac } = useWindow(); 27 | const { toggleSettings } = useSettings(); 28 | const { 29 | filteredNotes, 30 | activeNoteId, 31 | searchQuery, 32 | setSearchQuery, 33 | setActiveNoteId, 34 | addNote, 35 | deleteNote, 36 | updateNote, 37 | getActiveNote 38 | } = useNotes(); 39 | const { getSidebarStyle, getScrollbarStyle } = useSettings(); 40 | 41 | const formatDate = (date: Date): string => { 42 | if (!(date instanceof Date)) { 43 | date = new Date(date); 44 | } 45 | return date.toLocaleDateString('en-US', { 46 | month: 'short', 47 | day: 'numeric', 48 | year: 'numeric' 49 | }); 50 | }; 51 | 52 | const activeNote = getActiveNote(); 53 | 54 | const startEditing = () => { 55 | if (!activeNote) return; 56 | setIsEditing(true); 57 | setEditingTitle(activeNote.title); 58 | setEditingTags(activeNote.tags?.join(', ') || ''); 59 | }; 60 | 61 | const saveEditing = () => { 62 | if (!activeNote) return; 63 | 64 | const tagsArray = editingTags 65 | .split(',') 66 | .map(tag => tag.trim()) 67 | .filter(tag => tag.length > 0); 68 | 69 | const updatedNote = { 70 | ...activeNote, 71 | title: editingTitle, 72 | tags: tagsArray, 73 | updatedAt: new Date() 74 | }; 75 | 76 | updateNote(updatedNote); 77 | setIsEditing(false); 78 | }; 79 | 80 | const cancelEditing = () => { 81 | setIsEditing(false); 82 | }; 83 | 84 | const handleKeyDown = (e: React.KeyboardEvent) => { 85 | if (e.key === 'Enter') { 86 | saveEditing(); 87 | } else if (e.key === 'Escape') { 88 | cancelEditing(); 89 | } 90 | }; 91 | 92 | const handleDelete = (noteId: string, noteTitle: string, e: React.MouseEvent) => { 93 | e.stopPropagation(); 94 | setDeleteNoteId(noteId); 95 | setDeleteNoteTitle(noteTitle); 96 | }; 97 | 98 | const handleDeleteConfirm = (value: string) => { 99 | if (deleteNoteId && value.toLowerCase() === 'delete') { 100 | deleteNote(deleteNoteId); 101 | setDeleteNoteId(null); 102 | setDeleteNoteTitle(''); 103 | } 104 | }; 105 | 106 | useEffect(() => { 107 | setIsEditing(false); 108 | }, [activeNoteId]); 109 | 110 | const scrollbarStyle = getScrollbarStyle(); 111 | const customScrollbarClass = "scrollbar-custom"; 112 | 113 | return ( 114 | <> 115 |
127 | {isMac && ( 128 |
132 | )} 133 | 134 | {!collapsed ? ( 135 |
136 |
137 |

138 | {searchQuery ? `Search Results` : 'Notes'} 139 | {searchQuery && ({filteredNotes.length})} 140 |

141 |
142 | 152 | 163 |
164 |
165 | 166 |
167 | setSearchQuery(e.target.value)} 171 | className="w-full bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-sm text-white placeholder-white/50 focus:outline-none focus:ring-1 focus:ring-white/50" 172 | placeholder="Search notes..." 173 | /> 174 |
175 | {searchQuery ? ( 176 | 184 | ) : ( 185 | 186 | 187 | 188 | )} 189 |
190 |
191 |
192 | ) : ( 193 |
194 | 204 | 215 |
216 | )} 217 | 218 | {collapsed ? ( 219 |
226 | {filteredNotes.map(note => ( 227 |
setActiveNoteId(note.id)} 231 | title={note.title} 232 | > 233 |
234 | {note.title.charAt(0).toUpperCase()} 235 |
236 |
237 | ))} 238 | 239 |
240 |
245 | 246 | 247 | 248 |
249 |
250 |
251 | ) : ( 252 |
259 | {isEditing && activeNote ? ( 260 |
261 |
262 | 263 | setEditingTitle(e.target.value)} 267 | className="w-full bg-white/15 border border-white/20 rounded-md px-2 py-1 text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/50" 268 | placeholder="Note title" 269 | onKeyDown={handleKeyDown} 270 | autoFocus 271 | /> 272 |
273 |
274 | 275 | setEditingTags(e.target.value)} 279 | className="w-full bg-white/15 border border-white/20 rounded-md px-2 py-1 text-sm text-white focus:outline-none focus:ring-1 focus:ring-white/50" 280 | placeholder="tag1, tag2, tag3" 281 | onKeyDown={handleKeyDown} 282 | /> 283 |
284 |
285 | 291 | 297 |
298 |
299 | ) : null} 300 | 301 | {filteredNotes 302 | .filter(note => !isEditing || note.id !== activeNoteId) 303 | .map(note => ( 304 |
312 |
setActiveNoteId(note.id)} 315 | > 316 |
317 |

{note.title}

318 |

319 | {formatDate(note.updatedAt)} 320 |

321 | {note.tags && note.tags.length > 0 && ( 322 |
323 | {note.tags.map(tag => ( 324 | 325 | {tag} 326 | 327 | ))} 328 |
329 | )} 330 |
331 |
332 | {activeNoteId === note.id && ( 333 | 346 | )} 347 | 356 |
357 |
358 |
359 | ))} 360 | 361 | {filteredNotes.length === 0 && ( 362 |
363 | {searchQuery ? ( 364 | <> 365 | 366 | 367 | 368 |

No notes match your search.

369 | 370 | ) : ( 371 | <> 372 | 373 | 374 | 375 |

No notes yet.

376 | 382 | 383 | )} 384 |
385 | )} 386 |
387 | )} 388 | 389 | {!collapsed ? ( 390 |
391 |
392 | 401 | 402 | 403 |
404 | 405 |
406 |

VoidWorks

407 | 408 |
409 |
410 | ) : ( 411 |
412 | 413 |
414 | VoidWorks 415 |
416 |
417 | )} 418 |
419 | 420 | { 423 | setDeleteNoteId(null); 424 | setDeleteNoteTitle(''); 425 | }} 426 | onConfirm={handleDeleteConfirm} 427 | title={`Delete "${deleteNoteTitle}"`} 428 | placeholder="Type 'delete' to confirm" 429 | confirmText="Delete Note" 430 | /> 431 | 432 | ); 433 | }; 434 | 435 | export default Sidebar; 436 | --------------------------------------------------------------------------------