├── .env.development ├── .github └── workflows │ └── publish-to-release.yml ├── .gitignore ├── .prettierrc ├── .scripts └── move-binaries.js ├── .yarnrc.yml ├── README.md ├── index.html ├── package.json ├── pnpm-lock.yaml ├── public ├── app-icon.png ├── cover-photo.png └── vite.svg ├── server ├── .gitignore ├── README.md ├── bun.lockb ├── index.ts ├── package.json └── tsconfig.json ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── app-icon.png ├── build.rs ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── src │ └── main.rs └── tauri.conf.json ├── src ├── assets │ ├── fonts │ │ ├── BerkeleyMonoVariable-Italic.woff2 │ │ ├── BerkeleyMonoVariable-Regular.woff2 │ │ ├── Geist-Bold.otf │ │ ├── Geist-Medium.otf │ │ ├── Geist-Regular.otf │ │ ├── Geist-SemiBold.otf │ │ ├── GeistMono-Bold.otf │ │ ├── GeistMono-Medium.otf │ │ ├── GeistMono-Regular.otf │ │ ├── GeistMono-SemiBold.otf │ │ ├── Satoshi-Bold.woff2 │ │ └── Satoshi-Medium.woff2 │ ├── react.svg │ └── scissors.svg ├── components │ ├── components.scss │ ├── date-picker │ │ ├── DatePicker.Day.tsx │ │ ├── DatePicker.Grid.tsx │ │ ├── DatePicker.tsx │ │ ├── date-utils.ts │ │ └── datepicker.scss │ ├── editor │ │ ├── Editor.EntryHeader.tsx │ │ ├── Editor.TagEditor.tsx │ │ ├── Editor.tsx │ │ ├── FloatingLinkEditor.tsx │ │ ├── FloatingMenu.tsx │ │ ├── _code-highlight.scss │ │ ├── _editor-components.scss │ │ └── _editor.scss │ ├── index.ts │ ├── journal │ │ ├── Calendar.tsx │ │ └── Note.tsx │ ├── modal │ │ ├── Modal.tsx │ │ └── modal.scss │ ├── page-titlebar │ │ ├── PageTitleBar.tsx │ │ └── _page-titlebar.scss │ ├── search │ │ ├── SearchDialog.tsx │ │ └── _search-dialog.scss │ ├── shared │ │ ├── ErrorBoundary.tsx │ │ └── _error-boundary.scss │ ├── sidebar │ │ ├── FolderMenu.tsx │ │ ├── Sidebar.tsx │ │ ├── SidebarEntry.tsx │ │ ├── SidebarEntrySection.tsx │ │ ├── SidebarFolder.tsx │ │ ├── SidebarHeader.tsx │ │ └── _sidebar.scss │ ├── splash-screen │ │ ├── SplashScreen.tsx │ │ └── _splash-screen.scss │ ├── svgs │ │ └── checkicon.svg │ ├── tag-selector │ │ └── TagSelector.tsx │ └── tooltip │ │ ├── Tooltip.tsx │ │ ├── _tooltip.scss │ │ └── useHoverToggle.ts ├── hooks │ ├── useDoubleClick.ts │ ├── useModal.jsx │ ├── usePointerInteractions.tsx │ ├── useRaisedShadow.ts │ └── useRegisterAllShortcuts.ts ├── lib │ ├── auth │ │ └── auth-helpers.ts │ ├── config.ts │ ├── constants.ts │ ├── data-engine │ │ ├── syncing-engine.ts │ │ └── syncing-helpers.ts │ ├── logger.ts │ ├── mobx-debounce.ts │ ├── search │ │ └── search-engine.ts │ ├── storage.ts │ └── utils.ts ├── main.tsx ├── migrations │ ├── add-version.migrate.ts │ ├── file-date-pattern.migrate.ts │ ├── file-json-to-md.migrate.ts │ ├── index.ts │ ├── remove-datestring-from-ids.migrate.ts │ └── seed │ │ ├── entries.seed.ts │ │ ├── index.seed.ts │ │ └── journal.seed.ts ├── plugins │ ├── AutolinkPlugin.tsx │ ├── ClickableLinkPlugin.tsx │ ├── CodeHighlightPlugin.tsx │ ├── FloatingMenuPlugin.tsx │ ├── MarkdownShortcut.tsx │ ├── PageBreakPlugin │ │ ├── PageBreakPlugin.tsx │ │ └── nodes │ │ │ ├── PageBreakNode.scss │ │ │ └── PageBreakNode.tsx │ ├── SearchDialogPlugin.tsx │ ├── SlashCommandPicker.tsx │ ├── TabFocusPlugin.tsx │ └── theme.ts ├── routes │ ├── Root.tsx │ ├── layout │ │ ├── AppLayout.tsx │ │ └── _app-layout.scss │ ├── pages │ │ ├── entry │ │ │ ├── Entry.page.tsx │ │ │ └── _entry.scss │ │ ├── journal │ │ │ ├── Journal.page.tsx │ │ │ └── _journal.scss │ │ ├── safe │ │ │ ├── SafeLoadout.page.tsx │ │ │ └── _safe-loadout.scss │ │ ├── settings │ │ │ ├── Settings.page.tsx │ │ │ └── _settings.scss │ │ └── trash │ │ │ ├── Trash.page.tsx │ │ │ └── _trash.scss │ └── router.tsx ├── store │ ├── app-state.ts │ ├── entries.ts │ ├── index.ts │ ├── journal-state.ts │ ├── search.ts │ └── tags-state.ts ├── styles │ ├── _fonts.scss │ ├── global.scss │ └── index.scss └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.env.development: -------------------------------------------------------------------------------- 1 | VITE_SENTRY_DSN= 2 | VITE_SENTRY_AUTH_TOKEN= -------------------------------------------------------------------------------- /.github/workflows/publish-to-release.yml: -------------------------------------------------------------------------------- 1 | name: 'publish' 2 | 3 | on: 4 | push: 5 | branches: 6 | - release 7 | pull_request: 8 | branches: 9 | - release 10 | 11 | jobs: 12 | release-bundles: 13 | permissions: 14 | contents: write 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | include: 19 | - platform: 'macos-latest' # for Arm based macs (M1 and above). 20 | args: '--target aarch64-apple-darwin' 21 | - platform: 'macos-latest' # for Intel based macs. 22 | args: '--target x86_64-apple-darwin' 23 | - platform: 'windows-latest' 24 | args: '' 25 | 26 | runs-on: ${{ matrix.platform }} 27 | steps: 28 | - name: checkout 29 | uses: actions/checkout@v4 30 | 31 | - uses: pnpm/action-setup@v4 32 | name: Install pnpm 33 | with: 34 | version: 9 35 | run_install: false 36 | 37 | - name: Install Node.js 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: 20 41 | cache: 'pnpm' 42 | 43 | - name: Install dependencies 44 | run: pnpm install 45 | 46 | - name: install Rust stable 47 | uses: dtolnay/rust-toolchain@stable 48 | with: 49 | targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} 50 | 51 | - name: Rust cache 52 | uses: swatinem/rust-cache@v2 53 | with: 54 | workspaces: './src-tauri -> target' 55 | 56 | - name: install frontend dependencies 57 | run: pnpm install 58 | 59 | - name: build the app 60 | uses: tauri-apps/tauri-action@v0 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} 64 | APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} 65 | APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} 66 | APPLE_ID: ${{ secrets.APPLE_ID }} 67 | APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} 68 | APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} 69 | with: 70 | tagName: app-v__VERSION__ 71 | releaseName: 'Ibis v__VERSION__' 72 | releaseBody: 'See the assets to download this version and install.' 73 | releaseDraft: false 74 | prerelease: false 75 | args: ${{ matrix.settings.args }} 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | 27 | yarn.lock 28 | .yarn 29 | .env 30 | bin -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "all", 8 | "importOrderSortSpecifiers": true, 9 | "importOrderSeparation": true, 10 | "importOrder": [ 11 | "^(react)$", 12 | "^(react-dom)$", 13 | "", 14 | "@/components/(.*)$", 15 | "@/(.*)$", 16 | "^[./]" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.scripts/move-binaries.js: -------------------------------------------------------------------------------- 1 | import { execa } from 'execa'; 2 | import fs from 'node:fs'; 3 | 4 | async function moveBinaries() { 5 | let extension = ''; 6 | 7 | if (process.platform === 'win32') { 8 | extension = '.exe'; 9 | } 10 | 11 | const rustInfo = (await execa('rustc', ['-vV'])).stdout; 12 | const targetTriple = /host: (\S+)/g.exec(rustInfo)[1]; 13 | 14 | if (!targetTriple) { 15 | console.error('Failed to determine platform target triple'); 16 | } 17 | 18 | fs.renameSync( 19 | `src-tauri/binaries/ibis-server${extension}`, 20 | `src-tauri/binaries/ibis-server-${targetTriple}${extension}`, 21 | ); 22 | } 23 | 24 | async function main() { 25 | try { 26 | await moveBinaries(); 27 | } catch (e) { 28 | throw e; 29 | } 30 | } 31 | 32 | main().catch((e) => { 33 | throw e; 34 | }); 35 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ibis 2 | 3 | Ibis is a desktop app for writing and journaling in markdown. It allows you to organsize and tags your owns based on your own structure. 4 | 5 | ![Ibis app screenshot](./public/cover-photo.png) 6 | 7 | ## Getting started 8 | 9 | 1. Download the latest version of Ibis on the [release page](https://github.com/sunday-studio/ibis/releases) 10 | 2. Open the `.zip` or `.dmg` file and drag the application into the `Applications/` folder. 11 | 3. Open the application, select a location for your `safe` and start writing. 12 | 13 | ## License 14 | 15 | This project is licensed under the MIT License. 16 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Ibis 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ibis", 3 | "private": true, 4 | "version": "0.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "pnpm vite", 8 | "build": "pnpm vite build", 9 | "preview": "vite preview", 10 | "tauri": "tauri", 11 | "tsc": "tsc --noEmit", 12 | "prettier": "prettier", 13 | "knip": "knip", 14 | "package-sidecar": "cd server && pnpm build && cd .. && node .scripts/move-binaries.js" 15 | }, 16 | "dependencies": { 17 | "@adobe/react-spectrum": "^3.35.1", 18 | "@esbuild-plugins/node-globals-polyfill": "^0.2.3", 19 | "@floating-ui/dom": "^1.6.5", 20 | "@floating-ui/react": "^0.26.16", 21 | "@internationalized/date": "^3.5.4", 22 | "@lexical/clipboard": "0.17.1", 23 | "@lexical/code": "^0.17.1", 24 | "@lexical/headless": "^0.16.0", 25 | "@lexical/link": "^0.17.1", 26 | "@lexical/list": "^0.16.0", 27 | "@lexical/markdown": "^0.16.0", 28 | "@lexical/react": "^0.16.0", 29 | "@lexical/rich-text": "^0.16.0", 30 | "@lexical/selection": "^0.17.1", 31 | "@lexical/table": "^0.17.1", 32 | "@lexical/utils": "^0.16.0", 33 | "@radix-ui/react-context-menu": "^2.1.5", 34 | "@radix-ui/react-dialog": "^1.0.5", 35 | "@radix-ui/react-popover": "1.0.7", 36 | "@sentry/react": "^8.8.0", 37 | "@sentry/vite-plugin": "^2.22.3", 38 | "@tauri-apps/api": "^1.5.6", 39 | "@uidotdev/usehooks": "^2.4.1", 40 | "buffer": "^6.0.3", 41 | "clsx": "^2.1.1", 42 | "cmdk": "^1.0.0", 43 | "date-fns": "^3.6.0", 44 | "framer-motion": "^11.2.10", 45 | "fuse.js": "^7.0.0", 46 | "global": "^4.4.0", 47 | "gray-matter": "^4.0.3", 48 | "hono": "^4.5.9", 49 | "lexical": "^0.16.0", 50 | "lexical-floating-menu": "^0.1.0", 51 | "localforage": "^1.10.0", 52 | "lucide-react": "^0.390.0", 53 | "lunr": "^2.3.9", 54 | "markdown-to-text": "^0.1.1", 55 | "match-sorter": "^6.3.4", 56 | "mobx": "^6.12.3", 57 | "mobx-react-lite": "^4.0.7", 58 | "nanoid": "^5.0.7", 59 | "react": "^18.3.1", 60 | "react-aria": "^3.33.1", 61 | "react-aria-components": "^1.2.1", 62 | "react-dom": "^18.3.1", 63 | "react-error-boundary": "^4.0.13", 64 | "react-hotkeys-hook": "^4.5.0", 65 | "react-router-dom": "^6.23.1", 66 | "react-select": "^5.8.0", 67 | "react-stately": "^3.31.1", 68 | "sonner": "^1.5.0", 69 | "sort-by": "^1.2.0", 70 | "ts-key-enum": "^2.0.12", 71 | "typescript": "^5.4.5", 72 | "use-debounce": "^10.0.1", 73 | "usehooks-ts": "^3.1.0" 74 | }, 75 | "devDependencies": { 76 | "@tauri-apps/cli": "^1.5.14", 77 | "@testing-library/jest-dom": "^6.4.5", 78 | "@testing-library/react": "^16.0.0", 79 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 80 | "@types/node": "^20.14.2", 81 | "@types/react": "^18.3.3", 82 | "@types/react-dom": "^18.3.0", 83 | "@vitejs/plugin-react": "^4.3.0", 84 | "execa": "^9.2.0", 85 | "knip": "^5.18.2", 86 | "pkg": "^5.8.1", 87 | "prettier": "^3.3.1", 88 | "sass": "^1.77.4", 89 | "vite": "^5.2.13", 90 | "vitest": "^1.6.0" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /public/app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/public/app-icon.png -------------------------------------------------------------------------------- /public/cover-photo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/public/cover-photo.png -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # ibis-server 2 | 3 | To install dependencies: 4 | 5 | ```bash 6 | bun install 7 | ``` 8 | 9 | To run: 10 | 11 | ```bash 12 | bun run index.ts 13 | ``` 14 | 15 | This project was created using `bun init` in bun v1.0.30. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. 16 | -------------------------------------------------------------------------------- /server/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/server/bun.lockb -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono'; 2 | import { cors } from 'hono/cors'; 3 | 4 | const { createHeadlessEditor } = require('@lexical/headless'); 5 | const { $convertToMarkdownString, TRANSFORMERS, CHECK_LIST } = require('@lexical/markdown'); 6 | const { CodeHighlightNode, CodeNode } = require('@lexical/code'); 7 | const { AutoLinkNode, LinkNode } = require('@lexical/link'); 8 | const { ListItemNode, ListNode } = require('@lexical/list'); 9 | const { HeadingNode, QuoteNode } = require('@lexical/rich-text'); 10 | const { TableCellNode, TableNode, TableRowNode } = require('@lexical/table'); 11 | const { 12 | $applyNodeReplacement, 13 | $createParagraphNode, 14 | $createTextNode, 15 | LexicalNode, 16 | ElementNode, 17 | } = require('lexical'); 18 | const Prism = require('prismjs'); 19 | 20 | // @ts-ignore 21 | global.window = {}; 22 | // @ts-ignore 23 | global.window.Prism = Prism; 24 | 25 | type SerializedPageBreakNode = { 26 | type: 'page-break'; 27 | version: 1; 28 | }; 29 | 30 | class PageBreakNode extends ElementNode { 31 | static getType(): string { 32 | return 'page-break'; 33 | } 34 | constructor() { 35 | super(); 36 | this.type = 'page-break'; 37 | this.version = 1; 38 | } 39 | 40 | createDOM() { 41 | return null; 42 | } 43 | 44 | static clone(node: PageBreakNode): PageBreakNode { 45 | return new PageBreakNode(); 46 | } 47 | 48 | static importJSON(_serializedNode: SerializedPageBreakNode): typeof LexicalNode { 49 | return $createPageBreakNode(); 50 | } 51 | 52 | serialize() { 53 | return { type: this.type, version: this.version }; 54 | } 55 | 56 | exportJSON(): SerializedPageBreakNode { 57 | return { 58 | type: 'page-break', 59 | version: 1, 60 | }; 61 | } 62 | } 63 | 64 | export function $isPageBreakNode( 65 | node: typeof LexicalNode | null | undefined, 66 | ): node is PageBreakNode { 67 | return node instanceof PageBreakNode; 68 | } 69 | 70 | export function $createPageBreakNode(): PageBreakNode { 71 | return $applyNodeReplacement(new PageBreakNode()); 72 | } 73 | 74 | // @ts-ignore 75 | const PAGE_BREAK_NODE_TRANSFORMER: Transformer = { 76 | // @ts-ignore 77 | export: (node) => { 78 | if ($isPageBreakNode(node) || node.getType() === 'page-break') { 79 | return '---\n'; 80 | } 81 | }, 82 | regExp: /^---\s*$/, 83 | // @ts-ignore 84 | replace: (parentNode, _, match) => { 85 | const [allMatch] = match; 86 | const paragraphNode = $createParagraphNode(); 87 | const textNode = $createTextNode(allMatch); 88 | paragraphNode.append(textNode); 89 | parentNode.replace($createPageBreakNode()); 90 | }, 91 | type: 'element', 92 | dependencies: [], 93 | }; 94 | 95 | const app = new Hono(); 96 | 97 | app.use( 98 | '*', 99 | cors({ 100 | origin: ['http://localhost:1420', 'tauri://localhost', 'http://localhost:3323'], 101 | allowHeaders: ['X-Custom-Header', 'Upgrade-Insecure-Requests'], 102 | allowMethods: ['POST', 'GET', 'OPTIONS'], 103 | exposeHeaders: ['Content-Length', 'X-Kuma-Revision'], 104 | maxAge: 600, 105 | credentials: true, 106 | }), 107 | ); 108 | 109 | app.post('/json', async (c) => { 110 | const body = await c.req.text(); 111 | 112 | let markdown; 113 | const editor = createHeadlessEditor({ 114 | nodes: [ 115 | HeadingNode, 116 | ListNode, 117 | ListItemNode, 118 | QuoteNode, 119 | CodeNode, 120 | CodeHighlightNode, 121 | TableNode, 122 | TableCellNode, 123 | TableRowNode, 124 | AutoLinkNode, 125 | LinkNode, 126 | PageBreakNode, 127 | ], 128 | // @ts-ignore 129 | onError: (e) => { 130 | console.log('e =>', e); 131 | return c.json({ success: false, message: e.message, e }); 132 | }, 133 | }); 134 | 135 | const state = editor.parseEditorState(JSON.parse(body)); 136 | editor.setEditorState(state); 137 | 138 | editor.update(() => { 139 | markdown = $convertToMarkdownString( 140 | [CHECK_LIST, PAGE_BREAK_NODE_TRANSFORMER, ...TRANSFORMERS], 141 | undefined, 142 | true, 143 | ); 144 | }); 145 | 146 | return c.json({ success: true, content: markdown }); 147 | }); 148 | 149 | export default { 150 | port: 3323, 151 | fetch: app.fetch, 152 | }; 153 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ibis-server", 3 | "module": "index.ts", 4 | "type": "module", 5 | "devDependencies": { 6 | "@types/bun": "^1.1.6" 7 | }, 8 | "peerDependencies": { 9 | "typescript": "^5.5.3" 10 | }, 11 | "scripts": { 12 | "build": "bun build ./index.ts" 13 | }, 14 | "dependencies": { 15 | "@lexical/code": "^0.16.1", 16 | "@lexical/headless": "^0.16.1", 17 | "@lexical/link": "^0.16.1", 18 | "@lexical/list": "^0.16.1", 19 | "@lexical/markdown": "^0.16.1", 20 | "@lexical/rich-text": "^0.16.1", 21 | "hono": "^4.4.11", 22 | "lexical": "^0.16.1", 23 | "prismjs": "^1.29.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | /binaries/* -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["casprine"] 3 | description = "all in one daily writing driver" 4 | edition = "2021" 5 | license = "" 6 | name = "Ibis" 7 | repository = "" 8 | version = "0.1.0" 9 | 10 | 11 | [build-dependencies] 12 | tauri-build = { version = "1.4", features = [] } 13 | 14 | [dependencies] 15 | serde = { version = "1.0", features = ["derive"] } 16 | serde_json = "1.0" 17 | tauri = { version = "1.4", features = [ 18 | "macos-private-api", 19 | "http-all", 20 | "window-unminimize", 21 | "window-minimize", 22 | "window-maximize", 23 | "window-unmaximize", 24 | "window-show", 25 | "window-hide", 26 | "window-close", 27 | "path-all", 28 | "global-shortcut-all", 29 | "fs-all", 30 | "dialog-all", 31 | "window-start-dragging", 32 | "shell-open", 33 | ] } 34 | walkdir = "2" 35 | window-vibrancy = "0.4.0" 36 | 37 | [features] 38 | # this feature is used for production builds or when `devPath` points to the filesystem 39 | # DO NOT REMOVE!! 40 | custom-protocol = ["tauri/custom-protocol"] 41 | -------------------------------------------------------------------------------- /src-tauri/app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src-tauri/app-icon.png -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | use serde::{Deserialize, Serialize}; 5 | use std::fs; 6 | use std::fs::File; 7 | use std::io::Read; 8 | use std::io::Write; 9 | use std::path::Path; 10 | 11 | use tauri::command; 12 | use walkdir::{DirEntry, WalkDir}; 13 | 14 | #[derive(Serialize, Deserialize)] 15 | struct FileList { 16 | files: Vec, 17 | } 18 | 19 | #[derive(Serialize, Deserialize)] 20 | struct ErrorResponse { 21 | error: String, 22 | } 23 | 24 | type CommandResult = Result; 25 | 26 | #[command] 27 | 28 | fn get_all_files(path: String) -> CommandResult { 29 | let mut files = Vec::new(); 30 | 31 | // Helper function to determine if an entry is a hidden directory or file 32 | fn is_hidden(entry: &DirEntry) -> bool { 33 | entry 34 | .file_name() 35 | .to_str() 36 | .map(|s| s.starts_with('.')) 37 | .unwrap_or(false) 38 | } 39 | 40 | for entry in WalkDir::new(&path) 41 | .into_iter() 42 | .filter_entry(|e| !is_hidden(e)) 43 | { 44 | match entry { 45 | Ok(e) => { 46 | if e.file_type().is_file() { 47 | files.push(e.path().to_string_lossy().into_owned()); 48 | } 49 | } 50 | Err(e) => println!("Error: {}", e), 51 | } 52 | } 53 | 54 | if files.is_empty() { 55 | println!("No files found in the given directory."); 56 | } 57 | 58 | Ok(FileList { files }) 59 | } 60 | 61 | #[command] 62 | async fn read_file_content(path: String) -> Result { 63 | let path = Path::new(&path); 64 | 65 | let mut file = match fs::File::open(&path) { 66 | Ok(file) => file, 67 | Err(e) => return Err(e.to_string()), 68 | }; 69 | 70 | let mut contents = String::new(); 71 | match file.read_to_string(&mut contents) { 72 | Ok(_) => Ok(contents), 73 | Err(e) => Err(e.to_string()), 74 | } 75 | } 76 | 77 | #[command] 78 | async fn write_to_file(path: String, content: String) -> Result<(), String> { 79 | let path = Path::new(&path); 80 | let mut file = match File::create(&path) { 81 | Ok(file) => file, 82 | Err(e) => return Err(e.to_string()), 83 | }; 84 | 85 | match file.write_all(content.as_bytes()) { 86 | Ok(_) => Ok(()), 87 | Err(e) => Err(e.to_string()), 88 | } 89 | } 90 | 91 | #[command] 92 | async fn path_exists(path: String) -> bool { 93 | Path::new(&path).exists() 94 | } 95 | 96 | #[command] 97 | async fn delete_file(path: String) -> Result<(), String> { 98 | let path = Path::new(&path); 99 | fs::remove_file(&path).map_err(|e| e.to_string()) 100 | } 101 | 102 | #[command] 103 | async fn rename_file(old_path: String, new_path: String) -> Result<(), String> { 104 | std::fs::rename(&old_path, &new_path).map_err(|e| e.to_string()) 105 | } 106 | 107 | fn main() { 108 | tauri::Builder::default() 109 | // .setup(|app| { 110 | // // let window = app.get_window("main").unwrap(); 111 | // // #[cfg(target_os = "macos")] 112 | // // apply_vibrancy(&window, NSVisualEffectMaterial::HudWindow, None, Some(10.0)) 113 | // // .expect("Unsupported platform! 'apply_vibrancy' is only supported on macOS"); 114 | // Ok(()) 115 | // }) 116 | .invoke_handler(tauri::generate_handler![ 117 | get_all_files, 118 | read_file_content, 119 | write_to_file, 120 | path_exists, 121 | delete_file, 122 | rename_file 123 | ]) 124 | .run(tauri::generate_context!()) 125 | .expect("error while running tauri application"); 126 | } 127 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "beforeDevCommand": "pnpm run dev", 4 | "beforeBuildCommand": "pnpm run build", 5 | "devPath": "http://localhost:1420", 6 | "distDir": "../dist", 7 | "withGlobalTauri": false 8 | }, 9 | "package": { 10 | "productName": "Ibis", 11 | "version": "0.1.0" 12 | }, 13 | "tauri": { 14 | "updater": { 15 | "active": false, 16 | "dialog": true, 17 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM5MEFEN0I1RDEyMzU2MDEKUldRQlZpUFJ0ZGNLT1llVllUOUVQU2RCb1VWM0JMeGM5RGV1azhzVHoxbFgyTW9TaG4yQnhSc1YK", 18 | "windows": { 19 | "installMode": "passive", 20 | "installerArgs": [] 21 | } 22 | }, 23 | 24 | "allowlist": { 25 | "all": false, 26 | "fs": { 27 | "scope": [""], 28 | "all": true, 29 | "readFile": true, 30 | "writeFile": true, 31 | "readDir": true, 32 | "copyFile": true, 33 | "createDir": true, 34 | "removeDir": true, 35 | "removeFile": true, 36 | "renameFile": true, 37 | "exists": true 38 | }, 39 | "http": { 40 | "all": true, 41 | "request": true, 42 | "scope": ["http://localhost/*", "http://localhost:3323/json"] 43 | }, 44 | "path": { 45 | "all": true 46 | }, 47 | "globalShortcut": { 48 | "all": true 49 | }, 50 | "window": { 51 | "all": false, 52 | "close": true, 53 | "hide": true, 54 | "show": true, 55 | "maximize": true, 56 | "minimize": true, 57 | "unmaximize": true, 58 | "unminimize": true, 59 | "startDragging": true, 60 | "setDecorations": false 61 | }, 62 | 63 | "shell": { 64 | "sidecar": false, 65 | "all": false, 66 | "open": true 67 | }, 68 | "dialog": { 69 | "all": true, 70 | "ask": true, 71 | "confirm": true, 72 | "message": true, 73 | "open": true, 74 | "save": true 75 | } 76 | }, 77 | "bundle": { 78 | "active": true, 79 | "targets": "all", 80 | "identifier": "com.opps.dev", 81 | "icon": [ 82 | "icons/32x32.png", 83 | "icons/128x128.png", 84 | "icons/128x128@2x.png", 85 | "icons/icon.icns", 86 | "icons/icon.ico" 87 | ] 88 | }, 89 | 90 | "security": { 91 | "csp": null 92 | }, 93 | "macOSPrivateApi": true, 94 | "windows": [ 95 | { 96 | "fullscreen": false, 97 | "resizable": true, 98 | "title": "Ibis", 99 | "width": 1200, 100 | "height": 800, 101 | "decorations": false, 102 | "transparent": true 103 | } 104 | ] 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/assets/fonts/BerkeleyMonoVariable-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src/assets/fonts/BerkeleyMonoVariable-Italic.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/BerkeleyMonoVariable-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src/assets/fonts/BerkeleyMonoVariable-Regular.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/Geist-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src/assets/fonts/Geist-Bold.otf -------------------------------------------------------------------------------- /src/assets/fonts/Geist-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src/assets/fonts/Geist-Medium.otf -------------------------------------------------------------------------------- /src/assets/fonts/Geist-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src/assets/fonts/Geist-Regular.otf -------------------------------------------------------------------------------- /src/assets/fonts/Geist-SemiBold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src/assets/fonts/Geist-SemiBold.otf -------------------------------------------------------------------------------- /src/assets/fonts/GeistMono-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src/assets/fonts/GeistMono-Bold.otf -------------------------------------------------------------------------------- /src/assets/fonts/GeistMono-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src/assets/fonts/GeistMono-Medium.otf -------------------------------------------------------------------------------- /src/assets/fonts/GeistMono-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src/assets/fonts/GeistMono-Regular.otf -------------------------------------------------------------------------------- /src/assets/fonts/GeistMono-SemiBold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src/assets/fonts/GeistMono-SemiBold.otf -------------------------------------------------------------------------------- /src/assets/fonts/Satoshi-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src/assets/fonts/Satoshi-Bold.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/Satoshi-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src/assets/fonts/Satoshi-Medium.woff2 -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/scissors.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/components/components.scss: -------------------------------------------------------------------------------- 1 | @import './modal/modal.scss'; 2 | @import './date-picker/datepicker.scss'; 3 | @import './page-titlebar/_page-titlebar.scss'; 4 | @import './editor/editor'; 5 | @import './sidebar/sidebar'; 6 | @import './editor/editor-components'; 7 | @import './search/search-dialog'; 8 | @import './shared/error-boundary'; 9 | @import './tooltip/tooltip'; 10 | @import './splash-screen/splash-screen'; 11 | -------------------------------------------------------------------------------- /src/components/date-picker/DatePicker.Day.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { getLocalTimeZone, isSameDay, today } from '@internationalized/date'; 4 | import clsx from 'clsx'; 5 | import { useCalendarCell } from 'react-aria'; 6 | 7 | export function DatePickerDay({ state, date, showDotIndicator: showDotIndicatorFn }) { 8 | let ref = React.useRef(null); 9 | let now = today(getLocalTimeZone()); 10 | const isToday = isSameDay(now, date); 11 | 12 | const showDotIndicator = showDotIndicatorFn?.(new Date(date)) || false; 13 | 14 | let { cellProps, buttonProps, isSelected, isOutsideVisibleRange, isDisabled, formattedDate } = 15 | useCalendarCell({ date }, state, ref); 16 | 17 | return ( 18 | 19 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/date-picker/DatePicker.Grid.tsx: -------------------------------------------------------------------------------- 1 | import { getWeeksInMonth } from '@internationalized/date'; 2 | import { useCalendarGrid, useLocale } from 'react-aria'; 3 | 4 | import { DatePickerDay } from './DatePicker.Day'; 5 | 6 | export function DatePickerGrid({ state, showDotIndicator, ...props }) { 7 | let { locale } = useLocale(); 8 | let { gridProps, headerProps, weekDays } = useCalendarGrid({ ...props }, state); 9 | 10 | let weeksInMonth = getWeeksInMonth(state.visibleRange.start, locale); 11 | 12 | return ( 13 | 14 | 15 | 16 | {weekDays.map((day, index) => ( 17 | 18 | ))} 19 | 20 | 21 | 22 | {[...new Array(weeksInMonth).keys()].map((weekIndex) => ( 23 | 24 | {state 25 | .getDatesInWeek(weekIndex) 26 | .map((date: Date, i: number) => 27 | date ? ( 28 | 34 | ) : ( 35 | 39 | ))} 40 | 41 |
{day}
36 | ), 37 | )} 38 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/components/date-picker/DatePicker.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | import { CalendarDate, createCalendar, parseDate } from '@internationalized/date'; 4 | import { ChevronLeft, ChevronRight } from 'lucide-react'; 5 | import { PressEvent, useCalendar, useLocale } from 'react-aria'; 6 | import { useCalendarState } from 'react-stately'; 7 | 8 | import { DatePickerGrid } from './DatePicker.Grid'; 9 | 10 | //@ts-ignore 11 | const Button = ({ onPress, onFocusChange, ...rest }: { onPress?: (e: PressEvent) => void }) => { 12 | //@ts-ignore 13 | return 54 | 57 | 58 | 59 | 60 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /src/components/date-picker/date-utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addWeeks, 3 | endOfMonth as dfEndOfMonth, 4 | isSameDay as dfIsSameDay, 5 | startOfMonth as dfStartOfMonth, 6 | startOfWeek as dfStartOfWeek, 7 | endOfWeek as dfendOfWeek, 8 | format, 9 | isWithinInterval, 10 | } from 'date-fns'; 11 | 12 | /** 13 | * 14 | * @param date 15 | * @returns 16 | */ 17 | export function getStartofWeek(date: Date | string) { 18 | return dfStartOfWeek(new Date(date), { 19 | weekStartsOn: 0, 20 | }); 21 | } 22 | 23 | /** 24 | * 25 | * @param date 26 | */ 27 | export function getDaysInWeek(date: Date | string) { 28 | const endDate = dfendOfWeek(new Date(date)); 29 | const startDate = dfStartOfWeek(new Date(date)); 30 | 31 | const dates = []; 32 | 33 | // Strip hours minutes seconds etc. 34 | let currentDate = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate()); 35 | 36 | while (currentDate <= endDate) { 37 | dates.push(currentDate); 38 | currentDate = new Date( 39 | currentDate.getFullYear(), 40 | currentDate.getMonth(), 41 | currentDate.getDate() + 1, 42 | ); 43 | } 44 | 45 | return dates; 46 | } 47 | 48 | /** 49 | * 50 | * @param date 51 | */ 52 | export function getWeeksInMonth(date: Date | string) { 53 | let startOfMonth = dfStartOfMonth(new Date(date)); 54 | 55 | const weeksInMonth: number = 6; 56 | 57 | let weeks: any[] = []; 58 | 59 | for (let week = 0; week < weeksInMonth; week++) { 60 | weeks.push(getDaysInWeek(startOfMonth)); 61 | startOfMonth = addWeeks(startOfMonth, 1); 62 | } 63 | 64 | return weeks; 65 | } 66 | 67 | /** 68 | * 69 | * @param param0 70 | * @returns 71 | */ 72 | export function InCurrentMonth({ date, monthDate }: { date: Date; monthDate: Date }) { 73 | return isWithinInterval(new Date(date), { 74 | start: dfStartOfMonth(new Date(monthDate)), 75 | end: dfEndOfMonth(new Date(monthDate)), 76 | }); 77 | } 78 | 79 | export function isSameDate(date1: Date, date2: Date) { 80 | if (date1 && date2) { 81 | return dfIsSameDay(date1, date2); 82 | } else { 83 | return !date1 && !date2; 84 | } 85 | } 86 | 87 | export function getHeaderDays() { 88 | const days = getDaysInWeek(new Date()); 89 | return days.map((day: string | Date) => format(new Date(day), 'EEE')); 90 | } 91 | -------------------------------------------------------------------------------- /src/components/date-picker/datepicker.scss: -------------------------------------------------------------------------------- 1 | .datepicker { 2 | padding: 20px 10px; 3 | border-radius: 8px; 4 | 5 | font-family: var(--current-font); 6 | font-weight: 450; 7 | 8 | --cell-width: 30px; 9 | --cell-height: 30px; 10 | 11 | * { 12 | // outline: 1px dotted red; 13 | } 14 | 15 | &-header { 16 | display: flex; 17 | align-items: center; 18 | padding: 0px 10px; 19 | padding-bottom: 20px; 20 | gap: 10px; 21 | h2 { 22 | font-weight: 550; 23 | font-size: 1.1rem; 24 | margin-right: auto; 25 | } 26 | 27 | button { 28 | all: unset; 29 | cursor: pointer; 30 | width: 26px; 31 | height: 26px; 32 | display: flex; 33 | justify-content: center; 34 | align-items: center; 35 | border-radius: 99999px; 36 | background-color: var(--main-background); 37 | 38 | &:hover { 39 | background-color: var(--hover-background); 40 | border-color: var(--main-border); 41 | } 42 | } 43 | } 44 | 45 | table { 46 | width: 100%; 47 | border-collapse: separate; 48 | border-spacing: 20px 10px; 49 | } 50 | 51 | thead { 52 | th { 53 | height: var(--cell-height); 54 | font-weight: inherit; 55 | color: var(--subtitle-text-color); 56 | } 57 | } 58 | 59 | tbody { 60 | td { 61 | &:last-child { 62 | border-right: none; 63 | } 64 | 65 | &:first-child { 66 | border-left: none; 67 | } 68 | 69 | .cell { 70 | width: 100%; 71 | height: 100%; 72 | display: flex; 73 | align-items: center; 74 | justify-content: center; 75 | flex-direction: column; 76 | 77 | &:focus { 78 | outline: none; 79 | 80 | .date { 81 | outline: 1.5px solid var(--primary-color); 82 | outline-offset: 2px; 83 | } 84 | } 85 | 86 | &:hover { 87 | .date { 88 | outline: 1.5px solid var(--primary-color); 89 | outline-offset: 2px; 90 | } 91 | } 92 | 93 | &[hidden] { 94 | display: none; 95 | } 96 | 97 | &.today { 98 | .date { 99 | color: var(--primary-color); 100 | } 101 | } 102 | 103 | &.selected { 104 | .date { 105 | background-color: var(--primary-color); 106 | color: white; 107 | } 108 | } 109 | 110 | &.outsideVisibleRange { 111 | pointer-events: none; 112 | 113 | .date { 114 | color: var(--text-tertiary); 115 | } 116 | } 117 | 118 | .date { 119 | width: var(--cell-width); 120 | height: var(--cell-height); 121 | border-radius: 99999px; 122 | text-align: center; 123 | display: flex; 124 | align-items: center; 125 | justify-content: center; 126 | font-variant-numeric: tabular-nums; 127 | } 128 | 129 | .dot-indicator { 130 | color: var(--text-tertiary); 131 | line-height: 1; 132 | margin-top: 2px; 133 | } 134 | } 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/components/editor/Editor.EntryHeader.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, useState } from 'react'; 2 | 3 | import { observer } from 'mobx-react-lite'; 4 | 5 | import { entriesStore } from '@/store/entries'; 6 | 7 | import { TagEditor } from './Editor.TagEditor'; 8 | 9 | export const EntryTitle = observer(() => { 10 | const entryStore = entriesStore; 11 | const { activeEntry } = entryStore; 12 | 13 | const [inputValue, setInputValue] = useState(activeEntry!.title); 14 | 15 | const handleInputChange = (e: ChangeEvent) => { 16 | const value = e.target.value; 17 | setInputValue(value); 18 | entryStore.updateActiveEntryTitle(value); 19 | }; 20 | 21 | return ( 22 |
23 | 24 |
25 | ); 26 | }); 27 | 28 | export const EntryHeader = observer(() => { 29 | return ( 30 |
31 | 32 |
33 | 34 |
35 |
36 | ); 37 | }); 38 | -------------------------------------------------------------------------------- /src/components/editor/Editor.TagEditor.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef, useState } from 'react'; 2 | 3 | import { toJS } from 'mobx'; 4 | import { observer } from 'mobx-react-lite'; 5 | import { useOnClickOutside } from 'usehooks-ts'; 6 | 7 | import { entriesStore } from '@/store/entries'; 8 | import { tagsState } from '@/store/tags-state'; 9 | 10 | import { TagSelector } from '../tag-selector/TagSelector'; 11 | 12 | export const TagEditor = observer(() => { 13 | const entryStore = entriesStore; 14 | const { activeEntry } = entryStore; 15 | const [showTags, setShowTags] = useState(true); 16 | const tagsRef = useRef(null); 17 | 18 | const tags = useMemo(() => { 19 | return activeEntry?.tags!.map((t) => toJS(tagsState.tagsMap[t])).filter(Boolean) ?? []; 20 | }, [activeEntry?.tags, tagsState.tagsMap]); 21 | 22 | const onTagSelectorBlur = () => { 23 | setShowTags(true); 24 | }; 25 | 26 | const hasTags = tags.length > 0 && showTags; 27 | 28 | useOnClickOutside(tagsRef, onTagSelectorBlur); 29 | 30 | return ( 31 |
32 | {hasTags ? ( 33 |
setShowTags(false)} className="tags-container"> 34 | {tags?.map((tag: any) => { 35 | return ( 36 |
37 |

{tag.label}

38 |
39 | ); 40 | })} 41 |
42 | ) : ( 43 | { 45 | entriesStore.updateActiveEntryTags(tags); 46 | }} 47 | tags={tags} 48 | containerRef={tagsRef} 49 | /> 50 | )} 51 |
52 | ); 53 | }); 54 | -------------------------------------------------------------------------------- /src/components/editor/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | import { CodeHighlightNode, CodeNode } from '@lexical/code'; 4 | import { AutoLinkNode, LinkNode } from '@lexical/link'; 5 | import { ListItemNode, ListNode } from '@lexical/list'; 6 | import { $convertFromMarkdownString, $convertToMarkdownString } from '@lexical/markdown'; 7 | import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin'; 8 | import { LexicalComposer } from '@lexical/react/LexicalComposer'; 9 | import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; 10 | import { ContentEditable } from '@lexical/react/LexicalContentEditable'; 11 | import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'; 12 | import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; 13 | import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'; 14 | import { ListPlugin } from '@lexical/react/LexicalListPlugin'; 15 | import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; 16 | import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; 17 | import { TabIndentationPlugin } from '@lexical/react/LexicalTabIndentationPlugin'; 18 | import { HeadingNode, QuoteNode } from '@lexical/rich-text'; 19 | import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'; 20 | import { $getRoot } from 'lexical'; 21 | import { useDebouncedCallback } from 'use-debounce'; 22 | 23 | import AutoLinkPlugin, { validateUrl } from '@/plugins/AutolinkPlugin'; 24 | import ClickableLinkPlugin from '@/plugins/ClickableLinkPlugin'; 25 | import CodeHighlightPlugin from '@/plugins/CodeHighlightPlugin'; 26 | import FloatingMenuPlugin from '@/plugins/FloatingMenuPlugin'; 27 | import { CUSTOM_TRANSFORMERS, MarkdownShortcutPlugin } from '@/plugins/MarkdownShortcut'; 28 | import PageBreakPlugin from '@/plugins/PageBreakPlugin/PageBreakPlugin'; 29 | import { PageBreakNode } from '@/plugins/PageBreakPlugin/nodes/PageBreakNode'; 30 | import SearchDialogPlugin from '@/plugins/SearchDialogPlugin'; 31 | import SlashCommandPickerPlugin from '@/plugins/SlashCommandPicker'; 32 | import TabFocusPlugin from '@/plugins/TabFocusPlugin'; 33 | import { theme } from '@/plugins/theme'; 34 | 35 | import { EntryHeader } from './Editor.EntryHeader'; 36 | 37 | function Placeholder({ className }) { 38 | return
Write or type '/' for slash commands....
; 39 | } 40 | 41 | function MarkdownContentPlugin({ markdown }) { 42 | const [editor] = useLexicalComposerContext(); 43 | 44 | useEffect(() => { 45 | if (markdown) { 46 | editor.update(() => { 47 | const root = $getRoot(); 48 | root.clear(); 49 | $convertFromMarkdownString(markdown, CUSTOM_TRANSFORMERS, undefined, true); 50 | }); 51 | } 52 | }, [editor, markdown]); 53 | 54 | return null; 55 | } 56 | 57 | function onError(error: any) { 58 | console.error(error); 59 | } 60 | 61 | export const EDITOR_PAGES = { 62 | ENTRY: 'ENTRY', 63 | JOURNAL: 'JOURNAL', 64 | } as const; 65 | 66 | interface EditorType { 67 | id: string; 68 | content: string | null; 69 | onChange: (state: any) => void; 70 | page: keyof typeof EDITOR_PAGES; 71 | extendTheme?: {}; 72 | placeholderClassName?: string; 73 | } 74 | 75 | export const Editor = ({ 76 | id, 77 | content, 78 | onChange, 79 | page, 80 | extendTheme, 81 | placeholderClassName = 'editor-placeholder', 82 | }: EditorType) => { 83 | const markdownRef = useRef(); 84 | 85 | const editorConfig = { 86 | namespace: 'ContentEditor', 87 | theme: { 88 | ...theme, 89 | ...extendTheme, 90 | }, 91 | onError, 92 | nodes: [ 93 | HeadingNode, 94 | ListNode, 95 | ListItemNode, 96 | QuoteNode, 97 | CodeNode, 98 | CodeHighlightNode, 99 | TableNode, 100 | TableCellNode, 101 | TableRowNode, 102 | AutoLinkNode, 103 | LinkNode, 104 | PageBreakNode, 105 | ], 106 | }; 107 | 108 | const debouncedUpdates = useDebouncedCallback(async () => { 109 | onChange(markdownRef.current); 110 | }, 750); 111 | 112 | return ( 113 | 114 | 117 | {page === EDITOR_PAGES.ENTRY && } 118 | 119 | 120 | } 121 | placeholder={} 122 | ErrorBoundary={LexicalErrorBoundary} 123 | /> 124 | { 126 | state.read(() => { 127 | markdownRef.current = $convertToMarkdownString(CUSTOM_TRANSFORMERS, undefined, true); 128 | }); 129 | debouncedUpdates(); 130 | }} 131 | /> 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | ); 149 | }; 150 | -------------------------------------------------------------------------------- /src/components/editor/FloatingLinkEditor.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react'; 2 | 3 | import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'; 4 | import { $isAtNodeEnd } from '@lexical/selection'; 5 | import { mergeRegister } from '@lexical/utils'; 6 | import { $getSelection, $isRangeSelection, SELECTION_CHANGE_COMMAND } from 'lexical'; 7 | 8 | const LowPriority = 1; 9 | 10 | function positionEditorElement(editor, rect) { 11 | if (rect === null) { 12 | editor.style.opacity = '0'; 13 | editor.style.top = '-1000px'; 14 | editor.style.left = '-1000px'; 15 | } else { 16 | editor.style.opacity = '1'; 17 | editor.style.top = `${rect.top + rect.height + window.pageYOffset + 10}px`; 18 | editor.style.left = `${ 19 | rect.left + window.pageXOffset - editor.offsetWidth / 2 + rect.width / 2 20 | }px`; 21 | } 22 | } 23 | 24 | function getSelectedNode(selection) { 25 | const anchor = selection.anchor; 26 | const focus = selection.focus; 27 | const anchorNode = selection.anchor.getNode(); 28 | const focusNode = selection.focus.getNode(); 29 | if (anchorNode === focusNode) { 30 | return anchorNode; 31 | } 32 | const isBackward = selection.isBackward(); 33 | if (isBackward) { 34 | return $isAtNodeEnd(focus) ? anchorNode : focusNode; 35 | } else { 36 | return $isAtNodeEnd(anchor) ? focusNode : anchorNode; 37 | } 38 | } 39 | 40 | function FloatingLinkEditor({ editor }) { 41 | const editorRef = useRef(null); 42 | const inputRef = useRef(null); 43 | const mouseDownRef = useRef(false); 44 | const [linkUrl, setLinkUrl] = useState(''); 45 | const [isEditMode, setEditMode] = useState(false); 46 | const [lastSelection, setLastSelection] = useState(null); 47 | 48 | const updateLinkEditor = useCallback(() => { 49 | const selection = $getSelection(); 50 | if ($isRangeSelection(selection)) { 51 | const node = getSelectedNode(selection); 52 | const parent = node.getParent(); 53 | if ($isLinkNode(parent)) { 54 | setLinkUrl(parent.getURL()); 55 | } else if ($isLinkNode(node)) { 56 | setLinkUrl(node.getURL()); 57 | } else { 58 | setLinkUrl(''); 59 | } 60 | } 61 | const editorElem = editorRef.current; 62 | const nativeSelection = window.getSelection(); 63 | const activeElement = document.activeElement; 64 | 65 | if (editorElem === null) { 66 | return; 67 | } 68 | 69 | const rootElement = editor.getRootElement(); 70 | if ( 71 | selection !== null && 72 | !nativeSelection.isCollapsed && 73 | rootElement !== null && 74 | rootElement.contains(nativeSelection.anchorNode) 75 | ) { 76 | const domRange = nativeSelection.getRangeAt(0); 77 | let rect; 78 | if (nativeSelection.anchorNode === rootElement) { 79 | let inner = rootElement; 80 | while (inner.firstElementChild != null) { 81 | inner = inner.firstElementChild; 82 | } 83 | rect = inner.getBoundingClientRect(); 84 | } else { 85 | rect = domRange.getBoundingClientRect(); 86 | } 87 | 88 | if (!mouseDownRef.current) { 89 | positionEditorElement(editorElem, rect); 90 | } 91 | setLastSelection(selection); 92 | } else if (!activeElement || activeElement.className !== 'link-input') { 93 | positionEditorElement(editorElem, null); 94 | setLastSelection(null); 95 | setEditMode(false); 96 | setLinkUrl(''); 97 | } 98 | 99 | return true; 100 | }, [editor]); 101 | 102 | useEffect(() => { 103 | return mergeRegister( 104 | editor.registerUpdateListener(({ editorState }) => { 105 | editorState.read(() => { 106 | updateLinkEditor(); 107 | }); 108 | }), 109 | 110 | editor.registerCommand( 111 | SELECTION_CHANGE_COMMAND, 112 | () => { 113 | updateLinkEditor(); 114 | return true; 115 | }, 116 | LowPriority, 117 | ), 118 | ); 119 | }, [editor, updateLinkEditor]); 120 | 121 | useEffect(() => { 122 | editor.getEditorState().read(() => { 123 | updateLinkEditor(); 124 | }); 125 | }, [editor, updateLinkEditor]); 126 | 127 | useEffect(() => { 128 | if (isEditMode && inputRef.current) { 129 | inputRef.current.focus(); 130 | } 131 | }, [isEditMode]); 132 | 133 | return ( 134 |
135 | {isEditMode ? ( 136 | { 141 | setLinkUrl(event.target.value); 142 | }} 143 | onKeyDown={(event) => { 144 | if (event.key === 'Enter') { 145 | event.preventDefault(); 146 | if (lastSelection !== null) { 147 | if (linkUrl !== '') { 148 | editor.dispatchCommand(TOGGLE_LINK_COMMAND, linkUrl); 149 | } 150 | setEditMode(false); 151 | } 152 | } else if (event.key === 'Escape') { 153 | event.preventDefault(); 154 | setEditMode(false); 155 | } 156 | }} 157 | /> 158 | ) : ( 159 | <> 160 |
161 | 162 | {linkUrl} 163 | 164 |
event.preventDefault()} 169 | onClick={() => { 170 | setEditMode(true); 171 | }} 172 | /> 173 |
174 | 175 | )} 176 |
177 | ); 178 | } 179 | 180 | export default FloatingLinkEditor; 181 | -------------------------------------------------------------------------------- /src/components/editor/_code-highlight.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunday-studio/ibis/f8cd73218006f42dce64855e10a21618d59f25f0/src/components/editor/_code-highlight.scss -------------------------------------------------------------------------------- /src/components/editor/_editor-components.scss: -------------------------------------------------------------------------------- 1 | .component-picker-menu { 2 | width: 240px; 3 | padding: 6px; 4 | border-radius: 8px; 5 | 6 | // TODO: switch this to offset 7 | margin-top: 25px; 8 | 9 | ul { 10 | padding: 0; 11 | list-style: none; 12 | margin: 0; 13 | 14 | &:-webkit-scrollbar { 15 | display: none; 16 | } 17 | } 18 | 19 | li { 20 | padding: 0; 21 | display: flex; 22 | align-items: center; 23 | justify-content: flex-start; 24 | padding: 4px; 25 | border-radius: 6px; 26 | gap: 8px; 27 | font-size: 15px; 28 | color: var(--text-primary); 29 | 30 | &[aria-selected='true'] { 31 | background-color: var(--hover-background); 32 | box-shadow: 0 0 0 1px var(--dark-border); 33 | } 34 | 35 | .icon { 36 | width: 24px; 37 | height: 24px; 38 | display: flex; 39 | justify-content: center; 40 | align-items: center; 41 | } 42 | } 43 | } 44 | 45 | // Floating Menu 46 | .floating-menu { 47 | padding: 4px; 48 | border-radius: 10px; 49 | display: flex; 50 | gap: 4px; 51 | 52 | &__item { 53 | width: 32px; 54 | height: 32px; 55 | display: flex; 56 | justify-content: center; 57 | align-items: center; 58 | border-radius: 6px; 59 | cursor: pointer; 60 | color: var(--text-primary); 61 | 62 | &.item-active { 63 | background-color: var(--active-background); 64 | box-shadow: 0 0 0 1px var(--darker-border); 65 | } 66 | 67 | &:hover { 68 | background-color: var(--hover-background); 69 | box-shadow: 0 0 0 1px var(--main-border); 70 | } 71 | 72 | .icon { 73 | display: flex; 74 | justify-content: center; 75 | align-items: center; 76 | } 77 | } 78 | 79 | .floating-link-input { 80 | display: flex; 81 | align-items: center; 82 | gap: 4px; 83 | 84 | .icon { 85 | display: flex; 86 | justify-content: center; 87 | align-items: center; 88 | width: 32px; 89 | height: 32px; 90 | border-radius: 6px; 91 | cursor: pointer; 92 | 93 | &:hover { 94 | background-color: var(--hover-background); 95 | } 96 | } 97 | .vr-divider { 98 | width: 1px; 99 | background-color: var(--main-border); 100 | height: 80%; 101 | margin: 0 2px; 102 | } 103 | 104 | input { 105 | padding: 0 4px; 106 | height: 100%; 107 | font-size: 14px; 108 | background-color: var(--panel-background); 109 | } 110 | } 111 | } 112 | 113 | // entry header styling 114 | .entry-header { 115 | margin-bottom: 20px; 116 | color: var(--text-primary); 117 | 118 | .entry-input { 119 | margin-bottom: 20px; 120 | input { 121 | all: unset; 122 | width: 100%; 123 | font-size: 30px; 124 | font-weight: 600; 125 | line-height: 1; 126 | } 127 | } 128 | 129 | .tags { 130 | margin-top: 5px; 131 | 132 | &-container { 133 | display: flex; 134 | justify-content: flex-start; 135 | align-items: center; 136 | flex-wrap: wrap; 137 | gap: 5px; 138 | min-height: 39px; 139 | 140 | .tag { 141 | border-radius: var(--sm-radii); 142 | box-shadow: 0 0 0 1.5px var(--main-border); 143 | 144 | &:hover { 145 | background-color: var(--hover-background); 146 | } 147 | 148 | p { 149 | font-weight: 500; 150 | text-transform: lowercase; 151 | padding: 2px 8px; 152 | font-size: 14px; 153 | } 154 | } 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/components/editor/_editor.scss: -------------------------------------------------------------------------------- 1 | .editor-input { 2 | color: var(--text-primary); 3 | font-family: var(--current-font); 4 | } 5 | 6 | .editor-code { 7 | background-color: var(--hover-background); 8 | box-shadow: 0 0 0 1px var(--main-border); 9 | display: block; 10 | padding: 10px 16px; 11 | line-height: 1.2; 12 | margin: 0; 13 | margin-top: 8px; 14 | margin-bottom: 8px; 15 | tab-size: 2; 16 | white-space: pre; 17 | overflow-x: auto; 18 | position: relative; 19 | border-radius: var(--lg-radii); 20 | } 21 | 22 | // start inline formatting 23 | .editor-text-bold { 24 | font-weight: 600; 25 | } 26 | 27 | .editor-text-italic { 28 | font-style: italic; 29 | font-family: var(--italic-font); 30 | } 31 | 32 | .editor-text-underline { 33 | text-decoration: underline; 34 | } 35 | 36 | .editor-text-strikethrough { 37 | text-decoration: line-through; 38 | } 39 | 40 | .editor-text-underlineStrikethrough { 41 | text-decoration: underline line-through; 42 | } 43 | 44 | .editor-text-code { 45 | box-shadow: 0 0 0 1px var(--main-border); 46 | border-radius: var(--sm-radii); 47 | padding: 1px 4px; 48 | background-color: var(--hover-background); 49 | } 50 | // end of inline formatting 51 | 52 | .editor-link { 53 | color: var(--blue-10); 54 | text-decoration: none; 55 | cursor: pointer; 56 | 57 | &:hover { 58 | text-decoration: underline; 59 | } 60 | } 61 | 62 | .editor-paragraph { 63 | margin: 0; 64 | position: relative; 65 | margin-bottom: 8px; 66 | font-weight: 500; 67 | } 68 | 69 | .editor-heading-h1 { 70 | margin-top: 10px; 71 | margin-bottom: 15px; 72 | } 73 | 74 | .editor-heading-h2 { 75 | margin: 10px 0; 76 | } 77 | 78 | .editor-heading-h3 { 79 | margin: 10px 0; 80 | } 81 | 82 | .editor-quote { 83 | border-left: 4px solid var(--main-border); 84 | padding: 8px 0; 85 | padding-left: 14px; 86 | font-style: italic; 87 | font-weight: 500; 88 | } 89 | 90 | .editor-list-ol, 91 | .editor-list-ul { 92 | padding: 0; 93 | margin: 0; 94 | font-weight: 500; 95 | } 96 | 97 | .editor-listitem { 98 | margin: 8px 32px 8px 26px; 99 | 100 | &::marker { 101 | color: var(--text-secondary); 102 | } 103 | } 104 | 105 | .editor-nested-listitem { 106 | list-style-type: none; 107 | } 108 | 109 | .editor-listItemChecked, 110 | .editor-listItemUnchecked { 111 | position: relative; 112 | cursor: pointer; 113 | margin-left: 6px; 114 | list-style-type: none; 115 | padding-left: 24px; 116 | outline: none; 117 | 118 | span { 119 | line-height: 1; 120 | } 121 | 122 | &::before { 123 | content: ''; 124 | width: 14px; 125 | height: 14px; 126 | 127 | top: 50%; 128 | transform: translate(0%, -50%); 129 | 130 | left: 0; 131 | cursor: pointer; 132 | position: absolute; 133 | border-radius: var(--xs-radii); 134 | } 135 | } 136 | 137 | .editor-listItemChecked { 138 | span { 139 | text-decoration: line-through; 140 | color: var(--text-secondary); 141 | } 142 | } 143 | 144 | .editor-listItemChecked::before { 145 | box-shadow: 0 0 0 1.5px var(--primary-color); 146 | background-repeat: no-repeat; 147 | background-color: var(--primary-color); 148 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); 149 | } 150 | 151 | .editor-listItemUnchecked::before { 152 | box-shadow: 0 0 0 1.5px var(--dark-border); 153 | } 154 | 155 | .editor-nested-listitem { 156 | list-style-type: none; 157 | } 158 | .editor-nested-listitem:before, 159 | .editor-nested-listitem:after { 160 | display: none; 161 | } 162 | 163 | .editor-placeholder, 164 | .daily-note-placeholder { 165 | color: var(--text-secondary); 166 | overflow: hidden; 167 | position: absolute; 168 | text-overflow: ellipsis; 169 | left: 0; 170 | top: 0; 171 | font-size: 16px; 172 | pointer-events: none; 173 | padding-left: 4px; 174 | } 175 | 176 | .editor-placeholder { 177 | top: 156px; 178 | left: 80px; 179 | } 180 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './search/SearchDialog'; 2 | export * from './tag-selector/TagSelector'; 3 | export * from './tooltip/Tooltip'; 4 | -------------------------------------------------------------------------------- /src/components/journal/Calendar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function DailyCalendar() { 4 | return ( 5 |
6 |
7 |

Calendar

8 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/components/journal/Note.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import * as Popover from '@radix-ui/react-popover'; 4 | import clsx from 'clsx'; 5 | import { format, isToday } from 'date-fns'; 6 | import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react'; 7 | import { observer } from 'mobx-react-lite'; 8 | 9 | import { getDayPercentageCompleted } from '@/lib/utils'; 10 | import { journalEntryState } from '@/store/journal-state'; 11 | 12 | import { DatePicker } from '../date-picker/DatePicker'; 13 | import { EDITOR_PAGES, Editor } from '../editor/Editor'; 14 | 15 | function getDateValues(d: string) { 16 | if (!d) return {}; 17 | 18 | const date = new Date(d); 19 | 20 | return { 21 | dateNumber: format(date, 'dd'), 22 | day: format(date, 'EEEE'), 23 | month: format(date, 'MMMM'), 24 | year: format(date, 'uuuu'), 25 | isToday: isToday(date), 26 | }; 27 | } 28 | 29 | const DailyPage = observer(() => { 30 | const { journalEntry } = journalEntryState; 31 | 32 | const dateValues = getDateValues(journalEntry?.date); 33 | 34 | const updatePercentageCompleted = () => { 35 | const dayCompleted = getDayPercentageCompleted(); 36 | let root = document.documentElement; 37 | root.style.setProperty('--percentage-completed', `${dayCompleted}%`); 38 | }; 39 | 40 | useEffect(() => { 41 | updatePercentageCompleted(); 42 | const intervalId = setInterval( 43 | () => { 44 | updatePercentageCompleted(); 45 | }, 46 | 45 * 60 * 1000, 47 | ); 48 | return () => clearInterval(intervalId); 49 | }, [dateValues.day]); 50 | 51 | return ( 52 | 53 |
54 |
55 |
56 |

61 | {dateValues.dateNumber} 62 |

63 |
64 |

{dateValues?.day}

65 |

{dateValues?.month}

66 |

{dateValues?.year}

67 |
68 |
69 | 70 |
71 | 72 | 75 | 76 | 79 | 82 |
83 |
84 | 85 |
86 |
87 | {journalEntry && ( 88 | journalEntryState.saveContent(state)} 92 | content={journalEntry.content} 93 | id={journalEntry.id} 94 | /> 95 | )} 96 |
97 |
98 |
99 | 100 | 101 | 102 | {/* TODO: not sure why this is breaking but fix it later. something to do with the `parseDate` function; 103 | too tired to worry about this */} 104 | journalEntryState.goToDate(date)} 106 | value={journalEntry?.date} 107 | showDotIndicator={(date: Date) => journalEntryState.showDotIndicator(date)} 108 | /> 109 | 110 | 111 |
112 | ); 113 | }); 114 | 115 | export default DailyPage; 116 | -------------------------------------------------------------------------------- /src/components/modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | import { createPortal } from 'react-dom'; 4 | 5 | // import './Modal.scss'; 6 | 7 | interface ModalProps { 8 | onClose: () => void; 9 | children: any; 10 | title?: string; 11 | closeOnClickOutside?: boolean; 12 | className?: string; 13 | isDialog?: boolean; 14 | } 15 | 16 | function PortalImpl({ onClose, children, title, closeOnClickOutside, isDialog, className = '' }) { 17 | const modalRef = useRef(null); 18 | 19 | const propClassName = className ?? ''; 20 | 21 | useEffect(() => { 22 | if (modalRef.current !== null) { 23 | modalRef.current.focus(); 24 | } 25 | }, []); 26 | 27 | useEffect(() => { 28 | let modalOverlayElement = null; 29 | const handler = (event) => { 30 | if (event.keyCode === 27) { 31 | onClose(); 32 | } 33 | }; 34 | const clickOutsideHandler = (event) => { 35 | const target = event.target; 36 | if (modalRef.current !== null && !modalRef.current.contains(target) && closeOnClickOutside) { 37 | onClose(); 38 | } 39 | }; 40 | 41 | const modelElement = modalRef.current; 42 | if (modelElement !== null) { 43 | modalOverlayElement = modelElement.parentElement; 44 | if (modalOverlayElement !== null) { 45 | modalOverlayElement.addEventListener('click', clickOutsideHandler); 46 | } 47 | } 48 | 49 | window.addEventListener('keydown', handler); 50 | 51 | return () => { 52 | window.removeEventListener('keydown', handler); 53 | if (modalOverlayElement !== null) { 54 | modalOverlayElement?.removeEventListener('click', clickOutsideHandler); 55 | } 56 | }; 57 | }, [closeOnClickOutside, onClose]); 58 | 59 | return ( 60 |
61 |
62 |
63 | {isDialog ? ( 64 |
65 |

{title}

66 | {children} 67 |
68 | ) : ( 69 |
{children}
70 | )} 71 |
72 |
73 |
74 | ); 75 | } 76 | 77 | export default function Modal({ 78 | onClose, 79 | children, 80 | title, 81 | closeOnClickOutside = false, 82 | className, 83 | isDialog, 84 | }: ModalProps) { 85 | return createPortal( 86 | 93 | {children} 94 | , 95 | document.body, 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /src/components/modal/modal.scss: -------------------------------------------------------------------------------- 1 | .modal__container { 2 | padding: 20px; 3 | min-height: 100px; 4 | min-width: 400px; 5 | display: flex; 6 | flex-grow: 0px; 7 | background-color: var(--panel-background); 8 | flex-direction: column; 9 | position: relative; 10 | box-shadow: 11 | 0 4px 6px -1px rgba(0, 0, 0, 0.1), 12 | 0 2px 4px -2px rgba(0, 0, 0, 0.1); 13 | border-radius: 6px; 14 | 15 | .modal__title { 16 | margin: 0; 17 | font-size: 17px; 18 | font-weight: 600; 19 | margin-bottom: 10px; 20 | } 21 | } 22 | 23 | .modal__overlay { 24 | display: flex; 25 | justify-content: center; 26 | align-items: center; 27 | position: fixed; 28 | flex-direction: column; 29 | top: 0px; 30 | bottom: 0px; 31 | left: 0px; 32 | right: 0px; 33 | background-color: rgba(40, 40, 40, 0.6); 34 | flex-grow: 0px; 35 | flex-shrink: 1px; 36 | z-index: 100; 37 | border-radius: 8px; 38 | } 39 | -------------------------------------------------------------------------------- /src/components/page-titlebar/PageTitleBar.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { PanelLeft } from 'lucide-react'; 3 | import { observer } from 'mobx-react-lite'; 4 | 5 | import { Tooltip } from '@/components'; 6 | import { SAFE_LOCATION_KEY } from '@/lib/constants'; 7 | import { getData } from '@/lib/storage'; 8 | import { appState } from '@/store/app-state'; 9 | import { entriesStore } from '@/store/entries'; 10 | 11 | export const PageTitleBar = observer(() => { 12 | const currentEntryTitle = entriesStore.activeEntry?.title; 13 | 14 | const SAFE_NAME = getData(SAFE_LOCATION_KEY)?.split('/').pop(); 15 | 16 | return ( 17 |
23 | {!appState.sidebarIsOpen && ( 24 |
25 | appState.toggleSidebarOpenState()}> 30 | 31 |
32 | } 33 | /> 34 |
35 | )} 36 | 37 |

{currentEntryTitle}

38 | 39 | {SAFE_NAME && ( 40 |
41 | {SAFE_NAME}

} /> 42 |
43 | )} 44 |
45 | ); 46 | }); 47 | -------------------------------------------------------------------------------- /src/components/page-titlebar/_page-titlebar.scss: -------------------------------------------------------------------------------- 1 | .page-titlebar { 2 | // position: sticky; 3 | top: 0; 4 | width: 100%; 5 | display: flex; 6 | justify-content: center; 7 | align-items: center; 8 | background-color: var(--main-background); 9 | z-index: 2; 10 | border-bottom: 1px solid var(--main-border); 11 | color: var(--text-primary); 12 | height: var(--page-title-height); 13 | 14 | &.side-opened { 15 | p { 16 | margin-left: auto; 17 | } 18 | 19 | .single-title { 20 | margin-left: auto; 21 | } 22 | } 23 | 24 | .sidebar-toggle-container { 25 | display: flex; 26 | align-items: center; 27 | justify-content: center; 28 | border-right: 1px solid var(--main-border); 29 | padding: 0 12px; 30 | margin-right: auto; 31 | height: 100%; 32 | } 33 | 34 | .single-title { 35 | font-size: 15px; 36 | font-weight: 500; 37 | } 38 | 39 | .sidebar-toggle { 40 | margin-right: auto; 41 | } 42 | 43 | .current-safe { 44 | margin-left: auto; 45 | 46 | .safe-name { 47 | padding: 3px 6px; 48 | border-radius: var(--md-radii); 49 | background: var(--panel-background); 50 | font-size: 12px; 51 | font-weight: 500; 52 | margin-right: 12px; 53 | box-shadow: 0 0 0 1px var(--main-border); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/components/search/SearchDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import { Command } from 'cmdk'; 4 | import { 5 | BadgePlus, 6 | Construction, 7 | Library, 8 | LucideIcon, 9 | MonitorDown, 10 | Palette, 11 | // Play, 12 | RefreshCcwDot, 13 | Search, 14 | // Sparkles, 15 | } from 'lucide-react'; 16 | import { observer } from 'mobx-react-lite'; 17 | // import { nanoid } from 'nanoid'; 18 | import { useNavigate } from 'react-router-dom'; 19 | 20 | // import { generateNewDirectory } from '@/lib/auth/auth-helpers'; 21 | import { ACTIVE_ENTRY, SAFE_LOCATION_KEY } from '@/lib/constants'; 22 | import { loadDirectoryContent, resetAppState } from '@/lib/data-engine/syncing-helpers'; 23 | import { searchEngine } from '@/lib/search/search-engine'; 24 | import { clearData, getData } from '@/lib/storage'; 25 | // import { runMigration } from '@/migrations/file-date-pattern.migrate'; 26 | import { appState } from '@/store/app-state'; 27 | import { searchStore } from '@/store/search'; 28 | // import { meili } from '@/lib/data-engine/syncing-engine'; 29 | 30 | type ActionProps = { 31 | name: string; 32 | onClick: () => void; 33 | icon: LucideIcon; 34 | }; 35 | 36 | const ActionItem = (props: ActionProps) => { 37 | const { name, onClick, icon: Icon } = props; 38 | return ( 39 | 40 |
{}
41 |

{name}

42 |
43 | ); 44 | }; 45 | 46 | export const SearchDialog = observer(() => { 47 | const { showSearchModal } = searchStore; 48 | const navigate = useNavigate(); 49 | const [results, setResults] = useState([]); 50 | 51 | const defaultActions: ActionProps[] = [ 52 | // { 53 | // name: 'Run Migrations', 54 | // onClick: () => { 55 | // generateNewDirectory(`/Users/cas/Desktop/ibis-tests/${nanoid()}`); 56 | // // "" 57 | // }, 58 | // icon: Play, 59 | // }, 60 | { 61 | name: 'New Entry', 62 | onClick: () => { 63 | navigate('/'); 64 | }, 65 | icon: BadgePlus, 66 | }, 67 | 68 | // { 69 | // name: 'New Highlight', 70 | // onClick: () => { 71 | // navigate('/highlight'); 72 | // }, 73 | // icon: Sparkles, 74 | // }, 75 | { 76 | name: 'New Journal log', 77 | onClick: () => { 78 | navigate('/today'); 79 | }, 80 | icon: Library, 81 | }, 82 | 83 | { 84 | name: `Toggle ${appState.theme === 'night' ? 'light' : 'dark'} mode`, 85 | onClick: () => { 86 | appState.toggleTheme(appState.theme === 'night' ? 'light' : 'night'); 87 | }, 88 | icon: Palette, 89 | }, 90 | 91 | { 92 | name: 'Reload local data', 93 | onClick: () => { 94 | const SAFEURL = getData(SAFE_LOCATION_KEY); 95 | loadDirectoryContent(SAFEURL); 96 | }, 97 | icon: RefreshCcwDot, 98 | }, 99 | { 100 | name: 'Load new safe', 101 | onClick: () => { 102 | clearData(SAFE_LOCATION_KEY); 103 | clearData(ACTIVE_ENTRY); 104 | resetAppState(); 105 | navigate('/safe'); 106 | }, 107 | icon: MonitorDown, 108 | }, 109 | 110 | { 111 | name: 'Toggle zen mode', 112 | onClick: () => { 113 | appState.toggleZenMode(); 114 | }, 115 | icon: Construction, 116 | }, 117 | ]; 118 | 119 | const handleSearch = (term: string) => { 120 | const response = searchEngine.search(`${term}-1`); 121 | setResults(response); 122 | }; 123 | 124 | const showSearchResults = results.length > 0; 125 | 126 | return ( 127 | searchStore.toggleSearchModal()} 130 | className="search-dialog" 131 | > 132 |
133 |
134 | 135 |
136 | 141 |
142 | 143 | {/*
144 | {showSearchResults && 145 | results.map((result) => { 146 | return
{result.title}
; 147 | })} 148 |
*/} 149 | 150 | 151 | No results found. 152 | 153 | {defaultActions.map((action: ActionProps, index) => { 154 | return ( 155 | { 159 | action.onClick(); 160 | searchStore.toggleSearchModal(); 161 | }} 162 | /> 163 | ); 164 | })} 165 | 166 | 167 |
168 | ); 169 | }); 170 | -------------------------------------------------------------------------------- /src/components/search/_search-dialog.scss: -------------------------------------------------------------------------------- 1 | @keyframes overlayShow { 2 | from { 3 | opacity: 0; 4 | } 5 | to { 6 | opacity: 1; 7 | } 8 | } 9 | 10 | @keyframes contentShow { 11 | from { 12 | opacity: 0; 13 | transform: translate(-50%, -48%) scale(0.96); 14 | } 15 | to { 16 | opacity: 1; 17 | transform: translate(-50%, -50%) scale(1); 18 | } 19 | } 20 | 21 | [cmdk-list-sizer] { 22 | padding: 12px 8px; 23 | } 24 | 25 | [cmdk-list] { 26 | height: min(330px, calc(var(--cmdk-list-height))); 27 | max-height: 400px; 28 | overflow: auto; 29 | overscroll-behavior: contain; 30 | transition: 100ms ease; 31 | transition-property: height; 32 | background-color: var(--panel-background); 33 | border-radius: 8px; 34 | } 35 | 36 | [cmdk-overlay] { 37 | background-color: rgba(0, 0, 0, 0.7); 38 | border-radius: 10px; 39 | position: fixed; 40 | inset: 0; 41 | z-index: 3; 42 | } 43 | 44 | .search-dialog { 45 | box-shadow: 0 0.25rem 0.375rem -0.125rem #0003; 46 | position: fixed; 47 | top: 50%; 48 | left: 50%; 49 | transform: translate(-50%, -50%); 50 | width: 100vw; 51 | max-width: 550px; 52 | max-height: 85vh; 53 | animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 0.5); 54 | z-index: 4; 55 | padding: 4px; 56 | background-color: var(--active-background); 57 | border-radius: 12px; 58 | border: 0; 59 | backdrop-filter: blur(20px) saturate(190%) contrast(70%) brightness(80%); 60 | color: var(--text-primary); 61 | 62 | .search-input { 63 | display: flex; 64 | align-items: center; 65 | justify-content: center; 66 | padding: 0 8px; 67 | border-radius: 8px; 68 | 69 | .search-icon { 70 | display: flex; 71 | justify-content: center; 72 | align-items: center; 73 | } 74 | } 75 | 76 | [cmdk-input] { 77 | // background-color: var(--sidebar-background); 78 | all: unset; 79 | width: 100%; 80 | height: 40px; 81 | text-indent: 10px; 82 | font-size: 15px; 83 | 84 | &::placeholder { 85 | color: var(--text-primary); 86 | font-style: italic; 87 | } 88 | } 89 | 90 | [cmdk-group-heading] { 91 | font-weight: 600; 92 | margin-bottom: 8px; 93 | font-size: 14px; 94 | padding: 0 12px; 95 | font-family: 'Satoshi'; 96 | } 97 | 98 | [cmdk-item] { 99 | border-radius: 8px; 100 | width: 100%; 101 | padding: 8px 12px; 102 | cursor: pointer; 103 | font-size: 15px; 104 | display: flex; 105 | align-items: center; 106 | gap: 12px; 107 | content-visibility: auto; 108 | cursor: pointer; 109 | user-select: none; 110 | will-change: background, color; 111 | transition: all 150ms ease; 112 | transition-property: none; 113 | 114 | .action { 115 | &:hover { 116 | background-color: var(--hover-background); 117 | box-shadow: 0 0 0 1px var(--main-border); 118 | } 119 | 120 | &-icon { 121 | display: flex; 122 | align-items: center; 123 | justify-content: center; 124 | } 125 | } 126 | 127 | &:active { 128 | transition-property: background; 129 | background-color: var(--hover-background); 130 | box-shadow: 0 0 0 1px var(--main-border); 131 | } 132 | 133 | &[data-selected='true'] { 134 | background-color: var(--hover-background); 135 | box-shadow: 0 0 0 1px var(--main-border); 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/components/shared/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { ErrorBoundary } from 'react-error-boundary'; 3 | import { useNavigate } from 'react-router-dom'; 4 | 5 | import { SAFE_LOCATION_KEY } from '@/lib/constants'; 6 | import { clearData } from '@/lib/storage'; 7 | import { entriesStore } from '@/store/entries'; 8 | 9 | const AppErrorPage = observer(() => { 10 | const navigate = useNavigate(); 11 | 12 | const reload = () => { 13 | navigate('/today'); 14 | 15 | // TODO: figure out when to actually close the safe and reload 16 | clearData(SAFE_LOCATION_KEY); 17 | 18 | entriesStore.removeActiveEntry(); 19 | 20 | // doing this to cause a reload, not sure why going to /today doesn't cause the rerendering 21 | navigate(0); 22 | }; 23 | 24 | return ( 25 |
26 |
27 |

Some error happened. Don't worry, it's not you, it's me. We will get right to it.

28 | 31 |
32 |
33 | ); 34 | }); 35 | 36 | export function AppErrorBoundary({ children }: { children: React.ReactNode }) { 37 | return }>{children}; 38 | } 39 | -------------------------------------------------------------------------------- /src/components/shared/_error-boundary.scss: -------------------------------------------------------------------------------- 1 | .error-page { 2 | background-color: var(--main-background); 3 | height: 100vh; 4 | 5 | display: flex; 6 | justify-content: center; 7 | align-items: center; 8 | 9 | .error-message { 10 | max-width: 500px; 11 | text-align: center; 12 | display: flex; 13 | justify-items: center; 14 | align-items: center; 15 | flex-direction: column; 16 | 17 | p { 18 | font-size: 15px; 19 | } 20 | 21 | button { 22 | all: unset; 23 | font-family: var(--satoshi-font); 24 | margin-top: 10px; 25 | width: 50%; 26 | font-size: 16px; 27 | height: 38px; 28 | border-radius: 8px; 29 | background-color: var(--primary-color); 30 | border-color: var(--primary-color); 31 | color: white; 32 | font-weight: 500; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/sidebar/FolderMenu.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, useState } from 'react'; 2 | 3 | import { observer } from 'mobx-react-lite'; 4 | import { nanoid } from 'nanoid'; 5 | 6 | import { type Folder, entriesStore } from '@/store/entries'; 7 | 8 | interface FolderMenu { 9 | entryId: string; 10 | onFolderSelect: () => void; 11 | } 12 | 13 | export const FolderMenu = observer(({ entryId, onFolderSelect }) => { 14 | const folders: Array = Object.values(entriesStore.folders); 15 | 16 | const [inputValue, setInputValue] = useState(''); 17 | const [filteredFolders, setFilteredFolders] = useState(folders); 18 | 19 | const handleInputChange = (event: ChangeEvent) => { 20 | const { value } = event.target; 21 | setFilteredFolders( 22 | folders.filter((folder) => 23 | folder.name.toLocaleLowerCase().includes(value.toLocaleLowerCase()), 24 | ), 25 | ); 26 | 27 | setInputValue(value); 28 | }; 29 | 30 | const hasNoFolders = folders.length <= 0 && !Boolean(inputValue.length); 31 | const showCreateButton = filteredFolders.length >= 0 && inputValue.length > 0; 32 | 33 | return ( 34 |
35 |
36 | 42 |
43 |
    44 | {hasNoFolders && ( 45 |
  • No folders yet. Search to create your first one.
  • 46 | )} 47 | 48 | {filteredFolders?.length > 0 && ( 49 | <> 50 | {filteredFolders.map((folder: Folder) => { 51 | return ( 52 | 59 | ); 60 | })} 61 | 62 | )} 63 | 64 | {showCreateButton && ( 65 | 81 | )} 82 |
83 |
84 | ); 85 | }); 86 | -------------------------------------------------------------------------------- /src/components/sidebar/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | import clsx from 'clsx'; 4 | import { BadgePlus, DoorOpen, Search, Trash2Icon } from 'lucide-react'; 5 | import { observer } from 'mobx-react-lite'; 6 | import { useNavigate } from 'react-router-dom'; 7 | 8 | import { journalEntryState } from '@/store/journal-state'; 9 | import { searchStore } from '@/store/search'; 10 | 11 | import { type Entry, entriesStore } from '../../store/entries'; 12 | import { SidebarEntry } from './SidebarEntry'; 13 | import { SidebarFolder } from './SidebarFolder'; 14 | import { SidebarHeader } from './SidebarHeader'; 15 | 16 | const RouteLink = ({ 17 | onClick, 18 | title, 19 | icon: Icon, 20 | shortcutKey, 21 | }: { 22 | onClick: () => void; 23 | title: string; 24 | icon?: any; 25 | shortcutKey?: string; 26 | }) => { 27 | return ( 28 |
29 |
30 | {Icon && } 31 |
32 |

{title}

33 | 34 | {shortcutKey &&
{shortcutKey}
} 35 |
36 | ); 37 | }; 38 | 39 | export const Sidebar = observer(() => { 40 | const navigate = useNavigate(); 41 | 42 | function goToPage(route: string) { 43 | entriesStore.removeActiveEntry(); 44 | navigate(route); 45 | } 46 | 47 | const pinnedEntries = useMemo(() => { 48 | return entriesStore?.pinnedEntries.sort((a: Entry, b: Entry) => { 49 | return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); 50 | }); 51 | }, [entriesStore?.pinnedEntries]); 52 | 53 | const privateEntries = useMemo(() => { 54 | return entriesStore?.privateEntries.sort((a: Entry, b: Entry) => { 55 | return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); 56 | }); 57 | }, [entriesStore.privateEntries]); 58 | 59 | const folders = useMemo(() => { 60 | return entriesStore.foldersWithEntries; 61 | }, [entriesStore.foldersWithEntries]); 62 | 63 | return ( 64 |
65 | 66 |
67 |
68 | { 73 | journalEntryState.goToToday(); 74 | goToPage('/today'); 75 | }} 76 | /> 77 | {/* {}} /> */} 78 | searchStore.toggleSearchModal()} 83 | /> 84 | goToPage('/trash')} 89 | /> 90 | {/* goToPage('/templates')} 94 | shortcutKey="⌘ t" 95 | /> */} 96 | { 101 | const entryId = entriesStore.addNewEntry(); 102 | navigate(`/entry/${entryId}`); 103 | }} 104 | /> 105 |
106 |
107 | 108 |
109 |
110 |
111 | 117 | {folders?.map((folder) => { 118 | return ; 119 | })} 120 |
121 | 122 | {privateEntries.length > 0 && ( 123 |
124 |
125 |

Private

126 |
{ 129 | const id = entriesStore.addNewEntry(); 130 | navigate(`/entry/${id}`); 131 | }} 132 | /> 133 |
134 |
135 | {privateEntries?.map((entry) => ( 136 | { 138 | navigate(`/entry/${entry.id}`); 139 | entriesStore.selectEntry(entry); 140 | }} 141 | entry={entry} 142 | activeEntry={entriesStore.activeEntry} 143 | key={entry.id} 144 | /> 145 | ))} 146 |
147 |
148 | )} 149 |
150 |
151 |
152 | ); 153 | }); 154 | -------------------------------------------------------------------------------- /src/components/sidebar/SidebarEntry.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useState } from 'react'; 2 | import { useMemo } from 'react'; 3 | 4 | import * as ContextMenu from '@radix-ui/react-context-menu'; 5 | import * as Popover from '@radix-ui/react-popover'; 6 | import { clsx } from 'clsx'; 7 | import { format } from 'date-fns'; 8 | 9 | import { 10 | BadgeInfo, 11 | Columns, 12 | Copy, 13 | CornerUpRight, 14 | MoreHorizontal, 15 | Package, 16 | Pin, 17 | PinOff, 18 | Trash2, 19 | } from 'lucide-react'; 20 | import { observer } from 'mobx-react-lite'; 21 | import { toast } from 'sonner'; 22 | 23 | import { truncate } from '@/lib/utils'; 24 | 25 | import { Entry, entriesStore } from '../../store/entries'; 26 | import { FolderMenu } from './FolderMenu'; 27 | 28 | type SidebarEntry = { 29 | selectEntry: (entry: Entry) => void; 30 | entry: Entry; 31 | activeEntry?: Entry | null; 32 | }; 33 | 34 | export const SidebarEntry = observer(({ entry, activeEntry, selectEntry }: SidebarEntry) => { 35 | const isActive = entry?.id === activeEntry?.id; 36 | 37 | return ( 38 | 39 | 40 | 41 |
{ 44 | e.preventDefault(); 45 | e.stopPropagation(); 46 | selectEntry(entry); 47 | }} 48 | > 49 |

{truncate(entry.title) ?? 'Untitled'}

50 | 51 |
{ 54 | e.stopPropagation(); 55 | }} 56 | > 57 | 58 |
59 |
60 | 61 | 62 | 63 | 64 | 65 | 66 |
67 |
68 | 69 | 70 | 71 | 72 | 73 |
74 |
75 | ); 76 | }); 77 | 78 | const EntryActionOptions = observer<{ entry: Entry }>(({ entry }) => { 79 | const { pinnedEntriesId } = entriesStore; 80 | const [isDoubleClicked, setIsDoubleClicked] = useState(false); 81 | const [showFolderMenu, setShowFolderMenu] = useState(false); 82 | 83 | const isPinned = pinnedEntriesId.includes(entry?.id!); 84 | 85 | const options = useMemo(() => { 86 | return [ 87 | { 88 | title: isDoubleClicked ? 'Click again to delete' : 'Delete', 89 | action: () => 90 | isDoubleClicked ? entriesStore.deleteEntry(entry.id) : setIsDoubleClicked(true), 91 | icon: , 92 | disabled: false, 93 | active: isDoubleClicked, 94 | }, 95 | 96 | { 97 | title: 'Duplicate', 98 | action: () => entriesStore.duplicateEntry(entry), 99 | icon: , 100 | }, 101 | 102 | { 103 | title: isPinned ? 'Unpin' : 'Pin', 104 | action: () => 105 | entriesStore.updatePinned({ 106 | id: entry.id, 107 | type: isPinned ? 'REMOVE' : 'ADD', 108 | }), 109 | icon: isPinned ? : , 110 | }, 111 | 112 | { 113 | title: 'Move to', 114 | action: () => setShowFolderMenu(true), 115 | icon: , 116 | }, 117 | 118 | { 119 | title: 'Archive', 120 | action: () => toast('Note removed'), 121 | icon: , 122 | disabled: true, 123 | }, 124 | 125 | { 126 | title: 'Open in split view', 127 | action: () => {}, 128 | icon: , 129 | disabled: true, 130 | }, 131 | 132 | { 133 | title: 'Share', 134 | action: () => {}, 135 | icon: , 136 | disabled: true, 137 | }, 138 | ]; 139 | }, [pinnedEntriesId, isDoubleClicked]); 140 | 141 | if (showFolderMenu) { 142 | return setShowFolderMenu(false)} />; 143 | } 144 | 145 | return ( 146 | 147 |
152 | {options.map((option, index) => { 153 | return ( 154 | 168 | ); 169 | })} 170 | 171 |
172 |
173 |

Created on: {format(new Date(entry.createdAt), 'dd, MMMM yyy')}

174 | {entry.updatedAt && ( 175 |

Last edited on: {format(new Date(entry.createdAt), 'dd, MMM yy, h:m a')}

176 | )} 177 |
178 |
179 | 180 | ); 181 | }); 182 | -------------------------------------------------------------------------------- /src/components/sidebar/SidebarEntrySection.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useMemo, useState } from 'react'; 2 | 3 | import { Entry } from '@/store/entries'; 4 | import clsx from 'clsx'; 5 | import { Reorder, useMotionValue } from 'framer-motion'; 6 | import { ArrowDown, CornerDownRight, MoveDown, Plus } from 'lucide-react'; 7 | import { useNavigate } from 'react-router-dom'; 8 | 9 | import { useRaisedShadow } from '../../hooks/useRaisedShadow'; 10 | import { entriesStore } from '../../store/entries'; 11 | import { SidebarEntry } from './SidebarEntry'; 12 | 13 | type SidebarSectionProps = { 14 | sectionTitle: string; 15 | entries: Entry[]; 16 | actions: { 17 | newEntry: boolean; 18 | newFolder: boolean; 19 | }; 20 | }; 21 | 22 | interface EntryWrapperProps { 23 | entry: Entry; 24 | children: ReactNode; 25 | } 26 | 27 | const EntryWrapper: React.FC = ({ entry, children }) => { 28 | const y = useMotionValue(0); 29 | const boxShadow = useRaisedShadow(y); 30 | 31 | return ( 32 | 33 | {children} 34 | 35 | ); 36 | }; 37 | 38 | export const SidebarEntrySection: React.FC = ({ 39 | actions = { 40 | newEntry: true, 41 | newFolder: true, 42 | }, 43 | entries, 44 | sectionTitle, 45 | }) => { 46 | const navigate = useNavigate(); 47 | 48 | const [show, setShow] = useState(false); 49 | 50 | return ( 51 |
52 |
53 |
setShow((prev) => !prev)}> 54 |
55 | {show ? : } 56 |
57 |

Folder {sectionTitle}

58 |
59 | 60 |
65 | {entries.map((entry, index) => ( 66 |
{ 71 | entriesStore.selectEntry(entry); 72 | navigate(`/entry/${entry.id}`); 73 | }} 74 | key={index} 75 | > 76 |

{entry.title}

77 |
78 | ))} 79 |
80 |
81 |
82 | ); 83 | }; 84 | 85 | // export { SidebarEntrySection: useMemo(SidebarEntrySection)} 86 | -------------------------------------------------------------------------------- /src/components/sidebar/SidebarHeader.tsx: -------------------------------------------------------------------------------- 1 | import { appWindow } from '@tauri-apps/api/window'; 2 | import { Maximize2, Minus, PanelRight, RefreshCcw, X } from 'lucide-react'; 3 | import { useNavigate } from 'react-router-dom'; 4 | 5 | import { appState } from '@/store/app-state'; 6 | 7 | import { Tooltip } from '../tooltip/Tooltip'; 8 | 9 | export const SidebarHeader = () => { 10 | const navigate = useNavigate(); 11 | 12 | return ( 13 |
14 |
15 |
appWindow.close()}> 16 | 17 |
18 |
appWindow.minimize()}> 19 | 20 |
21 |
appWindow.maximize()}> 22 | 23 |
24 | 25 | navigate(0)}> 30 | 31 |
32 | } 33 | /> 34 |
35 | 36 | appState.toggleSidebarOpenState()} 44 | > 45 | 46 | 47 | } 48 | /> 49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/splash-screen/SplashScreen.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'framer-motion'; 2 | 3 | export const SplashScreen = () => { 4 | return ( 5 | 13 |

Logo here

14 |

{APP_VERSION}

15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/splash-screen/_splash-screen.scss: -------------------------------------------------------------------------------- 1 | .splash-screen { 2 | outline: 1px solid red; 3 | background-color: var(--gray-12); 4 | height: 100vh; 5 | align-items: center; 6 | justify-content: center; 7 | display: flex; 8 | color: var(--gray-1); 9 | backdrop-filter: blur(4px); 10 | flex-direction: column; 11 | padding: 40px 0; 12 | position: absolute; 13 | z-index: 99999; 14 | top: 0; 15 | width: 100%; 16 | height: 100%; 17 | 18 | * { 19 | outline: 1px dotted red; 20 | } 21 | 22 | .logo { 23 | margin-top: auto; 24 | } 25 | 26 | .version { 27 | font-variant-numeric: slashed-zero; 28 | margin-top: auto; 29 | font-weight: 600; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/components/svgs/checkicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/components/tag-selector/TagSelector.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject } from 'react'; 2 | 3 | import { observer } from 'mobx-react-lite'; 4 | import { MultiValue } from 'react-select'; 5 | import CreatableSelect from 'react-select/creatable'; 6 | 7 | import { tagsState } from '@/store/tags-state'; 8 | 9 | type TagSelectorProps = { 10 | isCreatable?: boolean; 11 | tags: any[]; 12 | onTagSelect: (tag: MultiValue | string[]) => void; 13 | containerRef?: RefObject; 14 | }; 15 | 16 | export const TagSelector = observer((props: TagSelectorProps) => { 17 | const { isCreatable = true, tags, onTagSelect, containerRef, ...rest } = props; 18 | 19 | const currentTagsId = tags.map((a) => a.value); 20 | 21 | return ( 22 |
23 | { 27 | const id = tagsState.createNewTag(newValue); 28 | onTagSelect([...currentTagsId, id]); 29 | }} 30 | isMulti 31 | onChange={(newValue) => { 32 | const ids = newValue.map((option) => option.value); 33 | onTagSelect(ids); 34 | }} 35 | options={tagsState.tags} 36 | {...rest} 37 | /> 38 |
39 | ); 40 | }); 41 | -------------------------------------------------------------------------------- /src/components/tooltip/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { cloneElement, useEffect, useRef } from 'react'; 2 | 3 | import { DismissButton, Overlay, usePopover } from 'react-aria'; 4 | import type { AriaPopoverProps, Placement } from 'react-aria'; 5 | import { useOverlayTrigger } from 'react-aria'; 6 | import type { OverlayTriggerState } from 'react-stately'; 7 | import { useOverlayTriggerState } from 'react-stately'; 8 | 9 | import { useHoverToggle } from './useHoverToggle'; 10 | 11 | interface TooltipContentProps extends Omit { 12 | children: React.ReactNode; 13 | state: OverlayTriggerState; 14 | } 15 | 16 | export const Popover = (props: TooltipContentProps) => { 17 | const { offset = 8, state, children } = props; 18 | 19 | let popoverRef = useRef(null); 20 | let { popoverProps, arrowProps, placement } = usePopover( 21 | { 22 | ...props, 23 | offset, 24 | popoverRef, 25 | }, 26 | state, 27 | ); 28 | 29 | return ( 30 | 31 |
32 | 33 | 34 | 35 | 36 | {children} 37 | 38 |
39 |
40 | ); 41 | }; 42 | 43 | interface TooltipProps { 44 | trigger: any; 45 | shortcuts?: string[]; 46 | content: string | React.ReactElement; 47 | shouldFlip?: boolean; 48 | placement?: Placement; 49 | leaveDuration?: number; 50 | hoverDuration?: number; 51 | } 52 | 53 | export const Tooltip = ({ trigger, content, shortcuts, ...rest }: TooltipProps) => { 54 | const { shouldFlip = false, placement = 'bottom', leaveDuration, hoverDuration, ...props } = rest; 55 | let ref = useRef(null); 56 | let state = useOverlayTriggerState({ 57 | ...props, 58 | }); 59 | let { 60 | triggerProps: { onPress, ...restOfTriggerProps }, 61 | overlayProps, 62 | } = useOverlayTrigger({ type: 'dialog' }, state, ref); 63 | 64 | useHoverToggle({ 65 | ref, 66 | leaveDuration, 67 | hoverDuration, 68 | onHoverCallback: () => state.setOpen(true), 69 | onLeaveCallback: () => state.setOpen(false), 70 | }); 71 | 72 | const contentElemenet = () => { 73 | if (typeof content === 'string') { 74 | return ( 75 |

76 | {content} 77 | {shortcuts?.length > 0 ? ( 78 | 79 | {shortcuts.map((s, i) => ( 80 | {s} 81 | ))} 82 | 83 | ) : null} 84 |

85 | ); 86 | } 87 | 88 | return content; 89 | }; 90 | 91 | return ( 92 | <> 93 | 94 | {trigger} 95 | 96 | {state.isOpen && ( 97 | 104 | {cloneElement(contentElemenet(), overlayProps)} 105 | 106 | )} 107 | 108 | ); 109 | }; 110 | -------------------------------------------------------------------------------- /src/components/tooltip/_tooltip.scss: -------------------------------------------------------------------------------- 1 | .tooltip-trigger { 2 | all: unset; 3 | } 4 | 5 | .tooltip-container { 6 | font-size: 13px; 7 | font-weight: 600; 8 | background-color: var(--panel-background); 9 | padding: 4px 6px; 10 | border-radius: var(--md-radii); 11 | box-sizing: border-box; 12 | max-width: 250px; 13 | color: var(--text-primary); 14 | box-shadow: var(--main-box-shadow); 15 | 16 | .tooltip-content { 17 | display: flex; 18 | align-items: center; 19 | 20 | .tooltip-shortcuts { 21 | margin-left: 6px; 22 | display: flex; 23 | gap: 3px; 24 | 25 | span { 26 | width: 18px; 27 | font-size: 12px; 28 | 29 | // TODO: fix this styling 30 | box-shadow: rgba(45, 35, 66, 0.4) 0 2px 4px, rgba(45, 35, 66, 0.3) 0 7px 13px -3px, 31 | #d6d6e7 0 -3px 0 inset; 32 | text-align: center; 33 | border-radius: var(--sm-radii); 34 | } 35 | } 36 | } 37 | } 38 | 39 | .arrow { 40 | position: absolute; 41 | fill: var(--panel-background); 42 | stroke: var(--main-border); 43 | stroke-width: 1px; 44 | width: 12px; 45 | height: 12px; 46 | } 47 | 48 | .arrow[data-placement='top'] { 49 | top: 100%; 50 | transform: translateX(-50%); 51 | } 52 | 53 | .arrow[data-placement='bottom'] { 54 | bottom: 100%; 55 | transform: translateX(-50%) rotate(180deg); 56 | } 57 | 58 | .arrow[data-placement='left'] { 59 | left: 100%; 60 | transform: translateY(-50%) rotate(-90deg); 61 | } 62 | 63 | .arrow[data-placement='right'] { 64 | right: 100%; 65 | transform: translateY(-50%) rotate(90deg); 66 | } 67 | -------------------------------------------------------------------------------- /src/components/tooltip/useHoverToggle.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useRef } from 'react'; 2 | 3 | interface UseHoverToggleProps { 4 | ref: RefObject; 5 | leaveDuration?: number; 6 | hoverDuration?: number; 7 | onHoverCallback: () => void; 8 | onLeaveCallback: () => void; 9 | } 10 | 11 | export function useHoverToggle({ 12 | ref, 13 | hoverDuration = 300, 14 | onHoverCallback, 15 | leaveDuration = 100, 16 | onLeaveCallback, 17 | }: UseHoverToggleProps) { 18 | const hoverTimeoutRef = useRef(null); 19 | const leaveTimeoutRef = useRef(null); 20 | 21 | useEffect(() => { 22 | const handleMouseEnter = () => { 23 | if (leaveTimeoutRef.current) { 24 | clearTimeout(leaveTimeoutRef.current); 25 | leaveTimeoutRef.current = null; 26 | } 27 | 28 | hoverTimeoutRef.current = setTimeout(() => { 29 | onHoverCallback(); 30 | }, hoverDuration); 31 | }; 32 | 33 | const handleMouseLeave = () => { 34 | if (hoverTimeoutRef.current) { 35 | clearTimeout(hoverTimeoutRef.current); 36 | hoverTimeoutRef.current = null; 37 | } 38 | leaveTimeoutRef.current = setTimeout(() => { 39 | onLeaveCallback(); 40 | }, leaveDuration); 41 | }; 42 | 43 | const element = ref.current; 44 | 45 | if (element) { 46 | element.addEventListener('mouseenter', handleMouseEnter); 47 | element.addEventListener('mouseleave', handleMouseLeave); 48 | } 49 | 50 | return () => { 51 | if (element) { 52 | element.removeEventListener('mouseenter', handleMouseEnter); 53 | element.removeEventListener('mouseleave', handleMouseLeave); 54 | } 55 | 56 | if (hoverTimeoutRef.current) { 57 | clearTimeout(hoverTimeoutRef.current); 58 | } 59 | if (leaveTimeoutRef.current) { 60 | clearTimeout(leaveTimeoutRef.current); 61 | } 62 | }; 63 | }, [ref, hoverDuration, leaveDuration, onHoverCallback, onLeaveCallback]); 64 | } 65 | 66 | export default useHoverToggle; 67 | -------------------------------------------------------------------------------- /src/hooks/useDoubleClick.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react'; 2 | 3 | type Callback = () => void; 4 | 5 | export const useDoubleClick = ({ 6 | onDoubleClick, 7 | timeout, 8 | onClick, 9 | }: { 10 | onDoubleClick: Callback; 11 | timeout?: number; 12 | onClick?: Callback; 13 | }): (() => void) => { 14 | const [clicks, setClicks] = useState(0); 15 | const timer = useRef(null); 16 | 17 | const handleClick = useCallback(() => { 18 | setClicks((prev) => prev + 1); 19 | }, []); 20 | 21 | useEffect(() => { 22 | const delay = timeout ?? 300; 23 | 24 | if (clicks === 1) { 25 | timer.current = setTimeout(() => { 26 | onClick(); 27 | setClicks(0); 28 | }, delay); 29 | } else if (clicks === 2) { 30 | if (timer.current) { 31 | clearTimeout(timer.current); 32 | } 33 | onDoubleClick(); 34 | setClicks(0); 35 | } 36 | 37 | return () => { 38 | if (timer.current) { 39 | clearTimeout(timer.current); 40 | } 41 | }; 42 | }, [clicks, onClick, onDoubleClick, timeout]); 43 | 44 | return handleClick; 45 | }; 46 | -------------------------------------------------------------------------------- /src/hooks/useModal.jsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useState } from 'react'; 2 | import * as React from 'react'; 3 | 4 | import Modal from '../components/Modal'; 5 | 6 | export default function useModal() { 7 | const [modalContent, setModalContent] = useState(null); 8 | 9 | const onClose = useCallback(() => { 10 | setModalContent(null); 11 | }, []); 12 | 13 | const modal = useMemo(() => { 14 | if (modalContent === null) { 15 | return null; 16 | } 17 | const { title, content, closeOnClickOutside } = modalContent; 18 | return ( 19 | 20 | {content} 21 | 22 | ); 23 | }, [modalContent, onClose]); 24 | 25 | const showModal = useCallback( 26 | ( 27 | title, 28 | // eslint-disable-next-line no-shadow 29 | getContent, 30 | closeOnClickOutside = false, 31 | ) => { 32 | setModalContent({ 33 | closeOnClickOutside, 34 | content: getContent(onClose), 35 | title, 36 | }); 37 | }, 38 | [onClose], 39 | ); 40 | 41 | return [modal, showModal]; 42 | } 43 | -------------------------------------------------------------------------------- /src/hooks/usePointerInteractions.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export function usePointerInteractions() { 4 | const [isPointerDown, setIsPointerDown] = useState(false); 5 | const [isPointerReleased, setIsPointerReleased] = useState(true); 6 | 7 | useEffect(() => { 8 | const handlePointerUp = () => { 9 | setIsPointerDown(false); 10 | setIsPointerReleased(true); 11 | document.removeEventListener('pointerup', handlePointerUp); 12 | }; 13 | 14 | const handlePointerDown = () => { 15 | setIsPointerDown(true); 16 | setIsPointerReleased(false); 17 | document.addEventListener('pointerup', handlePointerUp); 18 | }; 19 | 20 | document.addEventListener('pointerdown', handlePointerDown); 21 | return () => { 22 | document.removeEventListener('pointerdown', handlePointerDown); 23 | }; 24 | }, []); 25 | 26 | return { isPointerDown, isPointerReleased }; 27 | } 28 | -------------------------------------------------------------------------------- /src/hooks/useRaisedShadow.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { MotionValue, animate, useMotionValue } from 'framer-motion'; 4 | 5 | const inactiveShadow = '0px 0px 0px rgba(0,0,0,0.8)'; 6 | 7 | export function useRaisedShadow(value: MotionValue): MotionValue { 8 | const boxShadow: MotionValue = useMotionValue(inactiveShadow); 9 | 10 | useEffect(() => { 11 | let isActive = false; 12 | value.onChange((latest) => { 13 | const wasActive = isActive; 14 | if (latest !== 0) { 15 | isActive = true; 16 | if (isActive !== wasActive) { 17 | animate(boxShadow, '5px 5px 10px rgba(0,0,0,0.2)'); 18 | } 19 | } else { 20 | isActive = false; 21 | if (isActive !== wasActive) { 22 | animate(boxShadow, inactiveShadow); 23 | } 24 | } 25 | }); 26 | }, [value, boxShadow]); 27 | 28 | return boxShadow; 29 | } 30 | -------------------------------------------------------------------------------- /src/hooks/useRegisterAllShortcuts.ts: -------------------------------------------------------------------------------- 1 | import { useHotkeys } from 'react-hotkeys-hook'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { Key } from 'ts-key-enum'; 4 | 5 | import { appState, entriesStore, journalEntryState, searchStore } from '@/store/index'; 6 | 7 | export const useRegisterAllShortcuts = () => { 8 | const navigate = useNavigate(); 9 | 10 | useHotkeys(`${Key.Meta}+d`, () => appState.toggleSidebarOpenState()); 11 | useHotkeys(`${Key.Control}+d`, () => appState.toggleSidebarOpenState()); 12 | useHotkeys(`${Key.Meta}+k`, () => searchStore.toggleSearchModal()); 13 | useHotkeys(`${Key.Meta}+n`, () => { 14 | const entryId = entriesStore.addNewEntry(); 15 | navigate(`/entry/${entryId}`); 16 | }); 17 | 18 | useHotkeys(`${Key.Meta}+j`, () => { 19 | journalEntryState.goToToday(); 20 | navigate('/today'); 21 | }); 22 | 23 | useHotkeys(`${Key.Meta}+b`, () => navigate('/trash')); 24 | useHotkeys(`${Key.Meta}+t`, () => navigate('/templates')); 25 | }; 26 | -------------------------------------------------------------------------------- /src/lib/auth/auth-helpers.ts: -------------------------------------------------------------------------------- 1 | import { createDir, exists, writeTextFile } from '@tauri-apps/api/fs'; 2 | 3 | import { seedDefaultEntries, seedPinnedEntry } from '@/migrations/seed/entries.seed'; 4 | import { generateIndexSeed } from '@/migrations/seed/index.seed'; 5 | import { seedDefaultJournal } from '@/migrations/seed/journal.seed'; 6 | 7 | const createNewDirectory = async (path: string) => { 8 | const directoryExist = await exists(path); 9 | 10 | if (!directoryExist) { 11 | await createDir(path, { recursive: true }); 12 | } 13 | 14 | return; 15 | }; 16 | 17 | export const generateNewDirectory = async (name: string) => { 18 | // check if index exist then return early 19 | const indexExist = await exists(`${name}/index.json`); 20 | if (indexExist) { 21 | return; 22 | } 23 | 24 | const currentYear = new Date().getFullYear().toString(); 25 | const basePath = `${name}/${currentYear}`; 26 | 27 | const months = [ 28 | 'Jan', 29 | 'Feb', 30 | 'Mar', 31 | 'Apr', 32 | 'May', 33 | 'Jun', 34 | 'Jul', 35 | 'Aug', 36 | 'Sep', 37 | 'Oct', 38 | 'Nov', 39 | 'Dec', 40 | ]; 41 | 42 | // const files = ['index.json', 'tags.json']; 43 | 44 | // create months 45 | for (let index = 0; index < months.length; index++) { 46 | await createNewDirectory(`${basePath}/${months[index].toLowerCase()}`); 47 | } 48 | 49 | // create today directory 50 | await createNewDirectory(`${basePath}/today`); 51 | 52 | // seed new directory with dummy data 53 | try { 54 | const pinnedEntryId = await seedPinnedEntry(name); 55 | await writeTextFile( 56 | `${name}/index.json`, 57 | JSON.stringify(generateIndexSeed({ pinnedId: pinnedEntryId })), 58 | ); 59 | await seedDefaultJournal(name); 60 | await seedDefaultEntries(name); 61 | } catch (error) { 62 | console.log('trying to seed app => ', error); 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /src/lib/config.ts: -------------------------------------------------------------------------------- 1 | import packageJson from '../../package.json'; 2 | 3 | export enum ConfigKeys { 4 | SentryDSN = 'sentry_dsn', 5 | SentryAuthToken = 'sentry_auth_token', 6 | Version = 'version', 7 | Environment = 'environment', 8 | } 9 | 10 | export const Config: Record = { 11 | [ConfigKeys.SentryDSN]: import.meta.env.VITE_SENTRY_DSN ?? '', 12 | [ConfigKeys.SentryAuthToken]: import.meta.env.VITE_SENTRY_AUTH_TOKEN ?? '', 13 | [ConfigKeys.Version]: packageJson.version, 14 | [ConfigKeys.Environment]: import.meta.env.MODE, 15 | }; 16 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const CONTENT_KEY = 'ibis-content'; 2 | export const FOLDER_KEY = 'ibis-folder'; 3 | export const PINNED_KEY = 'ibis-pinned'; 4 | export const TRASH_KEY = 'ibis-trash'; 5 | export const JOURNAL_NOTES_KEY = 'ibis-journal-notes'; 6 | export const APP_STATE = 'ibis-appstate'; 7 | export const ACCESS_TOKEN = 'ibis-access-token'; 8 | export const USER_DATA = 'ibis-user-data'; 9 | export const SAFE_LOCATION_KEY = 'ibis-safe-location-test'; 10 | export const DATE_PATTERN = 'y-MM-dd'; 11 | export const ACTIVE_ENTRY = 'ibis-active-entry'; 12 | -------------------------------------------------------------------------------- /src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/react'; 2 | import { Config } from './config'; 3 | export type Environment = 'production' | 'development'; 4 | 5 | interface LoggerFactoryProps { 6 | environment: Environment; 7 | } 8 | 9 | type LogProps = { 10 | message: string; 11 | data?: any; 12 | breadcrumb?: Sentry.Breadcrumb; 13 | isProduction: boolean; 14 | level: Sentry.SeverityLevel; 15 | }; 16 | 17 | type LoggerProps = { 18 | message: string; 19 | breadcrumb?: Sentry.Breadcrumb; 20 | data?: any; 21 | }; 22 | 23 | type ErrorLoggerProps = Omit & { error: any }; 24 | 25 | const isError = (err: any): err is Error => err instanceof Error; 26 | 27 | const SENTRY_SEVERITY_MAP: Record void> = { 28 | fatal: console.error, 29 | error: console.error, 30 | warning: console.warn, 31 | info: console.info, 32 | log: console.log, 33 | debug: console.debug, 34 | }; 35 | 36 | const addBreadcrumb = ({ 37 | data, 38 | message, 39 | breadcrumb, 40 | level, 41 | }: { 42 | breadcrumb: Sentry.Breadcrumb; 43 | message?: string; 44 | data?: any; 45 | level: Sentry.SeverityLevel; 46 | }) => { 47 | Sentry.addBreadcrumb({ 48 | type: breadcrumb.type || 'info', 49 | category: breadcrumb.category || 'info', 50 | message: message ?? breadcrumb.message ?? '', 51 | data, 52 | level, 53 | }); 54 | }; 55 | 56 | const captureException = (message: string, data: any): void => { 57 | if (isError(data)) { 58 | Sentry.captureException(data, { extra: { message } }); 59 | } else { 60 | Sentry.captureException(new Error(message), { extra: data }); 61 | } 62 | }; 63 | 64 | const log = ({ message, isProduction, level, data, breadcrumb }: LogProps) => { 65 | if (!isProduction) { 66 | let consoleLevel = SENTRY_SEVERITY_MAP[level]; 67 | consoleLevel(message, data ?? ''); 68 | } 69 | 70 | if (!!breadcrumb) { 71 | addBreadcrumb({ 72 | data, 73 | breadcrumb, 74 | level: level, 75 | message, 76 | }); 77 | } 78 | 79 | if (level === 'error') { 80 | if (!isProduction) { 81 | console.error({ 82 | message, 83 | data, 84 | }); 85 | } 86 | captureException(message, data); 87 | } 88 | }; 89 | 90 | const loggerFactory = ({ environment }: LoggerFactoryProps) => { 91 | const isProduction = environment === 'production'; 92 | 93 | return { 94 | info: (props: LoggerProps): void => { 95 | log({ 96 | ...props, 97 | isProduction, 98 | level: 'info', 99 | }); 100 | }, 101 | 102 | debug: (props: LoggerProps): void => { 103 | log({ 104 | ...props, 105 | isProduction, 106 | level: 'debug', 107 | }); 108 | }, 109 | 110 | warn: (props: LoggerProps): void => { 111 | log({ 112 | ...props, 113 | isProduction, 114 | level: 'warning', 115 | }); 116 | }, 117 | 118 | error: ({ error, ...rest }: ErrorLoggerProps): void => { 119 | log({ 120 | ...rest, 121 | isProduction, 122 | level: 'error', 123 | data: error, 124 | }); 125 | }, 126 | }; 127 | }; 128 | 129 | export const logger = loggerFactory({ 130 | environment: Config.environment as Environment, 131 | }); 132 | -------------------------------------------------------------------------------- /src/lib/mobx-debounce.ts: -------------------------------------------------------------------------------- 1 | import { action } from 'mobx'; 2 | 3 | export const mobxDebounce = any>(fn: T, delay: number) => { 4 | let timeoutId: NodeJS.Timeout; 5 | 6 | return action(function (this: any, ...args: Parameters) { 7 | const context = this; 8 | 9 | clearTimeout(timeoutId); 10 | 11 | timeoutId = setTimeout(() => { 12 | fn.apply(context, args); 13 | }, delay); 14 | }) as T; 15 | }; 16 | -------------------------------------------------------------------------------- /src/lib/search/search-engine.ts: -------------------------------------------------------------------------------- 1 | import lunr from 'lunr'; 2 | import removeMarkdown from 'markdown-to-text'; 3 | 4 | import { Entry, entriesStore } from '@/store'; 5 | 6 | class SearchIndex { 7 | index = null; 8 | 9 | loadLocalData(entries, journalEntries) { 10 | try { 11 | this.index = lunr((builder) => { 12 | builder.ref('id'); 13 | builder.field('title'); 14 | builder.field('content'); 15 | 16 | for (let index = 0; index < entries.length; index++) { 17 | const entry = entries[index]; 18 | 19 | let doc = { 20 | id: entry?.fileContent?.data?.id, 21 | title: entry?.fileContent?.data?.title, 22 | content: removeMarkdown(entry.fileContent.markdown), 23 | }; 24 | 25 | builder.add(doc); 26 | } 27 | }); 28 | } catch (error) { 29 | console.error('Unable to add entries and journalEntries to search index', error); 30 | } 31 | } 32 | 33 | search(term: string) { 34 | // return this.index.search(term); 35 | let results = []; 36 | 37 | const matches = this.index.search(term); 38 | 39 | results = matches.map((match) => { 40 | // find match in entries 41 | // TODO: after migrating entries to dict, this should be an key find 42 | return entriesStore.entries.filter((entry: Entry) => entry.id === match.ref)?.[0]; 43 | }); 44 | 45 | return results; 46 | } 47 | } 48 | 49 | // export 50 | 51 | export const searchEngine = new SearchIndex(); 52 | -------------------------------------------------------------------------------- /src/lib/storage.ts: -------------------------------------------------------------------------------- 1 | export const getData = (name: string) => { 2 | const data = window?.localStorage.getItem(name); 3 | 4 | return (data && JSON.parse(data)) || null; 5 | }; 6 | 7 | export const clearData = (name: string) => { 8 | window.localStorage.removeItem(name); 9 | }; 10 | 11 | export const setData = (name: string, value: any) => { 12 | clearData(name); 13 | window.localStorage.setItem(name, JSON.stringify(value)); 14 | }; 15 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { differenceInMinutes, endOfToday, format, startOfToday } from 'date-fns'; 2 | 3 | export const formatDuplicatedTitle = (title: string, isDuplicate = false) => { 4 | const isAlreadyDuplicate = Boolean(title.match(/[()]/)); 5 | 6 | if (isAlreadyDuplicate && isDuplicate) { 7 | const valueInBracket = Number(title.match(/\((.*)\)/)?.[1]); 8 | 9 | const titleWithoutIndex = title 10 | .replace(/[{()}]/g, '') 11 | ?.slice(0, -1) 12 | ?.trim(); 13 | return `${titleWithoutIndex} (${valueInBracket + 1})`; 14 | } 15 | 16 | return `${title} (1)`; 17 | }; 18 | 19 | export const truncate = (value: string) => { 20 | if (!value) return null; 21 | 22 | return value.length >= 40 ? `${value.slice(0, 26)}...` : value; 23 | }; 24 | 25 | export function formatDateString(date: Date, pattern = 'y-MM-dd') { 26 | return format(date, pattern); 27 | } 28 | 29 | export const getDayPercentageCompleted = () => { 30 | const now: Date = new Date(); 31 | const start: Date = startOfToday(); 32 | const end: Date = endOfToday(); 33 | 34 | const totalMinutes = differenceInMinutes(end, start); 35 | const elapsedMinutes = differenceInMinutes(now, start); 36 | 37 | const remainingPercentage: number = (elapsedMinutes / totalMinutes) * 100; 38 | 39 | return Number(remainingPercentage.toFixed(2)); 40 | }; 41 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Buffer } from 'buffer'; 3 | 4 | window.Buffer = Buffer; 5 | 6 | import * as Sentry from '@sentry/react'; 7 | import ReactDOM from 'react-dom/client'; 8 | import { 9 | createRoutesFromChildren, 10 | matchRoutes, 11 | useLocation, 12 | useNavigationType, 13 | RouterProvider, 14 | } from 'react-router-dom'; 15 | 16 | import { router } from './routes/router'; 17 | import './styles/index.scss'; 18 | import { Config } from '@/lib/config'; 19 | 20 | Sentry.init({ 21 | dsn: Config.sentry_dsn, 22 | integrations: [ 23 | Sentry.breadcrumbsIntegration({ console: false }), 24 | Sentry.browserTracingIntegration(), 25 | Sentry.replayIntegration({ 26 | maskAllText: false, 27 | blockAllMedia: false, 28 | }), 29 | Sentry.reactRouterV6BrowserTracingIntegration({ 30 | useEffect: React.useEffect, 31 | useLocation, 32 | useNavigationType, 33 | createRoutesFromChildren, 34 | matchRoutes, 35 | }), 36 | Sentry.feedbackIntegration({ 37 | colorScheme: 'system', 38 | isNameRequired: false, 39 | isEmailRequired: false, 40 | showBranding: false, 41 | }), 42 | ], 43 | environment: process.env.NODE_ENV, 44 | tracesSampleRate: 1.0, 45 | tracePropagationTargets: ['localhost', /^https:\/\/yourserver\.io\/api/], 46 | replaysSessionSampleRate: 0.1, 47 | replaysOnErrorSampleRate: 1.0, 48 | }); 49 | 50 | ReactDOM.createRoot(document.getElementById('root')).render( 51 | 52 | , //{' '} 53 | , 54 | ); 55 | -------------------------------------------------------------------------------- /src/migrations/add-version.migrate.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from '@tauri-apps/api/tauri'; 2 | 3 | import { MigrationReturnType } from '.'; 4 | 5 | /** 6 | * This function updates the version in the index.json file 7 | * SCHEMA_VERSION: 0 8 | */ 9 | export const addVersionToFileSystem = async ({ 10 | updatedVersion = 0.0, 11 | indexFile, 12 | }): Promise => { 13 | const { url = '', fileContent } = indexFile; 14 | 15 | const updatedIndexFileContent = { 16 | ...fileContent, 17 | schemaVersion: updatedVersion, 18 | }; 19 | 20 | try { 21 | await invoke('write_to_file', { 22 | path: url, 23 | content: JSON.stringify(updatedIndexFileContent), 24 | }); 25 | } catch (error) { 26 | console.log('error ==>', error); 27 | } 28 | 29 | return {} as MigrationReturnType; 30 | }; 31 | -------------------------------------------------------------------------------- /src/migrations/file-date-pattern.migrate.ts: -------------------------------------------------------------------------------- 1 | import { SAFE_LOCATION_KEY } from '@/lib/constants'; 2 | import { meili } from '@/lib/data-engine/syncing-engine'; 3 | import { months } from '@/lib/data-engine/syncing-helpers'; 4 | import { getData } from '@/lib/storage'; 5 | import { getDateInStringFormat } from '@/store/journal-state'; 6 | 7 | const renameUrl = (url: string, createdAt: string) => { 8 | // rename today files 9 | if (url.includes('today')) { 10 | const urlPaths = url.split('/'); 11 | const oldFilenameIndex = urlPaths.length - 1; 12 | const newFilename = getDateInStringFormat(new Date(createdAt)); 13 | 14 | urlPaths[oldFilenameIndex] = newFilename; 15 | const newURL = urlPaths.join('/'); 16 | return `${newURL}.json`; 17 | } 18 | 19 | const isEntryUrl = months.some((month) => url.includes(month.toLowerCase())); 20 | 21 | // entries renaming 22 | if (isEntryUrl) { 23 | const urlPaths = url.split('/'); 24 | const oldFilenameIndex = urlPaths.length - 1; 25 | const oldFilenamePaths = urlPaths[oldFilenameIndex].split('-'); 26 | 27 | if (oldFilenamePaths.length === 3) return url; 28 | const newDateUrl = `${getDateInStringFormat(new Date(createdAt))}.${oldFilenamePaths[1]}`; 29 | 30 | urlPaths[oldFilenameIndex] = newDateUrl; 31 | 32 | return urlPaths.join('/'); 33 | } 34 | 35 | return url; 36 | }; 37 | 38 | const migrateFileDatePattern = async (data: { url: string; date: string }[]) => { 39 | const SAFE_URL = getData(SAFE_LOCATION_KEY); 40 | 41 | const promises = data.map(async ({ url, date }: { url: string; date: string }) => { 42 | const newURL = `${SAFE_URL}${renameUrl(url.replace(SAFE_URL, '').toLowerCase(), date)}`; 43 | 44 | return await meili.renameFile(url, newURL); 45 | }); 46 | 47 | await Promise.all(promises); 48 | }; 49 | 50 | export async function runMigration() { 51 | const SAFE_URL = getData(SAFE_LOCATION_KEY); 52 | const flatUrls = await meili.readDirectoryContent(SAFE_URL); 53 | 54 | const promises = flatUrls.map(async (url: string) => { 55 | return { 56 | url, 57 | content: await meili.readFileContent(url), 58 | }; 59 | }); 60 | const content = await Promise.all(promises); 61 | const dataToRename = content.map((data) => { 62 | return { 63 | url: data.url, 64 | date: data?.content?.createdAt || data?.content?.date, 65 | }; 66 | }); 67 | 68 | migrateFileDatePattern(dataToRename); 69 | } 70 | -------------------------------------------------------------------------------- /src/migrations/file-json-to-md.migrate.ts: -------------------------------------------------------------------------------- 1 | // TODO: clean this file later; too much weird stuff going on here 2 | import { Body, ResponseType, fetch } from '@tauri-apps/api/http'; 3 | import { Command } from '@tauri-apps/api/shell'; 4 | import { invoke } from '@tauri-apps/api/tauri'; 5 | import gm from 'gray-matter'; 6 | 7 | import { MigrationReturnType } from '.'; 8 | import { logger } from '@/lib/logger'; 9 | 10 | // TODO: move this into a migration util file 11 | export function getNewId(oldId: string) { 12 | const datePattern = /^[0-2]\d:[0-5]\d:[0-5]\d GMT[+-]\d{4} \(.+\)-/; 13 | 14 | if (typeof oldId === 'string' && datePattern.test(oldId)) { 15 | return oldId?.replace(datePattern, '')?.trim(); 16 | } else { 17 | return oldId?.trim(); 18 | } 19 | } 20 | 21 | const createFrontMatterData = (type: 'journalNotes' | 'entries', content: any) => { 22 | if (type === 'journalNotes') { 23 | return `--- 24 | id: ${getNewId(content?.id)} 25 | date: ${content?.date} 26 | --- 27 | `; 28 | } 29 | 30 | if (type === 'entries') { 31 | return `--- 32 | id: ${getNewId(content?.id)} 33 | createdAt: ${content?.createdAt} 34 | updatedAt: ${content?.updatedAt ?? content?.createdAt ?? ''} 35 | tags: ${content?.tags || []} 36 | title: ${content?.title || 'Untitled'} 37 | --- 38 | `; 39 | } 40 | }; 41 | 42 | async function convertLexicalJSONToMarkdown(content: string) { 43 | try { 44 | const response = await fetch<{ content: any }>('http://localhost:3323/json', { 45 | method: 'POST', 46 | timeout: 30, 47 | body: Body.text(content), 48 | responseType: ResponseType.JSON, 49 | }); 50 | 51 | if (response.status === 500) { 52 | return ''; 53 | } 54 | 55 | return response?.data?.content; 56 | } catch (error) { 57 | logger.error('convertLexicalJSONToMarkdown =>', error); 58 | } 59 | } 60 | 61 | export const migrateJSONTOMarkdown = async ({ data }): Promise => { 62 | const getContent = (item) => { 63 | if (item.type === 'entries') { 64 | return item?.fileContent?.content; 65 | } 66 | if (item.type === 'journalNotes') { 67 | return item?.fileContent?.noteContent; 68 | } 69 | }; 70 | 71 | const updatedData = data.map(async (item) => { 72 | if (item.type === 'index.json' || item.type === 'tags.json') { 73 | return item; 74 | } 75 | 76 | const contentString = getContent(item); 77 | const frontmatter = createFrontMatterData(item.type, item.fileContent); 78 | 79 | const content = 80 | contentString?.length === 0 || contentString === null 81 | ? '' 82 | : (await convertLexicalJSONToMarkdown(contentString)) ?? ''; 83 | 84 | const id = getNewId(item?.fileContent.id); 85 | 86 | const url = `${item.url.split('.')[0]}.${id}.md`; 87 | 88 | const data = `${frontmatter} 89 | ${content} 90 | `; 91 | 92 | try { 93 | await invoke('write_to_file', { 94 | path: url, 95 | content: data, 96 | }); 97 | } catch (error) { 98 | logger.error('error >', error); 99 | } 100 | 101 | await invoke('delete_file', { path: item.url }); 102 | 103 | const markdown = gm(data); 104 | 105 | return { 106 | type: item.type, 107 | url, 108 | fileContent: { 109 | markdown: markdown?.content, 110 | data: markdown?.data, 111 | }, 112 | }; 113 | }); 114 | 115 | return { 116 | data: await Promise.all(updatedData), 117 | }; 118 | }; 119 | -------------------------------------------------------------------------------- /src/migrations/index.ts: -------------------------------------------------------------------------------- 1 | import { addVersionToFileSystem } from './add-version.migrate'; 2 | import { migrateJSONTOMarkdown } from './file-json-to-md.migrate'; 3 | import { removeDateStringsFromIndex } from './remove-datestring-from-ids.migrate'; 4 | 5 | export type MigrationReturnType = { 6 | data?: any[]; 7 | indexFile?: any; 8 | }; 9 | 10 | type MigrationFunction = (args: any) => Promise; 11 | const VERSION_INCREMENT = 0.1; 12 | 13 | // All new migration versions must be an increment of 0.1 14 | // All migration functions always take the total array of data passed and should always return a modified version for the next function 15 | // the only exception to this rule is `addVersionToFileSystem` since we always run that first if you don't have a version 16 | const FILE_VERSION_MIGRATORS: Record = { 17 | 0: addVersionToFileSystem, 18 | 0.1: migrateJSONTOMarkdown, 19 | 0.2: removeDateStringsFromIndex, 20 | }; 21 | 22 | export const MAX_SCHEMA_VERSION: number = Math.max( 23 | ...Object.keys(FILE_VERSION_MIGRATORS).map((i) => Number(i)), 24 | ); 25 | 26 | /** 27 | * Ibis migration setup; 28 | * when there's no migration needed, the same data as content is returned 29 | */ 30 | export const migrateFileSystem = async (data: any) => { 31 | let migratedData = data; 32 | const indexFile = migratedData?.find((file: any) => file.type === 'index.json'); 33 | 34 | // let migratedIndex = indexFile; 35 | 36 | // let currentVersion = indexFile?.fileContent?.schemaVersion; 37 | 38 | // // only run this once when when there's no version 39 | // if (currentVersion === null || currentVersion === undefined) { 40 | // FILE_VERSION_MIGRATORS[0.0]?.({ data, indexFile }); 41 | // currentVersion = 0; 42 | // } 43 | 44 | // while (currentVersion < MAX_SCHEMA_VERSION) { 45 | // currentVersion = Number((currentVersion + VERSION_INCREMENT).toFixed(2)); 46 | // const currentMigrationData = await FILE_VERSION_MIGRATORS[currentVersion]?.({ 47 | // data: migratedData, 48 | // updatedVersion: currentVersion, 49 | // indexFile: migratedIndex, 50 | // }); 51 | 52 | // console.log({ 53 | // currentVersion, 54 | // curentdata: migratedData, 55 | // nextData: currentMigrationData, 56 | // // migerated, 57 | // }); 58 | 59 | // migratedIndex = currentMigrationData.indexFile ?? indexFile; 60 | // migratedData = currentMigrationData?.data ?? data; 61 | // } 62 | 63 | // await addVersionToFileSystem({ 64 | // updatedVersion: MAX_SCHEMA_VERSION, 65 | // indexFile: migratedIndex, 66 | // }); 67 | 68 | return { 69 | migratedData, 70 | indexFile, 71 | // indexFile: migratedIndex, 72 | }; 73 | }; 74 | -------------------------------------------------------------------------------- /src/migrations/remove-datestring-from-ids.migrate.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '@/lib/logger'; 2 | import { MigrationReturnType } from '.'; 3 | import { getNewId } from './file-json-to-md.migrate'; 4 | 5 | /** 6 | * This function iterates through the values of the index.json file and migrate the ids to new standard ie without the date string included 7 | * SCHEMA_VERSION: 0.2 8 | */ 9 | 10 | export const removeDateStringsFromIndex = async ({ 11 | data, 12 | updatedVersion, 13 | indexFile, 14 | }): Promise => { 15 | try { 16 | const { fileContent, ...rest } = indexFile; 17 | const fileFolders = fileContent?.folders ?? {}; 18 | 19 | const deletedEntries = fileContent.deletedEntries.map((entryId: string) => getNewId(entryId)); 20 | const pinnedEntries = fileContent.pinnedEntries.map((entry) => { 21 | if (typeof entry === 'object') { 22 | return entry?.id && getNewId(entry.id); 23 | } 24 | return getNewId(entry); 25 | }); 26 | 27 | const updatedFolders = Object.entries(fileFolders).map(([key, value]) => { 28 | const folderValue = { 29 | // @ts-ignore 30 | ...value, 31 | // @ts-ignore 32 | entries: value?.entries?.map((e) => getNewId(e)), 33 | }; 34 | 35 | return [key, folderValue]; 36 | }); 37 | 38 | const folders = Object.fromEntries(updatedFolders); 39 | 40 | const updatedIndexFileContent = { 41 | ...fileContent, 42 | schemaVersion: updatedVersion, 43 | deletedEntries, 44 | pinnedEntries, 45 | folders, 46 | }; 47 | 48 | return { 49 | indexFile: { 50 | ...rest, 51 | fileContent: updatedIndexFileContent, 52 | }, 53 | data, 54 | }; 55 | } catch (error) { 56 | logger.error('removeDateStringFromIndex', error); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/migrations/seed/entries.seed.ts: -------------------------------------------------------------------------------- 1 | import { writeTextFile } from '@tauri-apps/api/fs'; 2 | import { nanoid } from 'nanoid'; 3 | 4 | import { DocumentType, pathGenerator } from '@/lib/data-engine/syncing-engine'; 5 | 6 | export const seedDefaultEntries = async (basePath: string) => { 7 | const timestamp = new Date().toISOString(); 8 | const id = nanoid(); 9 | 10 | const { path } = pathGenerator.generatePath({ 11 | dateString: timestamp, 12 | type: DocumentType.Entry, 13 | basePath, 14 | id, 15 | }); 16 | 17 | const welcomeMarkdown = `--- 18 | id: ${id} 19 | createdAt: ${timestamp} 20 | updatedAt: ${timestamp} 21 | tags: '' 22 | title: 'Intro to Markdown' 23 | --- 24 | 25 | # Markdown: A Beginner's Guide 26 | 27 | Markdown is a lightweight markup language with plain-text formatting syntax. It allows you to format text using simple symbols and conventions, making it easy to read and write. 28 | 29 | ## Why Markdown? 30 | 31 | - **Simplicity**: Markdown syntax is straightforward and easy to learn. 32 | - **Versatility**: Markdown supports various formatting options for creating rich text documents. 33 | - **Portability**: Markdown documents can be easily converted to HTML, PDF, and other formats. 34 | 35 | ## Basic Syntax 36 | 37 | ### Headings 38 | 39 | # Heading 1 40 | ## Heading 2 41 | ### Heading 3 42 | 43 | ### Emphasis 44 | 45 | *Italic* or _Italic_ 46 | **Bold** or __Bold__ 47 | 48 | ### Lists 49 | #### Unordered List 50 | 51 | - Item 1 52 | - Item 2 53 | - Item 3 54 | 55 | #### Ordered List 56 | 57 | 1. First item 58 | 2. Second item 59 | 3. Third item 60 | 61 | ### Links 62 | 63 | [Link Text](https://www.example.com) 64 | 65 | ### Blockquotes 66 | 67 | > This is a blockquote. 68 | `; 69 | 70 | await writeTextFile(path, welcomeMarkdown); 71 | }; 72 | 73 | export const seedPinnedEntry = async (basePath: string) => { 74 | const timestamp = new Date().toISOString(); 75 | const id = `${nanoid()}`; 76 | 77 | const { path } = pathGenerator.generatePath({ 78 | dateString: timestamp, 79 | type: DocumentType.Entry, 80 | id, 81 | basePath, 82 | }); 83 | 84 | const markdown = `--- 85 | id: ${id} 86 | createdAt: ${timestamp} 87 | updatedAt: ${timestamp} 88 | tags: '' 89 | title: 'What is Ibis' 90 | --- 91 | ## What exactly is Ibis? 92 | 93 | Ibis a local-first writing tool. All your data is in markdown stored on your machine. Nothing goes out to anywhere. Total privacy. 94 | 95 | `; 96 | 97 | try { 98 | await writeTextFile(path, markdown); 99 | } catch (error) { 100 | console.error('mmore error', error); 101 | } 102 | 103 | return id; 104 | }; 105 | -------------------------------------------------------------------------------- /src/migrations/seed/index.seed.ts: -------------------------------------------------------------------------------- 1 | import { MAX_SCHEMA_VERSION } from '..'; 2 | 3 | export const generateIndexSeed = ({ pinnedId = '' }) => { 4 | // console.log( 5 | // 'generateIndexSeed =>', 6 | // JSON.stringify({ 7 | // schemaVersion: MAX_SCHEMA_VERSION, 8 | // deletedEntries: [], 9 | // pinnedEntries: [pinnedId], 10 | // folders: {}, 11 | // }), 12 | // ); 13 | return { 14 | schemaVersion: MAX_SCHEMA_VERSION, 15 | deletedEntries: [], 16 | pinnedEntries: [pinnedId], 17 | folders: {}, 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /src/migrations/seed/journal.seed.ts: -------------------------------------------------------------------------------- 1 | import { writeTextFile } from '@tauri-apps/api/fs'; 2 | import { nanoid } from 'nanoid'; 3 | 4 | import { generateTodayPath } from '@/lib/data-engine/syncing-helpers'; 5 | import { getDateInStringFormat } from '@/store'; 6 | 7 | export const seedDefaultJournal = async (name: string) => { 8 | const today = new Date(); 9 | const todayDate = getDateInStringFormat(today); 10 | 11 | const [path, journalPath] = generateTodayPath(today.toISOString(), name); 12 | 13 | const defaultJournalMarkdown = `--- 14 | id: ${nanoid()} 15 | date: ${todayDate} 16 | --- 17 | 18 | Welcome to Your Journal 🌟 19 | 20 | Your journal awaits you with daily ready to capture your thoughts, tasks, reflections and everything inbetween. 21 | 22 | Express yourself in markdown format – bold, italicize, and organize your notes just the way you like. 23 | 24 | **Happy journaling!** 25 | `; 26 | 27 | await writeTextFile(`${path}/${journalPath}`, defaultJournalMarkdown); 28 | }; 29 | -------------------------------------------------------------------------------- /src/plugins/AutolinkPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin'; 2 | 3 | export const URL_MATCHER = 4 | /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; 5 | 6 | const EMAIL_MATCHER = 7 | /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/; 8 | 9 | const MATCHERS = [ 10 | (text: string) => { 11 | const match = URL_MATCHER.exec(text); 12 | return ( 13 | match && { 14 | index: match.index, 15 | length: match[0].length, 16 | text: match[0], 17 | url: match[0], 18 | } 19 | ); 20 | }, 21 | (text: string) => { 22 | const match = EMAIL_MATCHER.exec(text); 23 | return ( 24 | match && { 25 | index: match.index, 26 | length: match[0].length, 27 | text: match[0], 28 | url: `mailto:${match[0]}`, 29 | } 30 | ); 31 | }, 32 | ]; 33 | 34 | // Source: https://stackoverflow.com/a/8234912/2013580 35 | const urlRegExp = new RegExp( 36 | /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)/, 37 | ); 38 | export function validateUrl(url: string) { 39 | return url === 'https://' || urlRegExp.test(url); 40 | } 41 | 42 | export default function PlaygroundAutoLinkPlugin() { 43 | return ; 44 | } 45 | -------------------------------------------------------------------------------- /src/plugins/ClickableLinkPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { $isLinkNode } from '@lexical/link'; 4 | import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; 5 | import { $findMatchingParent, isHTMLAnchorElement } from '@lexical/utils'; 6 | import { open } from '@tauri-apps/api/shell'; 7 | import { 8 | $getNearestNodeFromDOMNode, 9 | $getSelection, 10 | $isElementNode, 11 | $isRangeSelection, 12 | getNearestEditorFromDOMNode, 13 | } from 'lexical'; 14 | 15 | function findMatchingDOM(startNode, predicate) { 16 | if (!predicate) return null; 17 | 18 | let node = startNode; 19 | while (node != null) { 20 | if (predicate(node)) { 21 | return node; 22 | } 23 | node = node.parentNode; 24 | } 25 | return null; 26 | } 27 | 28 | export default function LexicalClickableLinkPlugin({ newTab = true }) { 29 | const [editor] = useLexicalComposerContext(); 30 | 31 | useEffect(() => { 32 | const onClick = (event) => { 33 | const target = event.target; 34 | if (!(target instanceof Node)) { 35 | return; 36 | } 37 | const nearestEditor = getNearestEditorFromDOMNode(target); 38 | 39 | if (nearestEditor === null) { 40 | return; 41 | } 42 | 43 | let url = null; 44 | let urlTarget = null; 45 | nearestEditor.update(() => { 46 | const clickedNode = $getNearestNodeFromDOMNode(target); 47 | if (clickedNode !== null) { 48 | const maybeLinkNode = $findMatchingParent(clickedNode, $isElementNode); 49 | if ($isLinkNode(maybeLinkNode)) { 50 | url = maybeLinkNode.getURL(); 51 | urlTarget = maybeLinkNode.getTarget(); 52 | } else { 53 | const a = findMatchingDOM(target, isHTMLAnchorElement); 54 | if (a !== null) { 55 | url = a.href; 56 | urlTarget = a.target; 57 | } 58 | } 59 | } 60 | }); 61 | 62 | if (url === null || url === '') { 63 | return; 64 | } 65 | 66 | // Allow user to select link text without follwing url 67 | const selection = editor.getEditorState().read($getSelection); 68 | if ($isRangeSelection(selection) && !selection.isCollapsed()) { 69 | event.preventDefault(); 70 | return; 71 | } 72 | open(url); 73 | event.preventDefault(); 74 | }; 75 | 76 | const onMouseUp = (event) => { 77 | if (event.button === 1 && editor.isEditable()) { 78 | onClick(event); 79 | } 80 | }; 81 | 82 | return editor.registerRootListener((rootElement, prevRootElement) => { 83 | if (prevRootElement !== null) { 84 | prevRootElement.removeEventListener('click', onClick); 85 | prevRootElement.removeEventListener('mouseup', onMouseUp); 86 | } 87 | if (rootElement !== null) { 88 | rootElement.addEventListener('click', onClick); 89 | rootElement.addEventListener('mouseup', onMouseUp); 90 | } 91 | }); 92 | }, [editor, newTab]); 93 | 94 | return null; 95 | } 96 | -------------------------------------------------------------------------------- /src/plugins/CodeHighlightPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { registerCodeHighlighting } from '@lexical/code'; 4 | import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; 5 | 6 | export default function CodeHighlightPlugin() { 7 | const [editor] = useLexicalComposerContext(); 8 | 9 | useEffect(() => { 10 | return registerCodeHighlighting(editor); 11 | }, [editor]); 12 | 13 | return null; 14 | } 15 | -------------------------------------------------------------------------------- /src/plugins/FloatingMenuPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react'; 2 | 3 | import { createPortal } from 'react-dom'; 4 | 5 | import { computePosition, flip, offset, shift } from '@floating-ui/dom'; 6 | import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; 7 | import { 8 | $getSelection, 9 | $isRangeSelection, 10 | COMMAND_PRIORITY_NORMAL as NORMAL_PRIORITY, 11 | SELECTION_CHANGE_COMMAND as ON_SELECTION_CHANGE, 12 | } from 'lexical'; 13 | 14 | import { FloatingMenu } from '../components/editor/FloatingMenu'; 15 | import { usePointerInteractions } from '../hooks/usePointerInteractions'; 16 | 17 | const DEFAULT_DOM_ELEMENT = document.body; 18 | 19 | function FloatingMenuPlugin() { 20 | const ref = useRef(null); 21 | const [coords, setCoords] = useState<{ x: number; y: number } | undefined>(undefined); 22 | const show = coords !== undefined; 23 | 24 | const [editor] = useLexicalComposerContext(); 25 | const { isPointerDown, isPointerReleased } = usePointerInteractions(); 26 | 27 | const calculatePosition = useCallback(() => { 28 | const domSelection = getSelection(); 29 | const domRange = domSelection?.rangeCount !== 0 && domSelection?.getRangeAt(0); 30 | 31 | if (!domRange || !ref.current || isPointerDown) return setCoords(undefined); 32 | 33 | computePosition(domRange, ref.current, { 34 | placement: 'top-start', 35 | middleware: [ 36 | flip(), 37 | shift(), 38 | offset({ 39 | alignmentAxis: 10, 40 | }), 41 | ], 42 | }) 43 | .then((pos) => { 44 | setCoords({ y: pos.y - 10, x: pos.x }); 45 | }) 46 | .catch(() => { 47 | setCoords(undefined); 48 | }); 49 | }, [isPointerDown]); 50 | 51 | const $handleSelectionChange = useCallback(() => { 52 | if (editor.isComposing()) return false; 53 | 54 | if (editor.getRootElement() !== document.activeElement) { 55 | setCoords(undefined); 56 | return true; 57 | } 58 | 59 | const selection = $getSelection(); 60 | 61 | if ($isRangeSelection(selection) && !selection.anchor.is(selection.focus)) { 62 | calculatePosition(); 63 | } else { 64 | setCoords(undefined); 65 | } 66 | 67 | return true; 68 | }, [editor, calculatePosition]); 69 | 70 | useEffect(() => { 71 | const unregisterCommand = editor.registerCommand( 72 | ON_SELECTION_CHANGE, 73 | $handleSelectionChange, 74 | NORMAL_PRIORITY, 75 | ); 76 | return unregisterCommand; 77 | }, [editor, $handleSelectionChange]); 78 | 79 | useEffect(() => { 80 | if (!show && isPointerReleased) { 81 | editor.getEditorState().read(() => { 82 | $handleSelectionChange(); 83 | }); 84 | } 85 | // Adding show to the dependency array causes an issue if 86 | // a range selection is dismissed by navigating via arrow keys. 87 | // eslint-disable-next-line react-hooks/exhaustive-deps 88 | }, [isPointerReleased, $handleSelectionChange, editor]); 89 | 90 | return createPortal( 91 |
102 | 103 |
, 104 | DEFAULT_DOM_ELEMENT, 105 | ); 106 | } 107 | 108 | export default FloatingMenuPlugin; 109 | -------------------------------------------------------------------------------- /src/plugins/MarkdownShortcut.tsx: -------------------------------------------------------------------------------- 1 | import { CHECK_LIST, TRANSFORMERS } from '@lexical/markdown'; 2 | import { MarkdownShortcutPlugin as LexicalMDShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin'; 3 | 4 | import { PAGE_BREAK_NODE_TRANSFORMER } from './PageBreakPlugin/nodes/PageBreakNode'; 5 | 6 | export const CUSTOM_TRANSFORMERS = [CHECK_LIST, PAGE_BREAK_NODE_TRANSFORMER, ...TRANSFORMERS]; 7 | 8 | export const MarkdownShortcutPlugin = () => { 9 | return ; 10 | }; 11 | -------------------------------------------------------------------------------- /src/plugins/PageBreakPlugin/PageBreakPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; 4 | import { $insertNodeToNearestRoot, mergeRegister } from '@lexical/utils'; 5 | import { 6 | $getSelection, 7 | $isRangeSelection, 8 | COMMAND_PRIORITY_EDITOR, 9 | LexicalCommand, 10 | createCommand, 11 | } from 'lexical'; 12 | 13 | import { $createPageBreakNode, PageBreakNode } from './nodes/PageBreakNode'; 14 | 15 | export const INSERT_PAGE_BREAK: LexicalCommand = createCommand(); 16 | 17 | export default function PageBreakPlugin(): JSX.Element | null { 18 | const [editor] = useLexicalComposerContext(); 19 | 20 | useEffect(() => { 21 | if (!editor.hasNodes([PageBreakNode])) 22 | throw new Error('PageBreakPlugin: PageBreakNode is not registered on editor'); 23 | 24 | return mergeRegister( 25 | editor.registerCommand( 26 | INSERT_PAGE_BREAK, 27 | () => { 28 | const selection = $getSelection(); 29 | 30 | if (!$isRangeSelection(selection)) return false; 31 | 32 | const focusNode = selection.focus.getNode(); 33 | 34 | if (focusNode !== null) { 35 | const pgBreak = $createPageBreakNode(); 36 | $insertNodeToNearestRoot(pgBreak); 37 | } 38 | 39 | return true; 40 | }, 41 | COMMAND_PRIORITY_EDITOR, 42 | ), 43 | ); 44 | }, [editor]); 45 | 46 | return null; 47 | } 48 | -------------------------------------------------------------------------------- /src/plugins/PageBreakPlugin/nodes/PageBreakNode.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --editor-input-padding: 20px; 3 | } 4 | 5 | [type='page-break'] { 6 | position: relative; 7 | display: block; 8 | overflow: unset; 9 | margin-left: calc(var(--editor-input-padding, 28px) * -1); 10 | margin-top: var(--editor-input-padding, 28px); 11 | margin-bottom: var(--editor-input-padding, 28px); 12 | 13 | border: none; 14 | 15 | border-top: 1px dashed var(--main-border); 16 | border-bottom: 1px dashed var(--main-border); 17 | background-color: var(--main-background); 18 | } 19 | 20 | [type='page-break'] { 21 | .page-break-svg { 22 | position: absolute; 23 | top: 50%; 24 | left: calc(var(--editor-input-padding, 28px) + 12px); 25 | transform: translateY(-50%); 26 | opacity: 0.7; 27 | color: var(--text-primary); 28 | } 29 | } 30 | 31 | [type='page-break']::after { 32 | position: absolute; 33 | top: 50%; 34 | left: 50%; 35 | transform: translate(-50%, -50%); 36 | 37 | display: block; 38 | padding: 4px 8px; 39 | border: 1px solid var(--main-border); 40 | background-color: var(--panel-background); 41 | border-radius: 4px; 42 | 43 | content: 'PAGE BREAK'; 44 | font-size: 12px; 45 | color: var(--text-primary); 46 | font-weight: 600; 47 | } 48 | 49 | .selected[type='page-break'] { 50 | .page-break-svg { 51 | opacity: 1; 52 | color: var(--primary-color); 53 | } 54 | border-color: var(--primary-color); 55 | } 56 | 57 | .selected[type='page-break']::before { 58 | opacity: 1; 59 | } 60 | -------------------------------------------------------------------------------- /src/plugins/SearchDialogPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from 'react'; 2 | 3 | import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; 4 | 5 | import { appState } from '@/store/app-state'; 6 | import { searchStore } from '@/store/search'; 7 | 8 | // TODO: convert this into a shortcut plugin 9 | export default function SearchDialogPlugin(): JSX.Element | null { 10 | const [editor] = useLexicalComposerContext(); 11 | 12 | useLayoutEffect(() => { 13 | const onkeyDown = (e: KeyboardEvent) => { 14 | if (e.metaKey && e.key == 'k') { 15 | // TODO: figure out a way to return the focus back to page when this is closed 16 | searchStore.toggleSearchModal(); 17 | } 18 | 19 | if ((e.metaKey || e.ctrlKey) && e.key == 'd') { 20 | appState.toggleSidebarOpenState(); 21 | } 22 | }; 23 | 24 | return editor.registerRootListener((rootElement, prevRootElement) => { 25 | if (prevRootElement !== null) { 26 | prevRootElement.removeEventListener('keydown', onkeyDown); 27 | } 28 | 29 | if (rootElement !== null) { 30 | rootElement.addEventListener('keydown', onkeyDown); 31 | } 32 | }); 33 | }, [editor]); 34 | 35 | return null; 36 | } 37 | -------------------------------------------------------------------------------- /src/plugins/TabFocusPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; 2 | import { $getSelection, $isRangeSelection, $setSelection, FOCUS_COMMAND } from 'lexical'; 3 | import { useEffect } from 'react'; 4 | 5 | const COMMAND_PRIORITY_LOW = 1; 6 | const TAB_TO_FOCUS_INTERVAL = 100; 7 | 8 | let lastTabKeyDownTimestamp = 0; 9 | let hasRegisteredKeyDownListener = false; 10 | 11 | function registerKeyTimeStampTracker() { 12 | window.addEventListener( 13 | 'keydown', 14 | (event) => { 15 | // Tab 16 | if (event.keyCode === 9) { 17 | lastTabKeyDownTimestamp = event.timeStamp; 18 | } 19 | }, 20 | true, 21 | ); 22 | } 23 | 24 | export default function TabFocusPlugin() { 25 | const [editor] = useLexicalComposerContext(); 26 | 27 | useEffect(() => { 28 | if (!hasRegisteredKeyDownListener) { 29 | registerKeyTimeStampTracker(); 30 | hasRegisteredKeyDownListener = true; 31 | } 32 | 33 | return editor.registerCommand( 34 | FOCUS_COMMAND, 35 | (event) => { 36 | const selection = $getSelection(); 37 | if ($isRangeSelection(selection)) { 38 | if (lastTabKeyDownTimestamp + TAB_TO_FOCUS_INTERVAL > event.timeStamp) { 39 | $setSelection(selection.clone()); 40 | } 41 | } 42 | return false; 43 | }, 44 | COMMAND_PRIORITY_LOW, 45 | ); 46 | }, [editor]); 47 | 48 | return null; 49 | } 50 | -------------------------------------------------------------------------------- /src/plugins/theme.ts: -------------------------------------------------------------------------------- 1 | export const theme = { 2 | ltr: 'ltr', 3 | rtl: 'rtl', 4 | placeholder: 'editor-placeholder', 5 | paragraph: 'editor-paragraph', 6 | quote: 'editor-quote', 7 | heading: { 8 | h1: 'editor-heading-h1', 9 | h2: 'editor-heading-h2', 10 | h3: 'editor-heading-h3', 11 | h4: 'editor-heading-h4', 12 | h5: 'editor-heading-h5', 13 | }, 14 | list: { 15 | listitemChecked: 'editor-listItemChecked', 16 | listitemUnchecked: 'editor-listItemUnchecked', 17 | nested: { 18 | listitem: 'editor-nested-listitem', 19 | }, 20 | ol: 'editor-list-ol', 21 | ul: 'editor-list-ul', 22 | listitem: 'editor-listitem', 23 | checklist: 'editor-checklist', 24 | }, 25 | image: 'editor-image', 26 | link: 'editor-link', 27 | text: { 28 | bold: 'editor-text-bold', 29 | italic: 'editor-text-italic', 30 | overflowed: 'editor-text-overflowed', 31 | hashtag: 'editor-text-hashtag', 32 | underline: 'editor-text-underline', 33 | strikethrough: 'editor-text-strikethrough', 34 | underlineStrikethrough: 'editor-text-underlineStrikethrough', 35 | code: 'editor-text-code', 36 | }, 37 | code: 'editor-code', 38 | codeHighlight: { 39 | atrule: 'editor-tokenAttr', 40 | attr: 'editor-tokenAttr', 41 | boolean: 'editor-tokenProperty', 42 | builtin: 'editor-tokenSelector', 43 | cdata: 'editor-tokenComment', 44 | char: 'editor-tokenSelector', 45 | class: 'editor-tokenFunction', 46 | 'class-name': 'editor-tokenFunction', 47 | comment: 'editor-tokenComment', 48 | constant: 'editor-tokenProperty', 49 | deleted: 'editor-tokenProperty', 50 | doctype: 'editor-tokenComment', 51 | entity: 'editor-tokenOperator', 52 | function: 'editor-tokenFunction', 53 | important: 'editor-tokenVariable', 54 | inserted: 'editor-tokenSelector', 55 | keyword: 'editor-tokenAttr', 56 | namespace: 'editor-tokenVariable', 57 | number: 'editor-tokenProperty', 58 | operator: 'editor-tokenOperator', 59 | prolog: 'editor-tokenComment', 60 | property: 'editor-tokenProperty', 61 | punctuation: 'editor-tokenPunctuation', 62 | regex: 'editor-tokenVariable', 63 | selector: 'editor-tokenSelector', 64 | string: 'editor-tokenSelector', 65 | symbol: 'editor-tokenProperty', 66 | tag: 'editor-tokenProperty', 67 | url: 'editor-tokenOperator', 68 | variable: 'editor-tokenVariable', 69 | }, 70 | }; 71 | -------------------------------------------------------------------------------- /src/routes/Root.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { Outlet } from 'react-router-dom'; 3 | import { Toaster } from 'sonner'; 4 | 5 | import { AppErrorBoundary } from '@/components/shared/ErrorBoundary'; 6 | 7 | const Root = observer(() => { 8 | return ( 9 | <> 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | }); 17 | 18 | export default Root; 19 | -------------------------------------------------------------------------------- /src/routes/layout/AppLayout.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import clsx from 'clsx'; 4 | import { observer } from 'mobx-react-lite'; 5 | import { Outlet, useNavigate } from 'react-router-dom'; 6 | 7 | import { PageTitleBar } from '@/components/page-titlebar/PageTitleBar'; 8 | import { Sidebar } from '@/components/sidebar/Sidebar'; 9 | import { SplashScreen } from '@/components/splash-screen/SplashScreen'; 10 | 11 | import { SearchDialog } from '@/components'; 12 | import { useRegisterAllShortcuts } from '@/hooks/useRegisterAllShortcuts'; 13 | import { SAFE_LOCATION_KEY } from '@/lib/constants'; 14 | import { loadDirectoryContent } from '@/lib/data-engine/syncing-helpers'; 15 | import { getData } from '@/lib/storage'; 16 | import { appState } from '@/store/app-state'; 17 | 18 | const AppLayout = observer(() => { 19 | const [showSplashScreen, setShowSplashScreen] = useState(false); 20 | const navigate = useNavigate(); 21 | useRegisterAllShortcuts(); 22 | 23 | useEffect(() => { 24 | const SAFEURL = getData(SAFE_LOCATION_KEY); 25 | 26 | if (SAFEURL) { 27 | appState.load(); 28 | loadDirectoryContent(SAFEURL); 29 | return; 30 | } 31 | navigate('/safe'); 32 | }, []); 33 | 34 | return ( 35 |
36 | {showSplashScreen && } 37 | 38 | 39 | 40 |
45 |
46 | 47 |
48 | 49 |
50 |
51 | 52 | 53 |
54 |
55 |
56 | ); 57 | }); 58 | 59 | export default AppLayout; 60 | -------------------------------------------------------------------------------- /src/routes/layout/_app-layout.scss: -------------------------------------------------------------------------------- 1 | .page-container { 2 | height: 100%; 3 | 4 | .layout-divider { 5 | background-color: var(--main-border); 6 | height: 100vh; 7 | width: 1px; 8 | } 9 | } 10 | 11 | .two-column-container { 12 | display: flex; 13 | margin-top: auto; 14 | width: 100%; 15 | height: 100%; 16 | 17 | &.sidebar-closed { 18 | .sidebar-container { 19 | display: none; 20 | } 21 | 22 | .layout-divider { 23 | display: none; 24 | } 25 | } 26 | 27 | .page-wrapper { 28 | flex: 1; 29 | overflow: hidden; 30 | background-color: var(--panel-background); 31 | } 32 | 33 | .sidebar-container { 34 | height: 100%; 35 | flex-shrink: 0; 36 | position: -webkit-sticky; 37 | position: sticky; 38 | top: 0; 39 | background-color: var(--alpha-main-background); 40 | width: 300px; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/routes/pages/entry/Entry.page.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | 3 | import { EDITOR_PAGES, Editor } from '../../../components/editor/Editor'; 4 | import { entriesStore } from '../../../store/entries'; 5 | import { useEffect, useRef } from 'react'; 6 | 7 | const EntryPage = observer(() => { 8 | const { activeEntry } = entriesStore; 9 | const ref = useRef(null); 10 | 11 | const content = activeEntry?.content || ''; 12 | 13 | return ( 14 |
15 | {activeEntry && ( 16 | entriesStore.saveContent(state)} 19 | id={activeEntry.id} 20 | content={content} 21 | /> 22 | )} 23 |
24 | ); 25 | }); 26 | 27 | export default EntryPage; 28 | -------------------------------------------------------------------------------- /src/routes/pages/entry/_entry.scss: -------------------------------------------------------------------------------- 1 | .entry-page { 2 | position: relative; 3 | 4 | .editor-wrapper { 5 | overflow-y: scroll; 6 | position: relative; 7 | padding: 40px 80px; 8 | height: calc(100vh - var(--page-title-height)); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/routes/pages/journal/Journal.page.tsx: -------------------------------------------------------------------------------- 1 | import { AppErrorBoundary } from '@/components/shared/ErrorBoundary'; 2 | 3 | import DailyNote from '../../../components/journal/Note'; 4 | 5 | export default function JournalPage() { 6 | return ( 7 |
8 | 9 | 10 | 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/routes/pages/journal/_journal.scss: -------------------------------------------------------------------------------- 1 | .journal-page { 2 | display: flex; 3 | margin: 0 auto; 4 | padding: 0 80px; 5 | height: 100vh; 6 | color: var(--text-primary); 7 | 8 | .daily-note { 9 | padding: 10px; 10 | width: 100%; 11 | padding-top: 40px; 12 | overflow-y: scroll; 13 | 14 | &__header { 15 | display: flex; 16 | align-items: center; 17 | justify-content: space-between; 18 | margin: 30px 0; 19 | } 20 | 21 | .note-date { 22 | display: flex; 23 | align-items: center; 24 | gap: 15px; 25 | height: 100%; 26 | position: relative; 27 | 28 | h1 { 29 | font-size: 5em; 30 | font-variant-numeric: tabular-nums; 31 | line-height: 1; 32 | position: relative; 33 | 34 | &.is-today { 35 | display: inline-block; 36 | background: linear-gradient( 37 | to bottom, 38 | var(--text-primary) var(--percentage-completed), 39 | var(--primary-color) 0% 40 | ); 41 | background-clip: text; 42 | color: transparent; 43 | } 44 | } 45 | 46 | .date-data { 47 | p { 48 | font-size: 15px; 49 | font-weight: 600; 50 | color: var(--text-secondary); 51 | } 52 | } 53 | } 54 | 55 | .note { 56 | .title { 57 | padding: 15px 0; 58 | 59 | h3 { 60 | font-size: 27px; 61 | line-height: 28px; 62 | font-weight: 600; 63 | color: var(--base-text-color); 64 | } 65 | } 66 | } 67 | 68 | .editor-container { 69 | position: relative; 70 | } 71 | 72 | .note-editor-container { 73 | position: relative; 74 | } 75 | 76 | .section-header { 77 | display: flex; 78 | align-items: center; 79 | gap: 8px; 80 | justify-content: flex-end; 81 | padding: 8px; 82 | 83 | .active-date { 84 | margin-right: auto; 85 | } 86 | 87 | button { 88 | padding: 0; 89 | width: 32px; 90 | height: 32px; 91 | display: flex; 92 | justify-content: center; 93 | align-items: center; 94 | border: 0; 95 | border-radius: 100%; 96 | box-shadow: 0 0 0 1px var(--main-border); 97 | background-color: transparent; 98 | color: var(--text-primary); 99 | 100 | &.action { 101 | width: auto; 102 | padding: 0 8px; 103 | border-radius: 4px; 104 | font-size: 14px; 105 | font-weight: 500; 106 | } 107 | 108 | &:hover { 109 | background-color: var(--hover-background); 110 | box-shadow: 0 0 0 1px var(--darker-border); 111 | } 112 | } 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/routes/pages/safe/SafeLoadout.page.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import { open } from '@tauri-apps/api/dialog'; 4 | import { useNavigate } from 'react-router-dom'; 5 | 6 | import { generateNewDirectory } from '@/lib/auth/auth-helpers'; 7 | import { SAFE_LOCATION_KEY } from '@/lib/constants'; 8 | import { loadDirectoryContent } from '@/lib/data-engine/syncing-helpers'; 9 | import { setData } from '@/lib/storage'; 10 | import { logger } from '@/lib/logger'; 11 | 12 | export const SafeLoadout = () => { 13 | const [location, setLocation] = useState(null); 14 | const [loading, setLoading] = useState(false); 15 | const [name, setName] = useState(''); 16 | 17 | const navigate = useNavigate(); 18 | 19 | const openPicker = async () => { 20 | const selected = await open({ 21 | directory: true, 22 | recursive: true, 23 | }); 24 | 25 | setLocation(selected as unknown as string); 26 | }; 27 | 28 | const createDirectoryAndStartService = async (e: React.MouseEvent) => { 29 | e.preventDefault(); 30 | setLoading(true); 31 | 32 | const directoryName = `${location}${name ? `/${name}` : ''}`; 33 | 34 | try { 35 | setData(SAFE_LOCATION_KEY, directoryName); 36 | await generateNewDirectory(directoryName); 37 | await loadDirectoryContent(directoryName); 38 | 39 | navigate('/today'); 40 | } catch (error) { 41 | logger.error('createDirectoryAndStartService =>', error); 42 | } finally { 43 | setLoading(false); 44 | } 45 | }; 46 | 47 | return ( 48 |
49 |
50 |
51 |
52 | 53 |
54 |

All your journal and notes, local first.

55 | {/* 56 | Now with AI butler 57 | */} 58 |
59 |
60 |
61 | 62 | setName(event.target.value)} 67 | placeholder="ibis safe" 68 | autoCapitalize="none" 69 | /> 70 |
71 | 72 |
73 | 74 |
openPicker()}> 75 |

{location ?? 'Pick location'}

76 |
77 | Pick a place on your workspace to put your safe 78 |
79 | 80 | 87 |
88 |
89 |
90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /src/routes/pages/safe/_safe-loadout.scss: -------------------------------------------------------------------------------- 1 | .safe-loadout { 2 | background-color: var(--main-background); 3 | color: var(--text-primary); 4 | 5 | display: flex; 6 | justify-content: center; 7 | align-items: center; 8 | 9 | .form-wrapper { 10 | width: 400px; 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | flex-direction: column; 15 | 16 | &-header { 17 | text-align: center; 18 | margin-bottom: 40px; 19 | display: flex; 20 | justify-content: center; 21 | align-items: center; 22 | flex-direction: column; 23 | 24 | .subtext { 25 | font-size: 14px; 26 | margin-top: 4px; 27 | 28 | .ai { 29 | font-weight: 600; 30 | } 31 | } 32 | 33 | .logo { 34 | width: 64px; 35 | height: 64px; 36 | margin-bottom: 10px; 37 | 38 | img { 39 | width: 100%; 40 | height: 100%; 41 | } 42 | } 43 | } 44 | 45 | form { 46 | width: 100%; 47 | 48 | .footnote { 49 | margin-top: 6px; 50 | cursor: pointer; 51 | font-size: 14px; 52 | text-align: right; 53 | font-family: var(--favorit-font); 54 | 55 | &:hover { 56 | text-decoration: underline; 57 | } 58 | } 59 | 60 | span.description { 61 | font-size: 13px; 62 | color: var(--subtitle-text-color); 63 | margin-top: 2px; 64 | } 65 | 66 | .location-picker { 67 | text-align: center; 68 | border: 1.5px solid var(--main-border); 69 | background-color: transparent; 70 | color: var(--base-text-color); 71 | width: 100%; 72 | padding: 0.5em 1.2em; 73 | border-radius: 8px; 74 | font-family: inherit; 75 | font-size: 0.9em; 76 | cursor: pointer; 77 | } 78 | } 79 | 80 | .form-control { 81 | display: flex; 82 | justify-content: center; 83 | align-items: flex-start; 84 | flex-direction: column; 85 | margin-bottom: 20px; 86 | width: 100%; 87 | 88 | label { 89 | margin-bottom: 4px; 90 | font-size: 15px; 91 | } 92 | 93 | input { 94 | width: 100%; 95 | border: 1.5px solid var(--main-border); 96 | background-color: transparent; 97 | color: var(--base-text-color); 98 | padding: 10px; 99 | border-radius: 8px; 100 | font-size: 15px; 101 | font-weight: 500; 102 | font-family: var(--current-font); 103 | 104 | &::placeholder { 105 | font-size: 14px; 106 | } 107 | 108 | &:focus, 109 | &:active { 110 | outline: 1.5px solid var(--primary-color); 111 | outline-offset: 2px; 112 | } 113 | } 114 | } 115 | 116 | button { 117 | width: 100%; 118 | font-size: 16px; 119 | height: 48px; 120 | border-radius: 8px; 121 | background-color: var(--primary-color); 122 | border-color: var(--primary-color); 123 | color: white; 124 | font-weight: 500; 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/routes/pages/settings/Settings.page.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from 'react'; 2 | 3 | import { Combine, CornerDownLeft, CornerUpLeft, Laptop2, Palette, UserSquare2 } from 'lucide-react'; 4 | import { useNavigate } from 'react-router-dom'; 5 | 6 | const SettingsPage = () => { 7 | const navigate = useNavigate(); 8 | 9 | const sections = [ 10 | { 11 | title: 'User', 12 | options: [ 13 | { 14 | title: 'Profile', 15 | icon: , 16 | }, 17 | ], 18 | }, 19 | 20 | { 21 | title: 'App', 22 | options: [ 23 | { 24 | title: 'General', 25 | icon: , 26 | }, 27 | 28 | { 29 | title: 'Appearance', 30 | icon: , 31 | }, 32 | ], 33 | }, 34 | 35 | { 36 | title: 'Calender', 37 | options: [ 38 | { 39 | title: 'Connected Accounts', 40 | icon: , 41 | }, 42 | ], 43 | }, 44 | ]; 45 | 46 | return ( 47 |
48 |
49 |
navigate('/today')}> 50 | 51 |
52 | 53 |
54 | {sections.map((section, index) => { 55 | return ( 56 | 57 |
58 |

{section.title}

59 |
60 | {section?.options?.map((category, index) => { 61 | return ( 62 |
63 |
{category.icon}
64 |

{category?.title}

65 |
66 | ); 67 | })} 68 |
69 |
70 | 71 | {sections.length - 1 !== index &&
} 72 |
73 | ); 74 | })} 75 |
76 |
77 |
Content goes here
78 |
79 | ); 80 | }; 81 | 82 | export default SettingsPage; 83 | -------------------------------------------------------------------------------- /src/routes/pages/settings/_settings.scss: -------------------------------------------------------------------------------- 1 | .settings-page { 2 | display: grid; 3 | grid-template-columns: repeat(12, 1fr); 4 | align-items: stretch; 5 | gap: 8px; 6 | 7 | .left-navigation { 8 | grid-column: span 4; 9 | height: 100%; 10 | display: flex; 11 | align-items: flex-start; 12 | justify-content: flex-end; 13 | 14 | .back-icon { 15 | width: 28px; 16 | height: 28px; 17 | display: flex; 18 | align-items: center; 19 | justify-content: center; 20 | border-radius: 4px; 21 | cursor: pointer; 22 | transition: all 0.3s ease-out; 23 | margin-right: 12px; 24 | margin-top: 15px; 25 | border: 1.5px solid var(--main-border); 26 | 27 | &:hover { 28 | border-color: var(--darker-border); 29 | } 30 | } 31 | 32 | .categories { 33 | width: 13rem; 34 | display: flex; 35 | flex-direction: column; 36 | align-items: flex-start; 37 | gap: 6px; 38 | justify-content: flex-start; 39 | 40 | .section-divider { 41 | width: 100%; 42 | height: 1px; 43 | background-color: var(--main-border); 44 | } 45 | 46 | .category-section { 47 | display: flex; 48 | flex-direction: column; 49 | width: 100%; 50 | margin-top: 15px; 51 | 52 | .section-header { 53 | font-size: 15px; 54 | margin-left: 10px; 55 | margin-bottom: 4px; 56 | } 57 | 58 | .sub-categories { 59 | width: 100%; 60 | display: flex; 61 | flex-direction: column; 62 | justify-content: flex-start; 63 | align-items: flex-start; 64 | gap: 6px; 65 | 66 | .category { 67 | display: flex; 68 | align-items: center; 69 | width: 100%; 70 | padding: 6px 8px; 71 | border-radius: 6px; 72 | cursor: pointer; 73 | border: 1.5px solid transparent; 74 | 75 | &:hover { 76 | border-color: var(--main-border); 77 | } 78 | 79 | &:active { 80 | transform: scale(0.98); 81 | } 82 | 83 | &-icon { 84 | width: 20px; 85 | height: 20px; 86 | display: flex; 87 | justify-content: center; 88 | align-items: center; 89 | margin-right: 4px; 90 | } 91 | 92 | &-name { 93 | font-size: 15px; 94 | font-weight: 500; 95 | } 96 | } 97 | } 98 | } 99 | } 100 | } 101 | 102 | .right-navigation { 103 | grid-column: span 8; 104 | background-color: var(--panel-background); 105 | box-shadow: var(--main-box-shadow); 106 | padding: 20px; 107 | border-radius: 8px; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/routes/pages/trash/Trash.page.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | 3 | import { Entry, entriesStore } from '@/store/entries'; 4 | 5 | const TrashPage = observer(() => { 6 | const { deletedEntries: entries } = entriesStore; 7 | 8 | const deletedEntries = entries.filter(Boolean) as Entry[]; 9 | 10 | return ( 11 |
12 |
13 | {deletedEntries.map((entry: Entry) => { 14 | return ( 15 |
16 |

{entry.title || 'Untitled'}

17 | 18 | 24 |
25 | ); 26 | })} 27 |
28 |
29 | ); 30 | }); 31 | 32 | export default TrashPage; 33 | -------------------------------------------------------------------------------- /src/routes/pages/trash/_trash.scss: -------------------------------------------------------------------------------- 1 | .trash-page { 2 | display: flex; 3 | flex-direction: column; 4 | padding: 40px 20px; 5 | width: 100%; 6 | justify-content: center; 7 | align-items: center; 8 | outline: 1px solid; 9 | 10 | .entry { 11 | display: flex; 12 | flex: 1; 13 | outline: 1px solid; 14 | width: 100%; 15 | align-items: center; 16 | padding: 5px 0; 17 | justify-content: space-between; 18 | border-bottom: 1px solid #e5e7eb; 19 | 20 | p { 21 | margin-right: auto; 22 | } 23 | 24 | button { 25 | margin-left: 5px; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/routes/router.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter, Outlet } from 'react-router-dom'; 2 | 3 | import { AppErrorBoundary } from '@/components/shared/ErrorBoundary'; 4 | 5 | import Root from './Root'; 6 | import AppLayout from './layout/AppLayout'; 7 | import EntryPage from './pages/entry/Entry.page'; 8 | import JournalPage from './pages/journal/Journal.page'; 9 | import { SafeLoadout } from './pages/safe/SafeLoadout.page'; 10 | import SettingsPage from './pages/settings/Settings.page'; 11 | import TrashPage from './pages/trash/Trash.page'; 12 | 13 | const NewLayout = () => { 14 | return ( 15 |
16 | hello world 17 | 18 |
19 | ); 20 | }; 21 | 22 | export const router = createBrowserRouter([ 23 | { 24 | path: '/', 25 | element: , 26 | ErrorBoundary: AppErrorBoundary, 27 | children: [ 28 | { 29 | element: , 30 | children: [ 31 | { 32 | path: '/', 33 | element: , 34 | }, 35 | { 36 | path: '/today', 37 | element: , 38 | }, 39 | 40 | { 41 | path: '/entry/:noteId', 42 | element: , 43 | }, 44 | 45 | { 46 | path: '/trash', 47 | element: , 48 | }, 49 | ], 50 | }, 51 | 52 | { 53 | path: '/settings', 54 | element: , 55 | }, 56 | 57 | { 58 | path: '/safe', 59 | element: , 60 | }, 61 | ], 62 | }, 63 | ]); 64 | -------------------------------------------------------------------------------- /src/store/app-state.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from 'mobx'; 2 | 3 | import { APP_STATE, USER_DATA } from '@/lib/constants'; 4 | 5 | import { getData, setData } from '../lib/storage'; 6 | 7 | type Theme = 'light' | 'night' | 'system'; 8 | 9 | const DEFAULT_APPSTATE = { 10 | theme: 'light', 11 | sidebarIsOpen: true, 12 | }; 13 | 14 | const ZEN_MODE_KEY = 'zen-mode'; 15 | 16 | class AppState { 17 | sidebarIsOpen: boolean = true; 18 | theme: Theme = 'light'; 19 | session: any | null = null; 20 | isZenMode: boolean = false; 21 | 22 | constructor() { 23 | makeAutoObservable(this); 24 | } 25 | 26 | load() { 27 | const state = getData(APP_STATE) ?? DEFAULT_APPSTATE; 28 | const session = getData(USER_DATA) || null; 29 | 30 | this.sidebarIsOpen = state.sidebarIsOpen; 31 | this.theme = state.theme; 32 | this.session = session; 33 | document.documentElement.setAttribute('data-theme', state.theme); 34 | document.documentElement.setAttribute('zen-mode', 'off'); 35 | } 36 | 37 | toggleZenMode() { 38 | const isZenModeOff = document.documentElement.getAttribute(ZEN_MODE_KEY) === 'off'; 39 | 40 | if (isZenModeOff) { 41 | document.documentElement.setAttribute(ZEN_MODE_KEY, 'on'); 42 | this.sidebarIsOpen = false; 43 | } else { 44 | document.documentElement.setAttribute(ZEN_MODE_KEY, 'off'); 45 | } 46 | } 47 | 48 | toggleSidebarOpenState() { 49 | this.sidebarIsOpen = !this.sidebarIsOpen; 50 | setData(APP_STATE, { 51 | sidebarIsOpen: this.sidebarIsOpen, 52 | theme: this.theme, 53 | }); 54 | } 55 | 56 | turnOffTransitions() { 57 | const css = document.createElement('style'); 58 | css.type = 'text/css'; 59 | 60 | css.appendChild( 61 | document.createTextNode( 62 | `*, *::before, *::after { 63 | -webkit-transition: none !important; 64 | -moz-transition: none !important; 65 | -o-transition: none !important; 66 | -ms-transition: none !important; 67 | transition: none !important; 68 | }`, 69 | ), 70 | ); 71 | document.head.appendChild(css); 72 | 73 | return css; 74 | } 75 | 76 | removeTurnOffTranstions(css: HTMLStyleElement) { 77 | window.getComputedStyle(css).opacity; 78 | document.head.removeChild(css); 79 | } 80 | 81 | toggleTheme(t: Theme) { 82 | const css = this.turnOffTransitions(); 83 | this.theme = t; 84 | document.documentElement.setAttribute('data-theme', t); 85 | setData(APP_STATE, { 86 | sidebarIsOpen: !this.sidebarIsOpen, 87 | theme: t, 88 | }); 89 | this.removeTurnOffTranstions(css); 90 | } 91 | } 92 | 93 | export const appState = new AppState(); 94 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app-state'; 2 | export * from './journal-state'; 3 | export * from './entries'; 4 | export * from './search'; 5 | export * from './tags-state'; 6 | -------------------------------------------------------------------------------- /src/store/journal-state.ts: -------------------------------------------------------------------------------- 1 | import { addDays, format, subDays } from 'date-fns'; 2 | import { makeAutoObservable } from 'mobx'; 3 | import { nanoid } from 'nanoid'; 4 | 5 | import { DocumentType } from '@/lib/data-engine/syncing-engine'; 6 | import { saveFileToDisk } from '@/lib/data-engine/syncing-helpers'; 7 | 8 | import { DATE_PATTERN, JOURNAL_NOTES_KEY } from '../lib/constants'; 9 | import { setData } from '../lib/storage'; 10 | import { logger } from '@/lib/logger'; 11 | 12 | export function getDateInStringFormat(date: Date, pattern = DATE_PATTERN) { 13 | return format(date, pattern); 14 | } 15 | 16 | type JournalEntry = { 17 | id: string; 18 | content: string | null; 19 | date: string; 20 | }; 21 | 22 | type JournalEntries = Record; 23 | 24 | class JournalStore { 25 | journalEntry: JournalEntry; 26 | journalEntries: JournalEntries | {} = {}; 27 | 28 | constructor() { 29 | makeAutoObservable(this); 30 | } 31 | 32 | reset() { 33 | this.journalEntry = null; 34 | this.journalEntries = {}; 35 | } 36 | 37 | loadLocalData(data: any[]) { 38 | try { 39 | // console.log('data =>', data); 40 | const allEntries = data.reduce((acc, obj) => { 41 | // console.log('state', obj.fileContent); 42 | 43 | const dateString = getDateInStringFormat(new Date(obj.fileContent?.data?.date)); 44 | 45 | if (!acc[dateString]) { 46 | acc[dateString] = {}; 47 | } 48 | acc[dateString] = { 49 | content: obj.fileContent?.markdown, 50 | id: obj.fileContent.data?.id, 51 | date: obj.fileContent.data?.date, 52 | }; 53 | return acc; 54 | }, {}); 55 | 56 | const today = getDateInStringFormat(new Date()); 57 | let entryForToday: JournalEntry = allEntries[today]; 58 | 59 | if (!entryForToday) { 60 | entryForToday = { 61 | id: nanoid(), 62 | content: null, 63 | date: today, 64 | }; 65 | } 66 | 67 | this.journalEntry = entryForToday; 68 | this.journalEntries = allEntries; 69 | } catch (error) { 70 | // logger.error('loading local data', error); 71 | } 72 | } 73 | 74 | saveContent(editorState: string) { 75 | const updatedEntry: JournalEntry = { 76 | ...(this.journalEntry as JournalEntry), 77 | content: editorState, 78 | }; 79 | 80 | saveFileToDisk({ 81 | type: DocumentType.Journal, 82 | data: { 83 | date: updatedEntry.date, 84 | content: `--- 85 | id: ${updatedEntry.id} 86 | date: ${updatedEntry.date} 87 | --- 88 | ${updatedEntry.content} 89 | `, 90 | }, 91 | }); 92 | 93 | this.journalEntry = updatedEntry; 94 | this.journalEntries[updatedEntry.date] = updatedEntry; 95 | } 96 | 97 | goToNextDay() { 98 | const nextDate = addDays(new Date(this.journalEntry.date), 1); 99 | this.goToDate(nextDate); 100 | } 101 | 102 | goToPreviousDay() { 103 | const prevDate = subDays(new Date(this.journalEntry.date), 1); 104 | 105 | this.goToDate(prevDate); 106 | } 107 | 108 | goToDate(date: Date) { 109 | const dateString = getDateInStringFormat(date); 110 | let entryForToday: JournalEntry = this.journalEntries[dateString]; 111 | 112 | if (!entryForToday) { 113 | entryForToday = { 114 | id: nanoid(), 115 | content: null, 116 | date: dateString, 117 | }; 118 | } 119 | this.journalEntry = entryForToday; 120 | this.journalEntries[dateString] = entryForToday; 121 | setData(JOURNAL_NOTES_KEY, Object.assign({}, this.journalEntries)); 122 | } 123 | 124 | goToToday() { 125 | const currentDate = getDateInStringFormat(new Date()); 126 | 127 | let todayEntry = this.journalEntries[currentDate]; 128 | 129 | if (!todayEntry) { 130 | todayEntry = { 131 | id: nanoid(), 132 | note: null, 133 | date: currentDate, 134 | }; 135 | } 136 | 137 | this.journalEntry = todayEntry; 138 | } 139 | 140 | showDotIndicator(date: Date) { 141 | const dateString = getDateInStringFormat(date); 142 | 143 | if (this.journalEntries[dateString]) { 144 | const note = this.journalEntries[dateString] as JournalEntry; 145 | return note?.content?.length > 0; 146 | } 147 | 148 | return false; 149 | } 150 | } 151 | 152 | export const journalEntryState = new JournalStore(); 153 | -------------------------------------------------------------------------------- /src/store/search.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from 'mobx'; 2 | 3 | class SearchState { 4 | showSearchModal: boolean = false; 5 | 6 | constructor() { 7 | makeAutoObservable(this); 8 | } 9 | 10 | toggleSearchModal() { 11 | this.showSearchModal = !this.showSearchModal; 12 | } 13 | } 14 | 15 | export const searchStore = new SearchState(); 16 | -------------------------------------------------------------------------------- /src/store/tags-state.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, observable } from 'mobx'; 2 | import { nanoid } from 'nanoid'; 3 | 4 | import { DocumentType } from '@/lib/data-engine/syncing-engine'; 5 | import { saveFileToDisk } from '@/lib/data-engine/syncing-helpers'; 6 | 7 | export type Tag = { 8 | label: string; 9 | value: string; 10 | }; 11 | 12 | type TagsMap = Record; 13 | 14 | // TODO: create these at the initial folder creation stage 15 | export const DEFAULT_MAP_TAGS = { 16 | private_5SggNEXrXhrhh6bA_9veW: { 17 | value: 'private_5SggNEXrXhrhh6bA_9veW', 18 | label: 'Private', 19 | }, 20 | today_hzwpYBFRBIfc3YsgGi1cx: { 21 | value: 'today_hzwpYBFRBIfc3YsgGi1cx', 22 | label: 'Today', 23 | }, 24 | highlights_0bDK41N9R5a0G5EOWl5Nc: { 25 | value: 'highlights_0bDK41N9R5a0G5EOWl5Nc', 26 | label: 'Highlights', 27 | }, 28 | }; 29 | 30 | class Tags { 31 | tagsMap: TagsMap = DEFAULT_MAP_TAGS; 32 | 33 | constructor() { 34 | makeAutoObservable(this); 35 | } 36 | 37 | loadLocalData(tags: any) { 38 | const localTags = tags?.fileContent || {}; 39 | Object.assign(this.tagsMap, localTags); 40 | } 41 | 42 | get tags() { 43 | return Object.values(this.tagsMap); 44 | } 45 | 46 | createNewTag(tagLabel: string) { 47 | const value = `${tagLabel.replace(' ', '-').toLocaleLowerCase()}_${nanoid()}`; 48 | 49 | const newTag = { 50 | value, 51 | label: tagLabel, 52 | }; 53 | 54 | this.tagsMap[value] = observable(newTag); 55 | 56 | saveFileToDisk({ 57 | type: DocumentType.Tags, 58 | data: this.tagsMap, 59 | }); 60 | 61 | return value; 62 | } 63 | } 64 | 65 | export const tagsState = new Tags(); 66 | -------------------------------------------------------------------------------- /src/styles/_fonts.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&display=swap'); 2 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); 3 | @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap'); 4 | 5 | .jakarta { 6 | font-family: 'Plus Jakarta Sans', sans-serif; 7 | font-optical-sizing: auto; 8 | font-weight: 400; 9 | font-style: normal; 10 | } 11 | 12 | .jakarta { 13 | font-family: 'Plus Jakarta Sans', sans-serif; 14 | font-optical-sizing: auto; 15 | font-weight: 500; 16 | font-style: normal; 17 | } 18 | .jakarta { 19 | font-family: 'Plus Jakarta Sans', sans-serif; 20 | font-optical-sizing: auto; 21 | font-weight: 600; 22 | font-style: normal; 23 | } 24 | .jakarta { 25 | font-family: 'Plus Jakarta Sans', sans-serif; 26 | font-optical-sizing: auto; 27 | font-weight: 700; 28 | font-style: normal; 29 | } 30 | 31 | @font-face { 32 | font-family: 'BerkeleyMonoVariable'; 33 | src: url('../assets/fonts/BerkeleyMonoVariable-Italic.woff2'); 34 | font-weight: normal; 35 | font-style: italic; 36 | font-display: swap; 37 | } 38 | 39 | @font-face { 40 | font-family: 'BerkeleyMonoVariable'; 41 | src: url('../assets/fonts/BerkeleyMonoVariable-Italic.woff2'); 42 | font-weight: normal; 43 | font-style: italic; 44 | font-display: swap; 45 | } 46 | 47 | @font-face { 48 | font-family: 'BerkeleyMonoVariable'; 49 | src: url('../assets/fonts/BerkeleyMonoVariable-Regular.woff2'); 50 | font-weight: normal; 51 | font-style: normal; 52 | font-display: swap; 53 | } 54 | 55 | @font-face { 56 | font-family: 'Satoshi'; 57 | src: url('../assets/fonts/Satoshi-Bold.woff2'); 58 | font-weight: 700; 59 | font-style: normal; 60 | font-display: swap; 61 | } 62 | 63 | @font-face { 64 | font-family: 'Satoshi'; 65 | src: url('../assets/fonts/Satoshi-Medium.woff2'); 66 | font-weight: 500; 67 | font-style: normal; 68 | font-display: swap; 69 | } 70 | 71 | @font-face { 72 | font-family: 'Geist'; 73 | src: url('../assets/fonts/Geist-Regular.otf'); 74 | font-weight: 400; 75 | font-display: swap; 76 | font-style: normal; 77 | } 78 | 79 | @font-face { 80 | font-family: 'Geist'; 81 | src: url('../assets/fonts/Geist-Medium.otf'); 82 | font-weight: 500; 83 | font-display: swap; 84 | font-style: normal; 85 | } 86 | 87 | @font-face { 88 | font-family: 'Geist'; 89 | src: url('../assets/fonts/Geist-SemiBold.otf'); 90 | font-weight: 600; 91 | font-display: swap; 92 | font-style: normal; 93 | } 94 | 95 | @font-face { 96 | font-family: 'Geist'; 97 | src: url('../assets/fonts/Geist-Bold.otf'); 98 | font-weight: 700; 99 | font-display: swap; 100 | font-style: normal; 101 | } 102 | 103 | @font-face { 104 | font-family: 'Geist-Mono'; 105 | src: url('../assets/fonts/GeistMono-Regular.otf'); 106 | font-weight: 400; 107 | font-display: swap; 108 | font-style: normal; 109 | } 110 | 111 | @font-face { 112 | font-family: 'Geist-Mono'; 113 | src: url('../assets/fonts/GeistMono-Medium.otf'); 114 | font-weight: 500; 115 | font-display: swap; 116 | font-style: normal; 117 | } 118 | 119 | @font-face { 120 | font-family: 'Geist-Mono'; 121 | src: url('../assets/fonts/GeistMono-SemiBold.otf'); 122 | font-weight: 600; 123 | font-display: swap; 124 | font-style: normal; 125 | } 126 | 127 | @font-face { 128 | font-family: 'Geist-Mono'; 129 | src: url('../assets/fonts/GeistMono-Bold.otf'); 130 | font-weight: 700; 131 | font-display: swap; 132 | font-style: normal; 133 | } 134 | 135 | .satoshi-font { 136 | font-family: 'Satoshi', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, 137 | Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 138 | } 139 | 140 | .geist-font { 141 | font-family: 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, 142 | Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 143 | } 144 | 145 | .geist-mono-font { 146 | font-family: 'Geist-Mono', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 147 | Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 148 | } 149 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import 'fonts'; 2 | @import 'global'; 3 | 4 | // components styling 5 | @import '../components/components.scss'; 6 | 7 | // page styling 8 | @import '../routes/pages/journal/journal'; 9 | @import '../routes/pages/safe/safe-loadout'; 10 | @import '../routes/pages/settings/settings'; 11 | @import '../routes/pages/entry/entry'; 12 | @import '../routes/pages/trash/trash'; 13 | @import '../routes/layout/app-layout'; 14 | 15 | // plugin styling 16 | @import '../plugins/PageBreakPlugin/nodes/PageBreakNode'; 17 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare const APP_VERSION: string; 4 | -------------------------------------------------------------------------------- /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": false, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": ".", 19 | "paths": { 20 | "@/*": ["src/*"] 21 | }, 22 | "types": ["node", "@testing-library/jest-dom", "vitest/globals"] 23 | }, 24 | "include": ["src"], 25 | "exclude": ["node_modules"], 26 | "references": [ 27 | { 28 | "path": "./tsconfig.node.json" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": [ 9 | "vite.config.ts" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /** @type {import('vite').UserConfig} */ 2 | import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill'; 3 | import react from '@vitejs/plugin-react'; 4 | import * as path from 'path'; 5 | import { defineConfig } from 'vite'; 6 | import { sentryVitePlugin } from '@sentry/vite-plugin'; 7 | 8 | export default defineConfig({ 9 | plugins: [ 10 | react(), 11 | // sentryVitePlugin({ 12 | // org: 'sundaystudio-ba', 13 | // project: 'ibis', 14 | // authToken: process.env.SENTRY_AUTH_TOKEN, 15 | // telemetry: false, 16 | // }), 17 | ], 18 | define: { 19 | APP_VERSION: JSON.stringify(process.env.npm_package_version), 20 | }, 21 | resolve: { 22 | alias: { 23 | '@': path.resolve(__dirname, 'src'), 24 | }, 25 | }, 26 | server: { 27 | port: 1420, 28 | strictPort: true, 29 | }, 30 | 31 | envPrefix: ['VITE_', 'TAURI_'], 32 | build: { 33 | // Tauri supports es2021 34 | target: process.env.TAURI_PLATFORM == 'windows' ? 'chrome105' : 'safari13', 35 | // don't minify for debug builds 36 | minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, 37 | // produce sourcemaps for debug builds 38 | sourcemap: !!process.env.TAURI_DEBUG, 39 | }, 40 | 41 | optimizeDeps: { 42 | esbuildOptions: { 43 | define: { 44 | global: 'globalThis', 45 | }, 46 | plugins: [ 47 | NodeGlobalsPolyfillPlugin({ 48 | buffer: true, 49 | }), 50 | ], 51 | }, 52 | }, 53 | }); 54 | --------------------------------------------------------------------------------