├── .all-contributorsrc ├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── deploy-netlify.yml │ ├── publish-tauri.yml │ ├── test-tauri.yml │ └── update-artifacts.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── logo.svg └── mockups.png ├── jest.config.ts ├── package.json ├── packages ├── desktop │ ├── .prettierrc.js │ ├── index.html │ ├── package.json │ ├── patches │ │ └── react-split-pane+0.1.92.patch │ ├── public │ │ ├── _redirects │ │ ├── apple-touch-icon.png │ │ ├── favicon.ico │ │ ├── favicon.svg │ │ ├── pwa-192x192.png │ │ ├── pwa-512x512.png │ │ └── robots.txt │ ├── src-tauri │ │ ├── .gitignore │ │ ├── Cargo.lock │ │ ├── Cargo.toml │ │ ├── build.rs │ │ ├── icons │ │ │ ├── 128x128.png │ │ │ ├── 128x128@2x.png │ │ │ ├── 32x32.png │ │ │ ├── icon.icns │ │ │ ├── icon.ico │ │ │ └── icon.png │ │ ├── src │ │ │ └── main.rs │ │ └── tauri.conf.json │ ├── src │ │ ├── App.tsx │ │ ├── components │ │ │ ├── Button │ │ │ │ └── index.tsx │ │ │ ├── KeyboardShortcuts.ts │ │ │ └── ThemeWrapper.tsx │ │ ├── index.tsx │ │ ├── react-app-env.d.ts │ │ ├── reset.css.ts │ │ ├── setupTests.ts │ │ ├── utils │ │ │ ├── constants.ts │ │ │ └── exports.tsx │ │ └── views │ │ │ ├── AppContainer.tsx │ │ │ ├── NoteContainer.tsx │ │ │ ├── NoteEditor.tsx │ │ │ ├── NotePreviewer │ │ │ ├── index.tsx │ │ │ └── style.ts │ │ │ ├── SettingsModal │ │ │ ├── index.tsx │ │ │ ├── panels │ │ │ │ ├── DataManagementPanel.tsx │ │ │ │ └── Preferences.tsx │ │ │ └── styled.ts │ │ │ └── SplitScreenEditor.tsx │ ├── tsconfig.json │ └── vite.config.ts ├── shared │ ├── .eslintignore │ ├── .eslintrc.js │ ├── .prettierignore │ ├── .prettierrc │ ├── package.json │ ├── src │ │ ├── components │ │ │ ├── Button │ │ │ │ ├── index.tsx │ │ │ │ └── style.ts │ │ │ ├── ContextMenu │ │ │ │ ├── index.tsx │ │ │ │ └── style.ts │ │ │ ├── Dropdown │ │ │ │ ├── index.tsx │ │ │ │ └── style.ts │ │ │ ├── HideForMobile │ │ │ │ ├── index.tsx │ │ │ │ └── style.ts │ │ │ ├── Icons.tsx │ │ │ ├── Input │ │ │ │ ├── index.tsx │ │ │ │ └── style.ts │ │ │ ├── NoteEditor │ │ │ │ ├── EditorBar │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styled.ts │ │ │ │ ├── Toolbar │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── style.ts │ │ │ │ └── commands │ │ │ │ │ ├── bold.tsx │ │ │ │ │ ├── code.tsx │ │ │ │ │ ├── header.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── italic.tsx │ │ │ │ │ ├── link.tsx │ │ │ │ │ ├── olist.tsx │ │ │ │ │ ├── quote.tsx │ │ │ │ │ ├── strike.tsx │ │ │ │ │ ├── todo.tsx │ │ │ │ │ ├── ulist.tsx │ │ │ │ │ └── underline.tsx │ │ │ ├── NoteList │ │ │ │ ├── NoteContext.tsx │ │ │ │ ├── NoteItem.tsx │ │ │ │ ├── SearchBar.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── style.ts │ │ │ ├── NotePreviewer │ │ │ │ ├── EmptyEditorMessage.tsx │ │ │ │ └── NoteLink.tsx │ │ │ ├── Popover │ │ │ │ ├── index.tsx │ │ │ │ └── style.ts │ │ │ ├── Select │ │ │ │ ├── index.tsx │ │ │ │ └── style.ts │ │ │ ├── SettingsModal │ │ │ │ ├── Option.tsx │ │ │ │ ├── SelectOption.tsx │ │ │ │ ├── Shortcut.tsx │ │ │ │ └── style.ts │ │ │ ├── Sidebar │ │ │ │ ├── AddCategoryForm.tsx │ │ │ │ ├── CategoryContext.tsx │ │ │ │ ├── CategoryList.tsx │ │ │ │ ├── CategoryOption.tsx │ │ │ │ ├── CollapseCategoriesButton.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── style.ts │ │ │ ├── SplitPanel.tsx │ │ │ ├── Switch │ │ │ │ ├── index.tsx │ │ │ │ └── styled.ts │ │ │ └── Tabs │ │ │ │ ├── Tab.tsx │ │ │ │ ├── TabPanel.tsx │ │ │ │ ├── Tabs.tsx │ │ │ │ └── style.ts │ │ ├── recoil │ │ │ ├── categories.recoil.ts │ │ │ ├── editor.recoil.ts │ │ │ ├── folder.recoil.ts │ │ │ ├── notes.recoil.ts │ │ │ ├── screen.recoil.ts │ │ │ ├── sections.recoil.ts │ │ │ ├── settings.recoil.ts │ │ │ └── types.ts │ │ ├── styles │ │ │ ├── layout.ts │ │ │ ├── theme │ │ │ │ ├── colors.ts │ │ │ │ ├── fonts.ts │ │ │ │ ├── index.ts │ │ │ │ ├── radius.ts │ │ │ │ ├── spacing.ts │ │ │ │ └── styled.d.ts │ │ │ └── typography.ts │ │ └── utils │ │ │ ├── constants.ts │ │ │ ├── editorKeymaps.ts │ │ │ ├── editorThemes.ts │ │ │ ├── enums.ts │ │ │ ├── exports.tsx │ │ │ ├── helpers.ts │ │ │ ├── hooks │ │ │ ├── useDayjs.ts │ │ │ ├── useDeviceOS.ts │ │ │ ├── useKey.ts │ │ │ └── useWindowDimensions.ts │ │ │ └── sorting.ts │ └── tsconfig.json └── web │ ├── .prettierrc.js │ ├── index.html │ ├── package.json │ ├── patches │ └── react-split-pane+0.1.92.patch │ ├── public │ ├── _redirects │ ├── apple-touch-icon.png │ ├── favicon.ico │ ├── favicon.svg │ ├── pwa-192x192.png │ ├── pwa-512x512.png │ └── robots.txt │ ├── src │ ├── App.tsx │ ├── components │ │ ├── Button │ │ │ └── index.tsx │ │ ├── KeyboardShortcuts.ts │ │ ├── MobileNav │ │ │ ├── index.tsx │ │ │ └── style.tsx │ │ └── ThemeWrapper.tsx │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── reset.css.ts │ ├── setupTests.ts │ ├── tests │ │ └── unit │ │ │ └── components │ │ │ ├── Button.test.tsx │ │ │ ├── Dropdown.test.tsx │ │ │ ├── EditorBar.test.tsx │ │ │ ├── Input.test.tsx │ │ │ ├── Select.test.tsx │ │ │ └── Switch.test.tsx │ ├── utils │ │ ├── constants.ts │ │ └── exports.tsx │ └── views │ │ ├── AppContainer.tsx │ │ ├── NoteContainer.tsx │ │ ├── NoteEditor.tsx │ │ ├── NotePreviewer │ │ ├── index.tsx │ │ └── style.ts │ │ ├── SettingsModal │ │ ├── index.tsx │ │ ├── panels │ │ │ ├── DataManagementPanel.tsx │ │ │ └── Preferences.tsx │ │ └── styled.ts │ │ └── SplitScreenEditor.tsx │ ├── tsconfig.json │ └── vite.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── tsconfig.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 60, 6 | "commit": false, 7 | "commitConvention": "angular", 8 | "contributors": [ 9 | { 10 | "login": "Prophetaa", 11 | "name": "Cláudio", 12 | "avatar_url": "https://avatars.githubusercontent.com/u/38473739?v=4", 13 | "profile": "https://github.com/Prophetaa", 14 | "contributions": [ 15 | "code", 16 | "doc", 17 | "maintenance" 18 | ] 19 | } 20 | ], 21 | "contributorsPerLine": 7, 22 | "skipCi": true, 23 | "repoType": "github", 24 | "repoHost": "https://github.com", 25 | "projectName": "Noteup", 26 | "projectOwner": "elementsinteractive" 27 | } 28 | 29 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | __tests__/* 2 | node_modules 3 | build 4 | .eslintrc.js 5 | .yarn 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | plugins: ["@typescript-eslint", "react-hooks"], 5 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 6 | rules: { 7 | "react-hooks/rules-of-hooks": "error", 8 | "react-hooks/exhaustive-deps": [ 9 | "warn", 10 | { 11 | additionalHooks: "(useRecoilCallback|useRecoilTransaction_UNSTABLE)", 12 | }, 13 | ], 14 | }, 15 | settings: { 16 | "import/resolver": { 17 | node: { 18 | paths: ["src"], 19 | extensions: [".js", ".jsx", ".ts", ".tsx"], 20 | }, 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[Title]" 5 | labels: "Type: Bug" 6 | assignees: "" 7 | --- 8 | 9 | **To Reproduce** 10 | Steps to reproduce the behavior (and which environment): 11 | 12 | **Expected behavior** 13 | A clear and concise description of what you expected to happen. 14 | 15 | **Actual behavior** 16 | What actually happened. 17 | 18 | **Notes** 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature]" 5 | labels: "Type: Feature" 6 | assignees: "" 7 | --- 8 | 9 | **Problem** 10 | A clear and concise description of what the problem is. 11 | 12 | **Solution** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Notes** 16 | -------------------------------------------------------------------------------- /.github/workflows/deploy-netlify.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy to Netlify 2 | 3 | on: 4 | release: 5 | types: published 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [18.x] 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Setup node 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: 18 20 | 21 | - name: "create env file" 22 | uses: SpicyPizza/create-envfile@v1 23 | with: 24 | envkey_VITE_FRONTEND_VERSION: $(node -p "require('./package.json').version")" 25 | file_name: .env 26 | 27 | - name: Install pnpm 28 | uses: pnpm/action-setup@v2 29 | id: pnpm-install 30 | with: 31 | version: 7 32 | run_install: false 33 | 34 | - name: Get pnpm store directory 35 | id: pnpm-cache 36 | shell: bash 37 | run: | 38 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 39 | 40 | - uses: actions/cache@v3 41 | name: Setup pnpm cache 42 | with: 43 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 44 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 45 | restore-keys: | 46 | ${{ runner.os }}-pnpm-store- 47 | 48 | - name: install app dependencies 49 | run: pnpm install 50 | 51 | - name: build app 52 | run: pnpm build:web 53 | 54 | - name: run the tests and generate coverage report 55 | run: pnpm test -- --coverage 56 | 57 | - name: codecov 58 | uses: codecov/codecov-action@v2.1.0 59 | 60 | - name: netlify Deploy 61 | env: 62 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 63 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 64 | run: netlify deploy --prod --dir "./packages/web/build" 65 | -------------------------------------------------------------------------------- /.github/workflows/publish-tauri.yml: -------------------------------------------------------------------------------- 1 | name: "publish" 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | publish-tauri: 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | platform: [macos-latest, ubuntu-20.04, windows-latest] 14 | 15 | runs-on: ${{ matrix.platform }} 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Setup node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 18 22 | 23 | - name: install Rust stable 24 | uses: actions-rs/toolchain@v1 25 | with: 26 | toolchain: stable 27 | 28 | - name: install dependencies (ubuntu only) 29 | if: matrix.platform == 'ubuntu-20.04' 30 | run: | 31 | sudo apt-get update 32 | sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf 33 | 34 | - name: get version 35 | run: echo "PACKAGE_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV 36 | 37 | - name: "Create env file" 38 | run: | 39 | touch .env 40 | echo VITE_FRONTEND_VERSION=`${process.env.PACKAGE_VERSION}` >> .env 41 | cat .env 42 | 43 | - name: Install pnpm 44 | uses: pnpm/action-setup@v2 45 | id: pnpm-install 46 | with: 47 | version: 7 48 | run_install: false 49 | 50 | - name: Get pnpm store directory 51 | id: pnpm-cache 52 | shell: bash 53 | run: | 54 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 55 | 56 | - uses: actions/cache@v3 57 | name: Setup pnpm cache 58 | with: 59 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 60 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 61 | restore-keys: | 62 | ${{ runner.os }}-pnpm-store- 63 | 64 | - name: install app dependencies 65 | run: pnpm install 66 | 67 | - name: Build app 68 | run: pnpm build:desktop 69 | 70 | - uses: tauri-apps/tauri-action@v0 71 | env: 72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 73 | with: 74 | tagName: app-v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version 75 | releaseName: "App v__VERSION__" 76 | releaseBody: "See the assets to download this version and install." 77 | releaseDraft: true 78 | prerelease: false 79 | -------------------------------------------------------------------------------- /.github/workflows/test-tauri.yml: -------------------------------------------------------------------------------- 1 | name: "test-tauri-on-pr" 2 | on: [pull_request] 3 | 4 | jobs: 5 | test-tauri: 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | platform: [macos-latest, ubuntu-20.04, windows-latest] 10 | 11 | runs-on: ${{ matrix.platform }} 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Setup node 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 18 19 | 20 | - name: Install Rust stable 21 | uses: actions-rs/toolchain@v1 22 | with: 23 | toolchain: stable 24 | 25 | - name: Install dependencies (ubuntu only) 26 | if: matrix.platform == 'ubuntu-20.04' 27 | run: | 28 | sudo apt-get update 29 | sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf 30 | 31 | - name: Install pnpm 32 | uses: pnpm/action-setup@v2 33 | id: pnpm-install 34 | with: 35 | version: 7 36 | run_install: false 37 | 38 | - name: Get pnpm store directory 39 | id: pnpm-cache 40 | shell: bash 41 | run: | 42 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 43 | 44 | - uses: actions/cache@v3 45 | name: Setup pnpm cache 46 | with: 47 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 48 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 49 | restore-keys: | 50 | ${{ runner.os }}-pnpm-store- 51 | 52 | - name: Install app dependencies 53 | run: pnpm install 54 | 55 | - name: Build app 56 | run: pnpm build:desktop 57 | 58 | - uses: tauri-apps/tauri-action@v0 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # production 12 | build 13 | dist 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | auto-install-peers=true 3 | strict-peer-dependencies=false -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | package-lock.json 5 | coverage 6 | helm 7 | .gitlab-ci.yml 8 | .yarn 9 | *.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "printWidth": 100, 4 | "singleQuote": false, 5 | "trailingComma": "all", 6 | "bracketSpacing": true 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Elements 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Netlify Status](https://api.netlify.com/api/v1/badges/c07f2af9-ca82-4f2e-a051-3de8e232cebe/deploy-status)](https://app.netlify.com/sites/stupendous-cucurucho-1aee31/deploys) 2 | 3 |

4 | 5 | Noteup 6 | 7 | [Live version](https://noteup.dev/) 8 | 9 |

10 | 11 |

A Web and Desktop markup note-taking app.

12 | 13 | ![Screenshot](./assets/mockups.png) 14 | 15 | [Noteup](https://noteup.dev/) is a free, open-source Github-flavored markup note-taking app for the web, mobile(PWA) and desktop. Your notes are saved in the local storage but are available for import/export. 16 | 17 | ## Get started 18 | 19 | To get started just visit [noteup.dev](https://noteup.dev) or [download a build](https://github.com/GarliqBread/Noteup/releases) or clone the repo and run the install and start scripts 20 | 21 | ```properties 22 | yarn install 23 | ``` 24 | 25 | ```properties 26 | yarn start 27 | ``` 28 | 29 | To run the desktop version, after installing run 30 | 31 | ```properties 32 | yarn tauri dev 33 | ``` 34 | 35 | To make a desktop build run 36 | 37 | ```properties 38 | yarn build 39 | yarn tauri build 40 | ``` 41 | 42 | ## Roadmap 43 | 44 | - [x] Tandem scroll for side-by-side editing 45 | - [x] Quick command bar (WYSIWYG style) 46 | - [x] Add a landing page to the website 47 | - [x] Extra download options (like `.pdf`) 48 | - [ ] Add a page with markdown help commands 49 | - [ ] Note sharing 50 | - [ ] Account sync 51 | - [ ] Cross-platform sync 52 | 53 | ## Contributing 54 | 55 | This is an open-source project, and contributions are welcomed and appreciated. Open issues, bugs, and enhancements are all listed on the [issues](https://github.com/GarliqBread/noteup/issues) tab and labeled accordingly. 56 | 57 | View [CONTRIBUTING.md](CONTRIBUTING.md) to learn about the style guide, folder structure, scripts, and how to contribute. 58 | 59 | ## Inspirations 60 | 61 | This project was visually inspired by another markdown note app called [Takenote](https://github.com/taniarascia/takenote) and the macOS notes app. 62 | 63 | ## Contributors ✨ 64 | 65 | Thanks go to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 |

Cláudio Silva

💻 📖 🚧
77 | 78 | 79 | 80 | 81 | 82 | 83 | ## Author 84 | 85 | - [Cláudio](https://github.com/GarliqBread) 86 | 87 | ## License 88 | 89 | This project is open source and available under the [MIT License](LICENSE). 90 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /assets/mockups.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GarliqBread/Noteup/3553f589518bad98c884cdf17f90f69ed4b10c21/assets/mockups.png -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "jest"; 2 | 3 | const config: Config = { 4 | preset: "ts-jest", 5 | rootDir: "./", 6 | moduleFileExtensions: ["ts", "tsx", "js", "jsx"], 7 | coverageDirectory: "/coverage", 8 | collectCoverageFrom: ["/packages/**/src/*.ts"], 9 | testPathIgnorePatterns: ["/node_modules"], 10 | projects: [ 11 | { 12 | displayName: "Web", 13 | testEnvironment: "jsdom", 14 | transform: { 15 | "^.+\\.tsx?$": [ 16 | "ts-jest", 17 | { 18 | diagnostics: false, 19 | }, 20 | ], 21 | "\\.(html|xml|txt|md)$": "jest-raw-loader", 22 | }, 23 | testMatch: ["/packages/web/src/**/*.test.tsx"], 24 | moduleNameMapper: { 25 | "@/(.*)$": "/packages/web/src/$1", 26 | "@noteup/shared/(.*)$": "/packages/shared/src/$1", 27 | }, 28 | setupFilesAfterEnv: ["@testing-library/jest-dom", "jest-extended"], 29 | }, 30 | { 31 | displayName: "Desktop", 32 | testEnvironment: "jsdom", 33 | transform: { 34 | "^.+\\.tsx?$": [ 35 | "ts-jest", 36 | { 37 | diagnostics: false, 38 | }, 39 | ], 40 | "\\.(html|xml|txt|md)$": "jest-raw-loader", 41 | }, 42 | testMatch: ["/packages/desktop/src/**/*.test.tsx"], 43 | moduleNameMapper: { 44 | "@/(.*)$": "/packages/desktop/src/$1", 45 | "@noteup/shared/(.*)$": "/packages/shared/src/$1", 46 | }, 47 | setupFilesAfterEnv: ["@testing-library/jest-dom", "jest-extended"], 48 | }, 49 | ], 50 | }; 51 | 52 | export default config; 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@noteup/root", 3 | "private": true, 4 | "version": "0.4.5", 5 | "scripts": { 6 | "preinstall": "npx only-allow pnpm", 7 | "nuke": "pnpm -r nuke && pnpx rimraf node_modules", 8 | "format": "prettier -w .", 9 | "lint:ts": "eslint . --fix --ext .ts,.tsx", 10 | "lint:other": "prettier **/*.{json,md} --list-different", 11 | "lint": "pnpm run lint:ts && pnpm run lint:other", 12 | "test": "jest --maxWorkers=1", 13 | "test:coverage": "jest --coverage --maxWorkers=1", 14 | "typecheck": "pnpm -r typecheck", 15 | "quality": "pnpm typecheck && pnpm lint", 16 | "clean:all": "pnpm -r clean", 17 | "clean:web": "pnpm --filter @noteup/web clean", 18 | "clean:presentation": "pnpm --filter @noteup/desktop clean", 19 | "start": "pnpm --filter @noteup/web start", 20 | "start:desktop": "pnpm --filter @noteup/desktop start", 21 | "build:web": "pnpm -r --filter @noteup/web run build", 22 | "build:desktop": "pnpm -r --filter @noteup/desktop run build" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+ssh://git@github.com:elementsinteractive/Noteup.git" 27 | }, 28 | "engines": { 29 | "node": ">=18.0.0" 30 | }, 31 | "author": "Claudio Silva", 32 | "license": "MIT", 33 | "devDependencies": { 34 | "@trivago/prettier-plugin-sort-imports": "^3.3.0", 35 | "@types/jest": "^29.2.5", 36 | "@typescript-eslint/eslint-plugin": "^5.48.1", 37 | "@typescript-eslint/parser": "^5.48.1", 38 | "dotenv": "^16.0.3", 39 | "eslint": "^8.31.0", 40 | "eslint-config-airbnb-typescript": "^17.0.0", 41 | "eslint-config-prettier": "^8.6.0", 42 | "eslint-config-standard": "^17.0.0", 43 | "eslint-import-resolver-typescript": "^3.5.3", 44 | "eslint-plugin-import": "^2.27.4", 45 | "eslint-plugin-jsx-a11y": "^6.7.1", 46 | "eslint-plugin-n": "^15.6.1", 47 | "eslint-plugin-promise": "^6.1.1", 48 | "eslint-plugin-react": "^7.32.1", 49 | "husky": "^8.0.3", 50 | "jest": "^29.3.1", 51 | "jest-environment-jsdom": "^29.3.1", 52 | "lint-staged": "^13.0.3", 53 | "ts-jest": "^29.0.0", 54 | "ts-loader": "^9.3.1", 55 | "ts-node": "^10.9.1", 56 | "prettier": "^2.8.2", 57 | "typescript": "^4.9.4" 58 | }, 59 | "pnpm": { 60 | "overrides": { 61 | "@typescript-eslint/eslint-plugin": "^5.48.0", 62 | "@typescript-eslint/parser": "^5.45.0" 63 | } 64 | }, 65 | "dependencies": { 66 | "eslint-import-resolver-node": "^0.3.7" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/desktop/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 2, 3 | printWidth: 100, 4 | singleQuote: false, 5 | trailingComma: "all", 6 | bracketSpacing: true, 7 | importOrder: [ 8 | "^@/recoil/(.*)$", 9 | "^@/utils/(.*)$", 10 | "^@/views/(.*)$", 11 | "^@/components/(.*)$", 12 | "^@/assets/(.*)$", 13 | "^@/styles/(.*)$", 14 | "^[./]", 15 | ], 16 | importOrderSeparation: true, 17 | importOrderSortSpecifiers: true, 18 | plugins: [require("@trivago/prettier-plugin-sort-imports")], 19 | }; 20 | -------------------------------------------------------------------------------- /packages/desktop/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Noteup 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /packages/desktop/patches/react-split-pane+0.1.92.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/react-split-pane/index.d.ts b/node_modules/react-split-pane/index.d.ts 2 | index d116f54..6297ee0 100644 3 | --- a/node_modules/react-split-pane/index.d.ts 4 | +++ b/node_modules/react-split-pane/index.d.ts 5 | @@ -25,6 +25,7 @@ export type SplitPaneProps = { 6 | pane2Style?: React.CSSProperties; 7 | resizerClassName?: string; 8 | step?: number; 9 | + children?: React.ReactNode | React.ReactNode[]; 10 | }; 11 | 12 | export type SplitPaneState = { 13 | -------------------------------------------------------------------------------- /packages/desktop/public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /packages/desktop/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GarliqBread/Noteup/3553f589518bad98c884cdf17f90f69ed4b10c21/packages/desktop/public/apple-touch-icon.png -------------------------------------------------------------------------------- /packages/desktop/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GarliqBread/Noteup/3553f589518bad98c884cdf17f90f69ed4b10c21/packages/desktop/public/favicon.ico -------------------------------------------------------------------------------- /packages/desktop/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/desktop/public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GarliqBread/Noteup/3553f589518bad98c884cdf17f90f69ed4b10c21/packages/desktop/public/pwa-192x192.png -------------------------------------------------------------------------------- /packages/desktop/public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GarliqBread/Noteup/3553f589518bad98c884cdf17f90f69ed4b10c21/packages/desktop/public/pwa-512x512.png -------------------------------------------------------------------------------- /packages/desktop/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Allow: / 4 | -------------------------------------------------------------------------------- /packages/desktop/src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | -------------------------------------------------------------------------------- /packages/desktop/src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "app" 3 | version = "0.4.5" 4 | description = "Markdown notes-taking app" 5 | authors = ["Elements"] 6 | license = "MIT" 7 | repository = "https://github.com/elementsinteractive/Noteup" 8 | default-run = "app" 9 | edition = "2021" 10 | rust-version = "1.57" 11 | 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | 14 | [build-dependencies] 15 | tauri-build = { version = "1.0.4", features = [] } 16 | 17 | [dependencies] 18 | serde_json = "1.0" 19 | serde = { version = "1.0", features = ["derive"] } 20 | tauri = { version = "1.0.5", features = ["api-all"] } 21 | 22 | [features] 23 | # by default Tauri runs in production mode 24 | # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL 25 | default = [ "custom-protocol" ] 26 | # this feature is used used for production builds where `devPath` points to the filesystem 27 | # DO NOT remove this 28 | custom-protocol = [ "tauri/custom-protocol" ] 29 | -------------------------------------------------------------------------------- /packages/desktop/src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /packages/desktop/src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GarliqBread/Noteup/3553f589518bad98c884cdf17f90f69ed4b10c21/packages/desktop/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /packages/desktop/src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GarliqBread/Noteup/3553f589518bad98c884cdf17f90f69ed4b10c21/packages/desktop/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /packages/desktop/src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GarliqBread/Noteup/3553f589518bad98c884cdf17f90f69ed4b10c21/packages/desktop/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /packages/desktop/src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GarliqBread/Noteup/3553f589518bad98c884cdf17f90f69ed4b10c21/packages/desktop/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /packages/desktop/src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GarliqBread/Noteup/3553f589518bad98c884cdf17f90f69ed4b10c21/packages/desktop/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /packages/desktop/src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GarliqBread/Noteup/3553f589518bad98c884cdf17f90f69ed4b10c21/packages/desktop/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /packages/desktop/src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr( 2 | all(not(debug_assertions), target_os = "windows"), 3 | windows_subsystem = "windows" 4 | )] 5 | 6 | use tauri::Manager; 7 | use std::thread::sleep; 8 | use std::time::Duration; 9 | 10 | fn main() { 11 | tauri::Builder::default() 12 | .setup(|app| { 13 | let main_window = app.get_window("main").unwrap(); 14 | tauri::async_runtime::spawn(async move { 15 | sleep(Duration::from_millis(700)); 16 | main_window.show().unwrap(); 17 | }); 18 | 19 | Ok(()) 20 | }) 21 | .run(tauri::generate_context!()) 22 | .expect("error while running tauri application"); 23 | } 24 | -------------------------------------------------------------------------------- /packages/desktop/src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/schema.json", 3 | "build": { 4 | "beforeBuildCommand": "", 5 | "beforeDevCommand": "", 6 | "devPath": "http://localhost:3000", 7 | "distDir": "../build", 8 | "withGlobalTauri": true 9 | }, 10 | "package": { 11 | "productName": "Noteup", 12 | "version": null 13 | }, 14 | "tauri": { 15 | "allowlist": { 16 | "all": true, 17 | "dialog": { 18 | "open": true, 19 | "save": true 20 | }, 21 | "fs": { 22 | "all": true, 23 | "scope": ["$APP"] 24 | } 25 | }, 26 | "bundle": { 27 | "active": true, 28 | "category": "DeveloperTool", 29 | "copyright": "", 30 | "deb": { 31 | "depends": [] 32 | }, 33 | "externalBin": [], 34 | "icon": [ 35 | "icons/32x32.png", 36 | "icons/128x128.png", 37 | "icons/128x128@2x.png", 38 | "icons/icon.icns", 39 | "icons/icon.ico" 40 | ], 41 | "identifier": "noteup.prod", 42 | "longDescription": "", 43 | "macOS": { 44 | "entitlements": null, 45 | "exceptionDomain": "", 46 | "frameworks": [], 47 | "providerShortName": null, 48 | "signingIdentity": null 49 | }, 50 | "resources": [], 51 | "shortDescription": "", 52 | "targets": "all", 53 | "windows": { 54 | "certificateThumbprint": null, 55 | "digestAlgorithm": "sha256", 56 | "timestampUrl": "" 57 | } 58 | }, 59 | "security": { 60 | "csp": null 61 | }, 62 | "updater": { 63 | "active": false 64 | }, 65 | "windows": [ 66 | { 67 | "fullscreen": false, 68 | "resizable": true, 69 | "title": "Noteup", 70 | "url": "/", 71 | "width": 1200, 72 | "height": 1200, 73 | "minWidth": 1000, 74 | "minHeight": 800, 75 | "visible": false 76 | } 77 | ] 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /packages/desktop/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, Route, Routes } from "react-router-dom"; 2 | 3 | import { AppContainer } from "@/views/AppContainer"; 4 | 5 | function App() { 6 | return ( 7 | 8 | 9 | } /> 10 | 11 | 12 | ); 13 | } 14 | 15 | export default App; 16 | -------------------------------------------------------------------------------- /packages/desktop/src/components/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import { StyledButton } from "@noteup/shared/components/Button/style"; 2 | import { open } from "@tauri-apps/api/dialog"; 3 | import { readBinaryFile } from "@tauri-apps/api/fs"; 4 | 5 | type ButtonProps = { 6 | testId?: string; 7 | className?: string; 8 | title?: string; 9 | type?: "button" | "submit" | "reset"; 10 | variant?: string; 11 | disabled?: boolean; 12 | onClick?: () => void; 13 | onUpload?: (file: File) => void; 14 | children: string | React.ReactNode; 15 | }; 16 | 17 | export const UploadButton = ({ 18 | className, 19 | title, 20 | variant, 21 | disabled, 22 | onUpload, 23 | children, 24 | }: ButtonProps) => { 25 | const handleTauriClick = () => { 26 | open({ 27 | filters: [ 28 | { 29 | name: "files", 30 | extensions: ["json"], 31 | }, 32 | ], 33 | }).then((path) => { 34 | if (typeof path === "string") { 35 | readBinaryFile(path).then((res) => { 36 | const blob = new Blob([res], { type: "application/octet-stream" }); 37 | const file = new File([blob], "test", { type: "application/octet-stream" }); 38 | onUpload && onUpload(file); 39 | }); 40 | } 41 | }); 42 | }; 43 | 44 | return ( 45 | 52 | {children} 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /packages/desktop/src/components/KeyboardShortcuts.ts: -------------------------------------------------------------------------------- 1 | import { selectedCategoryIdSelector } from "@noteup/shared/recoil/categories.recoil"; 2 | import { editingSelector } from "@noteup/shared/recoil/editor.recoil"; 3 | import { activeFolderSelector } from "@noteup/shared/recoil/folder.recoil"; 4 | import { notesSelector, selectedNoteSelector } from "@noteup/shared/recoil/notes.recoil"; 5 | import { fullScreenSelector } from "@noteup/shared/recoil/screen.recoil"; 6 | import { themeSelector } from "@noteup/shared/recoil/settings.recoil"; 7 | import { Folder, Shortcuts } from "@noteup/shared/utils/enums"; 8 | import { useKey } from "@noteup/shared/utils/hooks/useKey"; 9 | import dayjs from "dayjs"; 10 | import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil"; 11 | import { v4 as uuid } from "uuid"; 12 | 13 | import { downloadMarkdown } from "@/utils/exports"; 14 | 15 | export const KeyboardShortcuts = () => { 16 | const setNotes = useSetRecoilState(notesSelector); 17 | const selectedCategoryId = useRecoilValue(selectedCategoryIdSelector); 18 | const [activeFolder, setActiveFolder] = useRecoilState(activeFolderSelector); 19 | const [selectedNote, setSelectedNote] = useRecoilState(selectedNoteSelector); 20 | const [editing, setEditing] = useRecoilState(editingSelector); 21 | const [theme, setTheme] = useRecoilState(themeSelector); 22 | const [fullScreen, setFullScreen] = useRecoilState(fullScreenSelector); 23 | 24 | const createNewNote = () => { 25 | setEditing(false); 26 | setNotes([ 27 | { 28 | id: uuid(), 29 | text: "", 30 | created: dayjs().format(), 31 | lastUpdated: dayjs().format(), 32 | categoryId: selectedCategoryId || undefined, 33 | pinned: activeFolder === Folder.PINNED, 34 | }, 35 | ]); 36 | setTimeout(() => setEditing(true), 100); 37 | if (activeFolder === Folder.TRASH) { 38 | setActiveFolder(Folder.ALL); 39 | } 40 | }; 41 | 42 | const deleteCurrentNote = () => { 43 | if (selectedNote) { 44 | setSelectedNote({ 45 | ...selectedNote, 46 | trash: true, 47 | }); 48 | } 49 | }; 50 | 51 | const handleDownloadNotes = () => !!selectedNote && downloadMarkdown(selectedNote); 52 | 53 | const toggleEditing = () => setEditing(!editing); 54 | 55 | const toggleTheme = () => setTheme(theme === "dark" ? "light" : "dark"); 56 | 57 | const toggleFullScreen = () => setFullScreen(!fullScreen); 58 | 59 | useKey(Shortcuts.NEW_NOTE, createNewNote); 60 | useKey(Shortcuts.DELETE_NOTE, deleteCurrentNote); 61 | useKey(Shortcuts.DOWNLOAD_NOTES, handleDownloadNotes); 62 | useKey(Shortcuts.PREVIEW, toggleEditing); 63 | useKey(Shortcuts.TOGGLE_THEME, toggleTheme); 64 | useKey(Shortcuts.TOGGLE_FULL_SCREEN, toggleFullScreen); 65 | 66 | return null; 67 | }; 68 | -------------------------------------------------------------------------------- /packages/desktop/src/components/ThemeWrapper.tsx: -------------------------------------------------------------------------------- 1 | import GlobalStyles from "@/reset.css"; 2 | import { settingsState } from "@noteup/shared/recoil/settings.recoil"; 3 | import { themes } from "@noteup/shared/styles/theme"; 4 | import { darkTheme, lightTheme } from "@noteup/shared/styles/theme/colors"; 5 | import { useRecoilValue } from "recoil"; 6 | import { ThemeProvider } from "styled-components"; 7 | 8 | export const wrapWithTheme = (children: JSX.Element): JSX.Element => ( 9 | {children} 10 | ); 11 | interface Props { 12 | children: JSX.Element; 13 | } 14 | 15 | export const ThemeWrapper = ({ children }: Props) => { 16 | const settings = useRecoilValue(settingsState); 17 | 18 | const themeObject = { 19 | ...themes, 20 | color: settings.theme === "light" ? lightTheme : darkTheme, 21 | mode: settings.theme, 22 | }; 23 | 24 | return ( 25 | 26 | 27 | {children} 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /packages/desktop/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { RecoilRoot } from "recoil"; 4 | 5 | import { ThemeWrapper } from "@/components/ThemeWrapper"; 6 | 7 | import App from "./App"; 8 | 9 | const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); 10 | root.render( 11 | 12 | 13 | 14 | 15 | 16 | 17 | , 18 | ); 19 | -------------------------------------------------------------------------------- /packages/desktop/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/desktop/src/reset.css.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from "styled-components"; 2 | 3 | export default createGlobalStyle` 4 | * { 5 | border: 0; 6 | box-sizing: inherit; 7 | -webkit-font-smoothing: auto; 8 | font-weight: inherit; 9 | margin: 0; 10 | outline: 0; 11 | padding: 0; 12 | text-decoration: none; 13 | text-rendering: optimizeLegibility; 14 | -webkit-appearance: none; 15 | -moz-appearance: none; 16 | } 17 | html { 18 | display: flex; 19 | min-height: 100%; 20 | width: 100%; 21 | box-sizing: border-box; 22 | font-size: 16px; 23 | line-height: 1.5; 24 | color: #16171a; 25 | padding: 0; 26 | margin: 0; 27 | -webkit-font-smoothing: auto; 28 | -webkit-tap-highlight-color: rgba(0,0,0,0); 29 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, 30 | sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 31 | } 32 | body { 33 | box-sizing: border-box; 34 | width: 100%; 35 | height: 100%; 36 | background-color: ${(props) => props.theme.color.firstLayer}; 37 | overscroll-behavior-y: none; 38 | -webkit-overflow-scrolling: touch; 39 | } 40 | #root { 41 | height: 100vh; 42 | width: 100vw; 43 | } 44 | a { 45 | color: currentColor; 46 | text-decoration: none; 47 | } 48 | a:hover { 49 | cursor: pointer; 50 | } 51 | code, p, span { 52 | color: ${(props) => props.theme.color.text}; 53 | } 54 | code { 55 | background-color: ${(props) => props.theme.color.codeBlock}; 56 | } 57 | .code-mirror { 58 | width: 100%; 59 | height: ${({ theme }) => `calc(100% - ${theme.spaces.desktopEditor})`}; 60 | 61 | &-toolbar { 62 | height: ${({ theme }) => `calc(100% - ${theme.spaces.desktopEditorWithToolbar})`}; 63 | } 64 | } 65 | ::-webkit-scrollbar { 66 | width: 6px; 67 | height: 6px; 68 | background-color: ${(props) => props.theme.color.scrollBar}; 69 | } 70 | ::-webkit-scrollbar-thumb { 71 | background: #1f6ce0; 72 | border-radius: 2px; 73 | } 74 | #pdf-preview { 75 | position: absolute; 76 | width: 793px; 77 | z-index: -1; 78 | 79 | .previewer { 80 | min-height: 841px; 81 | } 82 | } 83 | .Resizer { 84 | opacity: 0.2; 85 | z-index: 97; 86 | box-sizing: border-box; 87 | background-clip: padding-box; 88 | } 89 | .Resizer:hover { 90 | transition: all 0.5s ease; 91 | } 92 | .Resizer.vertical { 93 | margin: 0 -5px; 94 | border-left: 5px solid rgba(255, 255, 255, 0); 95 | border-right: 5px solid rgba(255, 255, 255, 0); 96 | cursor: col-resize; 97 | } 98 | .Resizer.vertical:hover { 99 | border-left: 5px solid rgba(0, 0, 0, 0.5); 100 | border-right: 5px solid rgba(0, 0, 0, 0.5); 101 | } 102 | .Resizer.disabled { 103 | cursor: not-allowed; 104 | } 105 | .Resizer.disabled:hover { 106 | border-color: transparent; 107 | } 108 | .Pane { 109 | overflow: hidden; 110 | } 111 | `; 112 | -------------------------------------------------------------------------------- /packages/desktop/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom"; 6 | -------------------------------------------------------------------------------- /packages/desktop/src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const shortcutMap = [ 2 | { action: "Create a new note", key: "N" }, 3 | { action: "Delete a note", key: "U" }, 4 | { action: "Create a category", key: "C" }, 5 | { action: "Download a note", key: "O" }, 6 | { action: "Markdown preview", key: "P" }, 7 | { action: "Toggle theme", key: "K" }, 8 | { action: "Search notes", key: "F" }, 9 | { action: "Toggle full-screen", key: "B" }, 10 | ]; 11 | -------------------------------------------------------------------------------- /packages/desktop/src/utils/exports.tsx: -------------------------------------------------------------------------------- 1 | import { Category, Note } from "@noteup/shared/recoil/types"; 2 | import { save } from "@tauri-apps/api/dialog"; 3 | import { BaseDirectory, writeBinaryFile, writeTextFile } from "@tauri-apps/api/fs"; 4 | import { jsPDF } from "jspdf"; 5 | import { renderToStaticMarkup } from "react-dom/server"; 6 | import { RecoilRoot } from "recoil"; 7 | 8 | import { NotePreview } from "@/views/NotePreviewer"; 9 | 10 | import { ThemeWrapper } from "@/components/ThemeWrapper"; 11 | 12 | export const downloadPdf = async (note: Note) => { 13 | const element = document.getElementById("pdf-preview"); 14 | if (!element) return; 15 | const doc = new jsPDF("p", "pt", [793.706, 841.89]); 16 | element.innerHTML = renderToStaticMarkup( 17 | 18 | 19 | 20 | 21 | , 22 | ); 23 | 24 | doc.setFontSize(9); 25 | doc.html(element, { 26 | autoPaging: "text", 27 | callback: (doc) => 28 | save({ 29 | filters: [ 30 | { 31 | name: "files", 32 | extensions: ["pdf"], 33 | }, 34 | ], 35 | }).then((path) => 36 | writeBinaryFile(path || "", doc.output("arraybuffer"), { 37 | dir: BaseDirectory.App, 38 | }), 39 | ), 40 | }); 41 | }; 42 | 43 | export const downloadMarkdown = (note: Note) => 44 | save({ 45 | filters: [ 46 | { 47 | name: "files", 48 | extensions: ["md"], 49 | }, 50 | ], 51 | }).then((path) => 52 | writeTextFile(path || "", note.text, { 53 | dir: BaseDirectory.App, 54 | }), 55 | ); 56 | 57 | export const backupNotes = (notes: Note[], categories: Category[]) => 58 | save({ 59 | filters: [ 60 | { 61 | name: "files", 62 | extensions: ["json"], 63 | }, 64 | ], 65 | }).then((path) => 66 | writeTextFile(path || "", JSON.stringify({ notes, categories }), { 67 | dir: BaseDirectory.App, 68 | }), 69 | ); 70 | -------------------------------------------------------------------------------- /packages/desktop/src/views/AppContainer.tsx: -------------------------------------------------------------------------------- 1 | import { NoteList } from "@noteup/shared/components/NoteList"; 2 | import { Sidebar } from "@noteup/shared/components/Sidebar"; 3 | import { SplitPane } from "@noteup/shared/components/SplitPanel"; 4 | import { fullScreenSelector } from "@noteup/shared/recoil/screen.recoil"; 5 | import { Container } from "@noteup/shared/styles/layout"; 6 | import { useState } from "react"; 7 | import { useRecoilValue } from "recoil"; 8 | 9 | import { downloadMarkdown, downloadPdf } from "@/utils/exports"; 10 | 11 | import { NoteContainer } from "@/views/NoteContainer"; 12 | import { SettingsModal } from "@/views/SettingsModal"; 13 | 14 | import { KeyboardShortcuts } from "@/components/KeyboardShortcuts"; 15 | 16 | export const AppContainer = () => { 17 | const [showSettings, setShowSettings] = useState(false); 18 | const isFullScreen = useRecoilValue(fullScreenSelector); 19 | 20 | return ( 21 | 22 | {isFullScreen ? ( 23 | 24 | ) : ( 25 | 26 | setShowSettings(true)} /> 27 | 28 | 29 | 30 | 31 | 32 | )} 33 | 34 | {showSettings && setShowSettings(false)} />} 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /packages/desktop/src/views/NoteContainer.tsx: -------------------------------------------------------------------------------- 1 | import { EditorBar } from "@noteup/shared/components/NoteEditor/EditorBar"; 2 | import { EmptyEditorMessage } from "@noteup/shared/components/NotePreviewer/EmptyEditorMessage"; 3 | import { editingSelector, splitSelector } from "@noteup/shared/recoil/editor.recoil"; 4 | import { selectedNoteSelector } from "@noteup/shared/recoil/notes.recoil"; 5 | import { FlexColumn } from "@noteup/shared/styles/layout"; 6 | import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; 7 | import { useRef } from "react"; 8 | import { useRecoilState, useRecoilValue } from "recoil"; 9 | 10 | import { downloadMarkdown, downloadPdf } from "@/utils/exports"; 11 | 12 | import { NoteEditor } from "@/views/NoteEditor"; 13 | import { NotePreview } from "@/views/NotePreviewer"; 14 | 15 | import { SplitScreenEditor } from "./SplitScreenEditor"; 16 | 17 | export const NoteContainer = () => { 18 | const editorRef = useRef(null); 19 | const editing = useRecoilValue(editingSelector); 20 | const split = useRecoilValue(splitSelector); 21 | const [note, setNote] = useRecoilState(selectedNoteSelector); 22 | 23 | return ( 24 | 25 | 26 |
27 | <> 28 | {note ? ( 29 | <> 30 | {editing && split ? ( 31 | 32 | ) : ( 33 | <> 34 | {!editing && } 35 | {editing && } 36 | 37 | )} 38 | 39 | ) : ( 40 | 41 | )} 42 | 43 | 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /packages/desktop/src/views/NoteEditor.tsx: -------------------------------------------------------------------------------- 1 | import { defaultKeymap } from "@codemirror/commands"; 2 | import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; 3 | import { languages } from "@codemirror/language-data"; 4 | import { ViewPlugin, keymap } from "@codemirror/view"; 5 | import { Toolbar } from "@noteup/shared/components/NoteEditor/Toolbar"; 6 | import { 7 | autoCompleteSelector, 8 | breakLinesSelector, 9 | editorThemeSelector, 10 | foldGutterSelector, 11 | lineNumbersSelector, 12 | toolbarSelector, 13 | } from "@noteup/shared/recoil/editor.recoil"; 14 | import { themeSelector } from "@noteup/shared/recoil/settings.recoil"; 15 | import { Note } from "@noteup/shared/recoil/types"; 16 | import { customKeymap } from "@noteup/shared/utils/editorKeymaps"; 17 | import { editorThemes } from "@noteup/shared/utils/editorThemes"; 18 | import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; 19 | import { EditorView } from "codemirror"; 20 | import { RefObject, Suspense, UIEvent, lazy, useMemo } from "react"; 21 | import { useRecoilValue } from "recoil"; 22 | 23 | const CodeMirror = lazy(() => import("@uiw/react-codemirror")); 24 | 25 | type Props = { 26 | editorRef?: RefObject; 27 | note: Note; 28 | setNote: (value: Note) => void; 29 | onScroll?: (e: UIEvent) => void; 30 | }; 31 | 32 | export const NoteEditor = ({ editorRef, note, setNote, onScroll }: Props) => { 33 | const toolbar = useRecoilValue(toolbarSelector); 34 | const breakLines = useRecoilValue(breakLinesSelector); 35 | const foldGutter = useRecoilValue(foldGutterSelector); 36 | const lineNumbers = useRecoilValue(lineNumbersSelector); 37 | const autoComplete = useRecoilValue(autoCompleteSelector); 38 | const theme = useRecoilValue(themeSelector); 39 | const editorTheme = useRecoilValue(editorThemeSelector); 40 | 41 | const codeMirrorOptions = useMemo( 42 | () => ({ 43 | lineNumbers: lineNumbers, 44 | history: true, 45 | foldGutter: foldGutter, 46 | allowMultipleSelections: true, 47 | autocompletion: autoComplete, 48 | }), 49 | [lineNumbers, foldGutter, autoComplete], 50 | ); 51 | const scroll = ViewPlugin.fromClass( 52 | class { 53 | constructor(view: EditorView) { 54 | if (onScroll) { 55 | view.scrollDOM.addEventListener("scroll", (e) => onScroll(e as any)); 56 | } 57 | } 58 | }, 59 | ); 60 | const extensions = useMemo(() => { 61 | const defaultExtensions = [ 62 | scroll, 63 | markdown({ base: markdownLanguage, codeLanguages: languages }), 64 | keymap.of([...defaultKeymap, ...customKeymap]), 65 | ]; 66 | 67 | return breakLines ? [EditorView.lineWrapping, ...defaultExtensions] : defaultExtensions; 68 | }, [scroll, breakLines]); 69 | 70 | return ( 71 | <> 72 | {toolbar && } 73 | 74 | { 80 | setNote({ 81 | ...note, 82 | text: value, 83 | }); 84 | }} 85 | autoFocus 86 | indentWithTab 87 | extensions={extensions} 88 | theme={editorThemes[theme][editorTheme]} 89 | basicSetup={codeMirrorOptions} 90 | /> 91 | 92 | 93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /packages/desktop/src/views/NotePreviewer/index.tsx: -------------------------------------------------------------------------------- 1 | import { NoteLink } from "@noteup/shared/components/NotePreviewer/NoteLink"; 2 | import { previewerThemeSelector, renderHTMLSelector } from "@noteup/shared/recoil/editor.recoil"; 3 | import { folderState } from "@noteup/shared/recoil/folder.recoil"; 4 | import { notesSelector, selectNoteIdSelector } from "@noteup/shared/recoil/notes.recoil"; 5 | import { themeSelector } from "@noteup/shared/recoil/settings.recoil"; 6 | import { Note } from "@noteup/shared/recoil/types"; 7 | import { previewThemes } from "@noteup/shared/utils/editorThemes"; 8 | import { Folder } from "@noteup/shared/utils/enums"; 9 | import { ReactNode, Ref, UIEvent } from "react"; 10 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 11 | import { useRecoilValue, useSetRecoilState } from "recoil"; 12 | import rehypeRaw from "rehype-raw"; 13 | import remarkBreaks from "remark-breaks"; 14 | import remarkGfm from "remark-gfm"; 15 | 16 | import { Previewer, PreviewerWrapper } from "./style"; 17 | 18 | type Props = { 19 | innerRef?: Ref; 20 | previewNote: Note; 21 | border?: boolean; 22 | onScroll?: (e: UIEvent) => void; 23 | }; 24 | 25 | export const NotePreview = ({ innerRef, previewNote, border, onScroll }: Props) => { 26 | const theme = useRecoilValue(themeSelector); 27 | const previewerTheme = useRecoilValue(previewerThemeSelector); 28 | const renderHtml = useRecoilValue(renderHTMLSelector); 29 | const notes = useRecoilValue(notesSelector); 30 | const setSelectedNoteId = useSetRecoilState(selectNoteIdSelector); 31 | const setActiveFolder = useSetRecoilState(folderState); 32 | 33 | const handleNoteLinkClick = (note: Note) => { 34 | if (note) { 35 | setSelectedNoteId(note.id); 36 | 37 | if (note?.pinned) return setActiveFolder(Folder.PINNED); 38 | if (note?.trash) return setActiveFolder(Folder.TRASH); 39 | 40 | return setActiveFolder(Folder.ALL); 41 | } 42 | }; 43 | 44 | const returnNoteLink = (value = "", originalText: ReactNode) => { 45 | return ( 46 | 52 | ); 53 | }; 54 | 55 | return ( 56 | onScroll && onScroll(e)}> 57 | returnNoteLink(href, children), 64 | code({ inline, className, children, ...props }) { 65 | const match = /language-(\w+)/.exec(className || ""); 66 | return !inline && match ? ( 67 | 76 | ) : ( 77 | 78 | {children} 79 | 80 | ); 81 | }, 82 | }} 83 | children={previewNote.text.replaceAll("{{", "[](https://uuid:").replaceAll("}}", ")")} 84 | /> 85 | 86 | ); 87 | }; 88 | -------------------------------------------------------------------------------- /packages/desktop/src/views/SettingsModal/index.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from "@noteup/shared/components/Button"; 2 | import { Close, HardDrive, Keyboard, Sliders } from "@noteup/shared/components/Icons"; 3 | import { Shortcut } from "@noteup/shared/components/SettingsModal/Shortcut"; 4 | import { TabPanel } from "@noteup/shared/components/Tabs/TabPanel"; 5 | import { Tabs } from "@noteup/shared/components/Tabs/Tabs"; 6 | 7 | import { shortcutMap } from "@/utils/constants"; 8 | 9 | import { DataManagementPanel } from "./panels/DataManagementPanel"; 10 | import { PreferencesPanel } from "./panels/Preferences"; 11 | import { Modal, ModalHeader, Overlay, Version, Wrapper } from "./styled"; 12 | 13 | type Props = { 14 | closeModal: () => void; 15 | }; 16 | 17 | export const SettingsModal = ({ closeModal }: Props) => { 18 | return ( 19 | 20 | 21 | 22 | 23 | Settings 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | {shortcutMap.map((shortcut) => ( 37 | 38 | ))} 39 | 40 | 41 | Version {import.meta.env.VITE_FRONTEND_VERSION || "dev"} 42 | 43 | 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /packages/desktop/src/views/SettingsModal/panels/DataManagementPanel.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@noteup/shared/components/Button"; 2 | import { CloudDownload, CloudUpload } from "@noteup/shared/components/Icons"; 3 | import { categoriesSelector } from "@noteup/shared/recoil/categories.recoil"; 4 | import { notesSelector } from "@noteup/shared/recoil/notes.recoil"; 5 | import { Category, Note } from "@noteup/shared/recoil/types"; 6 | import { LabelText } from "@noteup/shared/utils/enums"; 7 | import { useRecoilState } from "recoil"; 8 | 9 | import { backupNotes } from "@/utils/exports"; 10 | 11 | import { UploadButton } from "@/components/Button"; 12 | 13 | type Props = { 14 | closeModal: () => void; 15 | }; 16 | 17 | export const DataManagementPanel = ({ closeModal }: Props) => { 18 | const [notes, setNotes] = useRecoilState(notesSelector); 19 | const [categories, setCategories] = useRecoilState(categoriesSelector); 20 | 21 | const importBackup = async (json: File) => { 22 | const content = await json.text(); 23 | const { notes, categories } = JSON.parse(content) as { 24 | notes: Note[]; 25 | categories: Category[]; 26 | }; 27 | 28 | if (!notes || !categories) return; 29 | 30 | setNotes(notes); 31 | setCategories(categories); 32 | closeModal(); 33 | }; 34 | 35 | return ( 36 | <> 37 |

Export Noteup data as JSON.

38 | 46 |

Import Noteup JSON file.

47 | 53 | {LabelText.IMPORT_BACKUP} 54 | 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /packages/desktop/src/views/SettingsModal/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const Wrapper = styled.div` 4 | position: absolute; 5 | top: 0; 6 | left: 0; 7 | width: 100vw; 8 | height: 100vh; 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | `; 13 | 14 | const Overlay = styled.div` 15 | width: 100vw; 16 | height: 100vh; 17 | position: fixed; 18 | top: 0; 19 | left: 0; 20 | overflow: hidden; 21 | background-color: ${(props) => props.theme.color.overlayColor}; 22 | z-index: 98; 23 | `; 24 | 25 | const Modal = styled.div` 26 | position: relative; 27 | border-radius: 0.3rem; 28 | background: ${(props) => props.theme.color.secondLayer}; 29 | box-shadow: ${(props) => props.theme.color.shadow}; 30 | text-align: left; 31 | width: 850px; 32 | max-width: 90%; 33 | user-select: text; 34 | z-index: 100; 35 | 36 | h2 { 37 | margin: 0; 38 | } 39 | 40 | .download-button { 41 | min-width: 165px; 42 | } 43 | 44 | @media (max-width: 500px) { 45 | max-width: 100%; 46 | width: 100%; 47 | height: 100%; 48 | overflow-y: auto; 49 | } 50 | `; 51 | 52 | const ModalHeader = styled.div` 53 | display: flex; 54 | align-items: center; 55 | justify-content: space-between; 56 | padding: 15px; 57 | background-color: ${(props) => props.theme.color.secondLayer}; 58 | border-bottom: 0.5px solid ${(props) => props.theme.color.border}; 59 | color: ${(props) => props.theme.color.text}; 60 | z-index: 100; 61 | 62 | svg { 63 | color: ${(props) => props.theme.color.text}; 64 | } 65 | 66 | @media (max-width: 500px) { 67 | position: sticky; 68 | top: 0; 69 | } 70 | `; 71 | 72 | const Version = styled.span` 73 | position: absolute; 74 | bottom: 10px; 75 | left: 15px; 76 | color: ${(props) => props.theme.color.lightText}; 77 | font-size: ${(props) => props.theme.fontSizes.body}; 78 | 79 | @media (max-width: 500px) { 80 | display: none; 81 | } 82 | `; 83 | 84 | export { Wrapper, Overlay, Modal, ModalHeader, Version }; 85 | -------------------------------------------------------------------------------- /packages/desktop/src/views/SplitScreenEditor.tsx: -------------------------------------------------------------------------------- 1 | import { toolbarSelector } from "@noteup/shared/recoil/editor.recoil"; 2 | import { Note } from "@noteup/shared/recoil/types"; 3 | import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; 4 | import { RefObject, UIEvent, useEffect, useRef } from "react"; 5 | import SplitPane from "react-split-pane"; 6 | import { useRecoilValue } from "recoil"; 7 | 8 | import { NoteEditor } from "./NoteEditor"; 9 | import { NotePreview } from "./NotePreviewer"; 10 | 11 | type Props = { 12 | editorRef: RefObject; 13 | note: Note; 14 | setNote: (value: Note) => void; 15 | }; 16 | 17 | export const SplitScreenEditor = ({ editorRef, note, setNote }: Props) => { 18 | const showToolbar = useRecoilValue(toolbarSelector); 19 | const active = useRef<"text" | "preview">("text"); 20 | const previewRef = useRef(null); 21 | 22 | const handleScroll = (e: UIEvent) => { 23 | const editorDom = editorRef?.current?.editor?.children[0].children[1] as HTMLDivElement; 24 | const previewDom = previewRef.current; 25 | 26 | if (editorDom && previewDom) { 27 | const scale = 28 | (editorDom.scrollHeight - editorDom.offsetHeight) / 29 | (previewDom.scrollHeight - previewDom.offsetHeight); 30 | 31 | if (e.target === editorDom && active.current === "text") { 32 | previewDom.scrollTop = editorDom.scrollTop / scale; 33 | } 34 | if (e.target === previewDom && active.current === "preview") { 35 | editorDom.scrollTop = previewDom.scrollTop * scale; 36 | } 37 | } 38 | }; 39 | 40 | useEffect(() => { 41 | if (previewRef.current) { 42 | previewRef.current?.addEventListener("mouseover", () => { 43 | active.current = "preview"; 44 | }); 45 | previewRef.current?.addEventListener("mouseleave", () => { 46 | active.current = "text"; 47 | }); 48 | } 49 | }, []); 50 | 51 | return ( 52 |
53 | 54 | 55 | 56 | 57 |
58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /packages/desktop/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./build", 5 | "baseUrl": "./", 6 | "types": ["vite/client"], 7 | "paths": { 8 | "@/*": ["./src/*"], 9 | "@noteup/shared/*": ["../shared/src/*"] 10 | } 11 | }, 12 | "include": ["./src/**/*"], 13 | "exclude": ["./src/**/*.test.tsx"] 14 | } 15 | -------------------------------------------------------------------------------- /packages/desktop/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { defineConfig } from "vite"; 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | 5 | export default defineConfig({ 6 | plugins: [react(), tsconfigPaths()], 7 | server: { port: 3000 }, 8 | build: { 9 | chunkSizeWarningLimit: 1500, 10 | outDir: "build", 11 | rollupOptions: { 12 | output: { 13 | manualChunks(id) { 14 | if (id.includes("node_modules")) { 15 | return id.toString().split("node_modules/")[1].split("/")[0].toString(); 16 | } 17 | }, 18 | }, 19 | }, 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /packages/shared/.eslintignore: -------------------------------------------------------------------------------- 1 | __tests__/* 2 | node_modules 3 | build 4 | .eslintrc.js 5 | .yarn 6 | -------------------------------------------------------------------------------- /packages/shared/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | plugins: ["@typescript-eslint", "react-hooks"], 5 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 6 | rules: { 7 | "react-hooks/rules-of-hooks": "error", 8 | "react-hooks/exhaustive-deps": [ 9 | "warn", 10 | { 11 | additionalHooks: "(useRecoilCallback|useRecoilTransaction_UNSTABLE)", 12 | }, 13 | ], 14 | }, 15 | settings: { 16 | "import/resolver": { 17 | node: { 18 | paths: ["src"], 19 | extensions: [".js", ".jsx", ".ts", ".tsx"], 20 | }, 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /packages/shared/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | package-lock.json 5 | coverage 6 | helm 7 | .gitlab-ci.yml 8 | .yarn 9 | *.json -------------------------------------------------------------------------------- /packages/shared/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "printWidth": 100, 4 | "singleQuote": false, 5 | "trailingComma": "all", 6 | "bracketSpacing": true, 7 | "importOrder": [ 8 | "^@/recoil/(.*)$", 9 | "^@/utils/(.*)$", 10 | "^@/views/(.*)$", 11 | "^@/components/(.*)$", 12 | "^@/assets/(.*)$", 13 | "^@/styles/(.*)$", 14 | "^[./]" 15 | ], 16 | "importOrderSeparation": true, 17 | "importOrderSortSpecifiers": true 18 | } 19 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@noteup/shared", 3 | "version": "0.4.5", 4 | "private": true, 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/elementsinteractive/Noteup" 8 | }, 9 | "engines": { 10 | "node": ">=18.0.0" 11 | }, 12 | "scripts": { 13 | "format": "prettier --write \"./**/*.{json,ts,tsx,js,md}\"", 14 | "lint": "eslint . --ext .js,.ts,.tsx --fix", 15 | "nuke": "pnpx rimraf node_modules" 16 | }, 17 | "dependencies": { 18 | "@codemirror/commands": "^6.2.4", 19 | "@codemirror/lang-markdown": "^6.2.0", 20 | "@codemirror/language-data": "^6.3.1", 21 | "@codemirror/state": "^6.2.1", 22 | "@codemirror/view": "^6.15.3", 23 | "@radix-ui/react-context-menu": "^2.1.4", 24 | "@radix-ui/react-dropdown-menu": "^2.0.5", 25 | "@radix-ui/react-popover": "^1.0.6", 26 | "@testing-library/jest-dom": "^5.17.0", 27 | "@uiw/codemirror-theme-duotone": "^4.21.7", 28 | "@uiw/codemirror-theme-github": "^4.21.7", 29 | "@uiw/codemirror-theme-xcode": "^4.21.7", 30 | "@uiw/react-codemirror": "^4.21.7", 31 | "axios": "^1.4.0", 32 | "codemirror": "^6.0.1", 33 | "dayjs": "^1.11.9", 34 | "file-saver": "^2.0.5", 35 | "jspdf": "^2.5.1", 36 | "mousetrap": "^1.6.5", 37 | "mousetrap-global-bind": "^1.1.0", 38 | "patch-package": "^7.0.2", 39 | "postinstall-postinstall": "^2.1.0", 40 | "react": "^18.2.0", 41 | "react-dom": "^18.2.0", 42 | "react-markdown": "^8.0.7", 43 | "react-router-dom": "^6.14.2", 44 | "react-split-pane": "^0.1.92", 45 | "react-syntax-highlighter": "^15.5.0", 46 | "recoil": "^0.7.7", 47 | "recoil-persist": "^5.1.0", 48 | "rehype-raw": "^6.1.1", 49 | "remark-breaks": "^3.0.3", 50 | "remark-gfm": "^3.0.1", 51 | "remark-parse": "^11.0.0", 52 | "styled-components": "^6.0.4", 53 | "uuid": "^9.0.0" 54 | }, 55 | "devDependencies": { 56 | "@testing-library/react": "^14.0.0", 57 | "@types/codemirror": "^5.60.8", 58 | "@types/file-saver": "^2.0.5", 59 | "@types/mousetrap": "^1.6.11", 60 | "@types/node": "^20.4.2", 61 | "@types/react": "^18.2.15", 62 | "@types/react-dom": "^18.2.7", 63 | "@types/react-syntax-highlighter": "^15.5.7", 64 | "@types/styled-components": "^5.1.26", 65 | "@types/uuid": "^9.0.2", 66 | "@vitejs/plugin-react": "^4.0.3", 67 | "eslint-plugin-react-hooks": "^4.6.0", 68 | "jest-extended": "^4.0.0", 69 | "jest-raw-loader": "^1.0.1", 70 | "jest-styled-components": "^7.1.1" 71 | }, 72 | "lint-staged": { 73 | "**/*.{js,jsx,ts,tsx}": [ 74 | "eslint --fix" 75 | ], 76 | "**/*.{json,css,md}": [ 77 | "prettier --write" 78 | ] 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/shared/src/components/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import { StyledButton, StyledIconButton } from "./style"; 2 | 3 | type ButtonProps = { 4 | testId?: string; 5 | className?: string; 6 | title?: string; 7 | type?: "button" | "submit" | "reset"; 8 | variant?: string; 9 | disabled?: boolean; 10 | onClick?: () => void; 11 | onUpload?: (file: File) => void; 12 | children: string | React.ReactNode; 13 | }; 14 | 15 | export const Button = ({ 16 | testId, 17 | className, 18 | title, 19 | type, 20 | variant, 21 | disabled, 22 | onClick, 23 | children, 24 | }: ButtonProps) => { 25 | return ( 26 | 35 | {children} 36 | 37 | ); 38 | }; 39 | 40 | export const IconButton = ({ className, title, disabled, onClick, children }: ButtonProps) => { 41 | return ( 42 | 43 | {children} 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /packages/shared/src/components/Button/style.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const StyledButton = styled.button<{ variant?: string }>` 4 | display: flex; 5 | align-items: center; 6 | padding: 9px 10px; 7 | border: none; 8 | border-radius: ${(props) => props.theme.radius.small}; 9 | background-color: ${(props) => 10 | props.variant === "primary" 11 | ? props.theme.color.primary 12 | : props.variant === "danger" 13 | ? props.theme.color.danger 14 | : props.theme.color.darkGray}; 15 | color: ${(props) => 16 | props.variant === "primary" || props.variant === "danger" 17 | ? props.theme.color.white 18 | : props.theme.color.text}; 19 | cursor: pointer; 20 | gap: 10px; 21 | 22 | &:hover { 23 | background-color: ${(props) => 24 | props.variant === "danger" 25 | ? props.theme.color.danger 26 | : props.variant === "primary" 27 | ? props.theme.color.primary 28 | : props.theme.color.darkerGray}; 29 | color: ${(props) => props.variant === "danger" && props.theme.color.white}; 30 | opacity: ${(props) => (props.variant === "primary" ? 0.9 : 1)}; 31 | } 32 | 33 | &:disabled { 34 | cursor: not-allowed; 35 | background-color: ${(props) => props.theme.color.input}; 36 | color: ${(props) => props.theme.color.lightText}; 37 | } 38 | 39 | @media (max-width: 500px) { 40 | padding: 13px; 41 | } 42 | `; 43 | 44 | const StyledIconButton = styled.button` 45 | display: flex; 46 | align-items: center; 47 | padding: 5px; 48 | cursor: pointer; 49 | background-color: transparent; 50 | border: none; 51 | 52 | &:hover { 53 | background-color: rbga(0, 0, 0, 0.01); 54 | } 55 | `; 56 | 57 | export { StyledButton, StyledIconButton }; 58 | -------------------------------------------------------------------------------- /packages/shared/src/components/ContextMenu/index.tsx: -------------------------------------------------------------------------------- 1 | import { Portal, Root, Sub, Trigger } from "@radix-ui/react-context-menu"; 2 | 3 | import { 4 | ContextContent, 5 | ContextItem, 6 | ContextMenuSubContent, 7 | ContextMenuSubTrigger, 8 | ContextWrapper, 9 | } from "./style"; 10 | 11 | type Props = { 12 | menu: { 13 | id: string; 14 | onClick?: () => void; 15 | children: JSX.Element; 16 | danger?: boolean; 17 | subMenu?: { 18 | id: string; 19 | onClick?: () => void; 20 | children: JSX.Element; 21 | }[]; 22 | }[]; 23 | children: React.ReactNode; 24 | }; 25 | 26 | export const ContextMenu = ({ menu, children }: Props) => { 27 | return ( 28 | 29 | 30 | {children} 31 | 32 | 33 | {menu.map((item) => 34 | item.subMenu ? ( 35 | 36 | {item.children} 37 | 38 | 39 | {item.subMenu.map((subItem) => ( 40 | 41 | {subItem.children} 42 | 43 | ))} 44 | 45 | 46 | 47 | ) : item.onClick ? ( 48 | 53 | {item.children} 54 | 55 | ) : ( 56 | item.children 57 | ), 58 | )} 59 | 60 | 61 | 62 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /packages/shared/src/components/ContextMenu/style.ts: -------------------------------------------------------------------------------- 1 | import { Content, Item, SubContent, SubTrigger } from "@radix-ui/react-context-menu"; 2 | import styled from "styled-components"; 3 | 4 | const ContextWrapper = styled.div` 5 | position: relative; 6 | width: 100%; 7 | 8 | &:hover { 9 | .dropdown-icon { 10 | visibility: visible !important; 11 | } 12 | } 13 | `; 14 | 15 | const ContextContent = styled(Content)` 16 | display: flex; 17 | flex-direction: column; 18 | background-color: ${(props) => props.theme.color.context}}; 19 | padding: 5px; 20 | border-radius: ${(props) => props.theme.radius.xsmall}; 21 | border: 1px solid ${(props) => props.theme.color.firstLayer}; 22 | min-width: 200px; 23 | box-shadow: ${(props) => props.theme.color.shadower}; 24 | z-index: 105; 25 | 26 | .select { 27 | margin: 3px 0 8px 0; 28 | 29 | select { 30 | padding: 8px; 31 | } 32 | } 33 | `; 34 | 35 | const ContextMenuSubContent = styled(SubContent)` 36 | display: flex; 37 | flex-direction: column; 38 | background-color: ${(props) => props.theme.color.context}}; 39 | padding: 5px; 40 | border-radius: ${(props) => props.theme.radius.xsmall}; 41 | border: 1px solid ${(props) => props.theme.color.firstLayer}; 42 | min-width: 150px; 43 | box-shadow: ${(props) => props.theme.color.shadower}; 44 | z-index: 106; 45 | `; 46 | 47 | const ContextItem = styled(Item)<{ danger?: "true" | undefined }>` 48 | width: 100%; 49 | background-color: transparent; 50 | border: none; 51 | padding: 5px; 52 | display: flex; 53 | align-items: center; 54 | cursor: pointer; 55 | color: ${(props) => props.theme.color.lightText}; 56 | gap: 5px; 57 | 58 | &:hover { 59 | background-color: ${(props) => 60 | props.danger ? props.theme.color.danger : props.theme.color.contrastGray}; 61 | color: ${(props) => props.danger && props.theme.color.white}; 62 | } 63 | 64 | svg { 65 | opacity: 0.8; 66 | } 67 | `; 68 | 69 | const ContextMenuSubTrigger = styled(SubTrigger)` 70 | width: 100%; 71 | background-color: transparent; 72 | border: none; 73 | padding: 5px; 74 | display: flex; 75 | align-items: center; 76 | cursor: pointer; 77 | color: ${(props) => props.theme.color.lightText}; 78 | gap: 5px; 79 | 80 | &:hover, 81 | &[data-state="open"] { 82 | background-color: ${(props) => props.theme.color.contrastGray}; 83 | } 84 | 85 | svg { 86 | opacity: 0.8; 87 | } 88 | `; 89 | 90 | export { 91 | ContextWrapper, 92 | ContextContent, 93 | ContextItem, 94 | ContextMenuSubContent, 95 | ContextMenuSubTrigger, 96 | }; 97 | -------------------------------------------------------------------------------- /packages/shared/src/components/Dropdown/index.tsx: -------------------------------------------------------------------------------- 1 | import { Portal, Root, Sub, Trigger } from "@radix-ui/react-dropdown-menu"; 2 | 3 | import { More } from "../Icons"; 4 | 5 | import { DropItem, MenuContent, MenuSubContent, MenuSubTrigger, TriggerButton } from "./style"; 6 | import { Fragment } from "react"; 7 | 8 | type Props = { 9 | selected?: boolean; 10 | menu: { 11 | id: string; 12 | onClick?: () => void; 13 | children: JSX.Element; 14 | danger?: boolean; 15 | subMenu?: { 16 | id: string; 17 | onClick?: () => void; 18 | children: JSX.Element; 19 | }[]; 20 | }[]; 21 | }; 22 | 23 | export const Dropdown = ({ selected, menu }: Props) => { 24 | return ( 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {menu.map((item) => 33 | item.subMenu ? ( 34 | 35 | {item.children} 36 | 37 | 38 | {item.subMenu.map((subItem) => ( 39 | 40 | {subItem.children} 41 | 42 | ))} 43 | 44 | 45 | 46 | ) : item.onClick ? ( 47 | 52 | {item.children} 53 | 54 | ) : ( 55 | {item.children} 56 | ), 57 | )} 58 | 59 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /packages/shared/src/components/Dropdown/style.ts: -------------------------------------------------------------------------------- 1 | import { Content, Item, SubContent, SubTrigger } from "@radix-ui/react-dropdown-menu"; 2 | import styled from "styled-components"; 3 | 4 | const MenuContent = styled(Content)` 5 | display: flex; 6 | flex-direction: column; 7 | gap: 3; 8 | background-color: ${(props) => props.theme.color.context}}; 9 | padding: 5px; 10 | border-radius: ${(props) => props.theme.radius.xsmall}; 11 | border: 1px solid ${(props) => props.theme.color.firstLayer}; 12 | min-width: 200px; 13 | margin-right: 8px; 14 | box-shadow: ${(props) => props.theme.color.shadower}; 15 | z-index: 105; 16 | 17 | .select { 18 | margin: 3px 0 8px 0; 19 | 20 | select { 21 | padding: 8px; 22 | } 23 | } 24 | `; 25 | 26 | const TriggerButton = styled.button` 27 | background-color: transparent; 28 | border: none; 29 | padding: 5px; 30 | display: flex; 31 | align-items: center; 32 | position: absolute; 33 | right: 5px; 34 | top: 5px; 35 | cursor: pointer; 36 | color: ${(props) => props.theme.color.text}; 37 | 38 | @media (min-width: 500px) { 39 | .dropdown-icon { 40 | visibility: hidden; 41 | } 42 | } 43 | `; 44 | 45 | const DropItem = styled(Item)<{ danger?: "true" | undefined }>` 46 | width: 100%; 47 | background-color: transparent; 48 | border: none; 49 | padding: 5px; 50 | display: flex; 51 | align-items: center; 52 | cursor: pointer; 53 | color: ${(props) => props.theme.color.lightText}; 54 | gap: 5px; 55 | 56 | &:hover { 57 | background-color: ${(props) => 58 | props.danger ? props.theme.color.danger : props.theme.color.contrastGray}; 59 | color: ${(props) => props.danger && props.theme.color.white}; 60 | } 61 | 62 | svg { 63 | opacity: 0.8; 64 | } 65 | `; 66 | 67 | const MenuSubTrigger = styled(SubTrigger)` 68 | width: 100%; 69 | background-color: transparent; 70 | border: none; 71 | padding: 5px; 72 | display: flex; 73 | align-items: center; 74 | cursor: pointer; 75 | color: ${(props) => props.theme.color.lightText}; 76 | gap: 5px; 77 | 78 | &:hover { 79 | background-color: ${(props) => props.theme.color.contrastGray}; 80 | } 81 | 82 | svg { 83 | opacity: 0.8; 84 | } 85 | `; 86 | 87 | const MenuSubContent = styled(SubContent)` 88 | display: flex; 89 | flex-direction: column; 90 | background-color: ${(props) => props.theme.color.context}}; 91 | padding: 5px; 92 | border-radius: ${(props) => props.theme.radius.xsmall}; 93 | border: 1px solid ${(props) => props.theme.color.firstLayer}; 94 | min-width: 150px; 95 | box-shadow: ${(props) => props.theme.color.shadower}; 96 | z-index: 106; 97 | `; 98 | 99 | export { MenuContent, TriggerButton, DropItem, MenuSubTrigger, MenuSubContent }; 100 | -------------------------------------------------------------------------------- /packages/shared/src/components/HideForMobile/index.tsx: -------------------------------------------------------------------------------- 1 | import { Wrapper } from "./style"; 2 | 3 | type Props = { 4 | children: JSX.Element | JSX.Element[]; 5 | }; 6 | 7 | export const HideForMobile = ({ children }: Props) => {children}; 8 | -------------------------------------------------------------------------------- /packages/shared/src/components/HideForMobile/style.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Wrapper = styled.div` 4 | height: 100%; 5 | @media (max-width: 500px) { 6 | display: none; 7 | } 8 | `; 9 | -------------------------------------------------------------------------------- /packages/shared/src/components/Input/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | 3 | import { useKey } from "../../utils/hooks/useKey"; 4 | 5 | import { IconButton } from "../Button"; 6 | import { Close } from "../Icons"; 7 | 8 | import { InputContainer, StyledInput } from "./style"; 9 | 10 | type Props = { 11 | testId?: string; 12 | value: string; 13 | placeholder?: string; 14 | maxLength?: number; 15 | clear?: boolean; 16 | autoFocus?: boolean; 17 | shortcut?: string; 18 | onChange: (value: string) => void; 19 | onBlur?: (value: string) => void; 20 | }; 21 | 22 | export const Input = ({ 23 | testId, 24 | value, 25 | placeholder, 26 | maxLength, 27 | autoFocus, 28 | shortcut, 29 | clear, 30 | onChange, 31 | onBlur, 32 | }: Props) => { 33 | const inputRef = useRef(null); 34 | 35 | const handleClear = () => { 36 | onChange(""); 37 | inputRef.current?.focus(); 38 | }; 39 | 40 | useKey(shortcut, () => inputRef.current?.focus()); 41 | 42 | return ( 43 | 44 | onBlur && onBlur(e.target.value)} 52 | onChange={(e) => onChange(e.target.value)} 53 | /> 54 | {clear && value !== "" && ( 55 | 56 | 57 | 58 | )} 59 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /packages/shared/src/components/Input/style.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const InputContainer = styled.div` 4 | display: flex; 5 | align-items: center; 6 | position: relative; 7 | width: 100%; 8 | 9 | .clear-button { 10 | position: absolute; 11 | right: 5px; 12 | color: ${(props) => props.theme.color.primary}; 13 | } 14 | `; 15 | 16 | const StyledInput = styled.input` 17 | width: 100%; 18 | padding: 8px; 19 | background-color: ${(props) => props.theme.color.input}; 20 | color: ${(props) => props.theme.color.lightText}; 21 | border-radius: ${(props) => props.theme.radius.small}; 22 | border: 0.5px solid ${(props) => props.theme.color.border}; 23 | box-shadow: ${(props) => props.theme.color.shadow}; 24 | font-size: 15px; 25 | 26 | @media (max-width: 500px) { 27 | padding: 13px; 28 | } 29 | `; 30 | 31 | export { InputContainer, StyledInput }; 32 | -------------------------------------------------------------------------------- /packages/shared/src/components/NoteEditor/EditorBar/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const TopNav = styled.nav` 4 | width: 100%; 5 | min-height: 51px; 6 | max-height: 51px; 7 | display: flex; 8 | align-items: center; 9 | justify-content: space-between; 10 | background-color: ${(props) => props.theme.color.secondLayer}; 11 | box-shadow: ${(props) => props.theme.color.shadow}; 12 | z-index: 2; 13 | `; 14 | 15 | const TopNavButton = styled.button<{ trash?: boolean; primary?: boolean }>` 16 | height: 100%; 17 | display: flex; 18 | align-items: center; 19 | padding: 0 12px; 20 | border: none; 21 | background-color: transparent; 22 | cursor: pointer; 23 | color: ${(props) => (props.primary ? props.theme.color.primary : props.theme.color.lightText)}; 24 | &:hover { 25 | background-color: ${(props) => props.theme.color.hover}; 26 | color: ${(props) => props.trash && props.theme.color.danger}; 27 | } 28 | 29 | span { 30 | font-size: 10px; 31 | margin-left: 5px; 32 | } 33 | `; 34 | 35 | export { TopNav, TopNavButton }; 36 | -------------------------------------------------------------------------------- /packages/shared/src/components/NoteEditor/Toolbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; 2 | import { RefObject } from "react"; 3 | 4 | import { defaultCommands } from "../commands"; 5 | import { Bar, CommandButton } from "./style"; 6 | 7 | type Props = { 8 | editorRef?: RefObject; 9 | }; 10 | 11 | export const Toolbar = ({ editorRef }: Props) => { 12 | return ( 13 | 14 | {defaultCommands.map((command, index) => ( 15 | command.execute(editorRef?.current || null)} 20 | > 21 | {command.icon} 22 | 23 | ))} 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /packages/shared/src/components/NoteEditor/Toolbar/style.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const Bar = styled.div` 4 | width: 100%; 5 | display: flex; 6 | align-items: center; 7 | flex-wrap: wrap; 8 | background-color: ${(props) => props.theme.color.secondLayer}; 9 | box-shadow: ${(props) => props.theme.color.shadowBottom}; 10 | border-bottom: 0.5px solid ${(props) => props.theme.color.border}; 11 | z-index: 5; 12 | `; 13 | 14 | const CommandButton = styled.button` 15 | padding: 8px 10px; 16 | border: none; 17 | background-color: transparent; 18 | cursor: pointer; 19 | color: ${(props) => props.theme.color.lightText}; 20 | 21 | &:hover { 22 | background-color: ${(props) => props.theme.color.hover}; 23 | } 24 | `; 25 | 26 | export { Bar, CommandButton }; 27 | -------------------------------------------------------------------------------- /packages/shared/src/components/NoteEditor/commands/bold.tsx: -------------------------------------------------------------------------------- 1 | import { insertBoldMarker } from "../../../utils/editorKeymaps"; 2 | 3 | import { Command } from "../../NoteEditor/commands"; 4 | 5 | export const bold: Command = { 6 | label: "Add bold text", 7 | icon: ( 8 | 9 | 13 | 14 | ), 15 | execute: (editor) => editor?.view && insertBoldMarker(editor.view), 16 | }; 17 | -------------------------------------------------------------------------------- /packages/shared/src/components/NoteEditor/commands/code.tsx: -------------------------------------------------------------------------------- 1 | import { EditorSelection } from "@codemirror/state"; 2 | 3 | import { insertCodeMarker } from "../../../utils/editorKeymaps"; 4 | 5 | import { Command } from "."; 6 | 7 | export const code: Command = { 8 | label: "Insert code", 9 | icon: ( 10 | 11 | 18 | 19 | 20 | ), 21 | execute: (editor) => editor?.view && insertCodeMarker(editor.view), 22 | }; 23 | 24 | export const codeBlock: Command = { 25 | label: "Insert Code Block", 26 | icon: ( 27 | 28 | 35 | 42 | 43 | ), 44 | execute: (editor) => { 45 | if (!editor || !editor.view) return; 46 | const { view } = editor; 47 | 48 | const main = view.state.selection.main; 49 | const txt = view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to); 50 | 51 | view.dispatch({ 52 | changes: { 53 | from: main.from, 54 | to: main.to, 55 | insert: `\`\`\`js\n${txt}\n\`\`\``, 56 | }, 57 | selection: EditorSelection.range(main.from + 3, main.from + 5), 58 | }); 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /packages/shared/src/components/NoteEditor/commands/header.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from "."; 2 | 3 | export const header: Command = { 4 | label: "Add header text", 5 | icon: ( 6 | 7 | 8 | 9 | ), 10 | execute: (editor) => { 11 | if (!editor || !editor.view) return; 12 | const { view } = editor; 13 | 14 | const lineInfo = view.state.doc.lineAt(view.state.selection.main.from); 15 | let mark = "#"; 16 | const matchMark = lineInfo.text.match(/^#+/); 17 | 18 | if (matchMark && matchMark[0]) { 19 | const txt = matchMark[0]; 20 | if (txt.length < 6) { 21 | mark = txt + "#"; 22 | } 23 | } 24 | if (mark.length > 6) { 25 | mark = "#"; 26 | } 27 | 28 | const title = lineInfo.text.replace(/^#+/, ""); 29 | view.dispatch({ 30 | changes: { 31 | from: lineInfo.from, 32 | to: lineInfo.to, 33 | insert: `${mark} ${title}`, 34 | }, 35 | selection: { anchor: lineInfo.from + mark.length + 1 }, 36 | }); 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /packages/shared/src/components/NoteEditor/commands/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; 2 | import { ReactElement } from "react"; 3 | 4 | import { bold } from "./bold"; 5 | import { code, codeBlock } from "./code"; 6 | import { header } from "./header"; 7 | import { italic } from "./italic"; 8 | import { link } from "./link"; 9 | import { olist } from "./olist"; 10 | import { quote } from "./quote"; 11 | import { strike } from "./strike"; 12 | import { todo } from "./todo"; 13 | import { ulist } from "./ulist"; 14 | import { underline } from "./underline"; 15 | 16 | export type Command = { 17 | icon: ReactElement; 18 | label: string; 19 | execute: (editor: ReactCodeMirrorRef | null) => void; 20 | }; 21 | 22 | export const defaultCommands = [ 23 | bold, 24 | italic, 25 | header, 26 | strike, 27 | underline, 28 | quote, 29 | ulist, 30 | olist, 31 | todo, 32 | link, 33 | code, 34 | codeBlock, 35 | ]; 36 | -------------------------------------------------------------------------------- /packages/shared/src/components/NoteEditor/commands/italic.tsx: -------------------------------------------------------------------------------- 1 | import { insertItalicMarker } from "../../../utils/editorKeymaps"; 2 | 3 | import { Command } from "."; 4 | 5 | export const italic: Command = { 6 | label: "Add italic text", 7 | icon: ( 8 | 9 | 13 | 14 | ), 15 | execute: (editor) => editor?.view && insertItalicMarker(editor.view), 16 | }; 17 | -------------------------------------------------------------------------------- /packages/shared/src/components/NoteEditor/commands/link.tsx: -------------------------------------------------------------------------------- 1 | import { insertLinkMarker } from "../../../utils/editorKeymaps"; 2 | 3 | import { Command } from "."; 4 | 5 | export const link: Command = { 6 | label: "Add link text", 7 | icon: ( 8 | 9 | 10 | 11 | ), 12 | execute: (editor) => editor?.view && insertLinkMarker(editor.view), 13 | }; 14 | -------------------------------------------------------------------------------- /packages/shared/src/components/NoteEditor/commands/olist.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from "."; 2 | 3 | export const olist: Command = { 4 | label: "Add ordered List", 5 | icon: ( 6 | 7 | 11 | 12 | ), 13 | execute: (editor) => { 14 | if (!editor || !editor.view) return; 15 | const { view } = editor; 16 | 17 | const lineInfo = view.state.doc.lineAt(view.state.selection.main.from); 18 | let mark = "1. "; 19 | const matchMark = lineInfo.text.match(/^\1\./); 20 | 21 | if (matchMark && matchMark[0]) { 22 | mark = ""; 23 | } 24 | 25 | view.dispatch({ 26 | changes: { 27 | from: lineInfo.from, 28 | to: lineInfo.to, 29 | insert: `${mark}${lineInfo.text}`, 30 | }, 31 | selection: { anchor: view.state.selection.main.from + mark.length }, 32 | }); 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /packages/shared/src/components/NoteEditor/commands/quote.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from "."; 2 | 3 | export const quote: Command = { 4 | label: "Add quote text", 5 | icon: ( 6 | 7 | 8 | 9 | ), 10 | execute: (editor) => { 11 | if (!editor || !editor.view) return; 12 | const { view } = editor; 13 | 14 | const lineInfo = view.state.doc.lineAt(view.state.selection.main.from); 15 | let mark = "> "; 16 | const matchMark = lineInfo.text.match(/^>\s/); 17 | 18 | if (matchMark && matchMark[0]) { 19 | mark = ""; 20 | } 21 | 22 | view.dispatch({ 23 | changes: { 24 | from: lineInfo.from, 25 | to: lineInfo.to, 26 | insert: `${mark}${lineInfo.text}`, 27 | }, 28 | selection: { anchor: view.state.selection.main.from + mark.length }, 29 | }); 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /packages/shared/src/components/NoteEditor/commands/strike.tsx: -------------------------------------------------------------------------------- 1 | import { EditorSelection } from "@codemirror/state"; 2 | 3 | import { Command } from "."; 4 | 5 | export const strike: Command = { 6 | label: "Add strike text", 7 | icon: ( 8 | 9 | 10 | 11 | ), 12 | execute: (editor) => { 13 | if (!editor || !editor.view) return; 14 | const { view } = editor; 15 | 16 | view.dispatch( 17 | view.state.changeByRange((range) => ({ 18 | changes: [ 19 | { from: range.from, insert: "~~" }, 20 | { from: range.to, insert: "~~" }, 21 | ], 22 | range: EditorSelection.range(range.from + 2, range.to + 2), 23 | })), 24 | ); 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /packages/shared/src/components/NoteEditor/commands/todo.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from "."; 2 | 3 | export const todo: Command = { 4 | label: "Add todo List", 5 | icon: ( 6 | 7 | 14 | 15 | ), 16 | execute: (editor) => { 17 | if (!editor || !editor.view) return; 18 | const { view } = editor; 19 | 20 | const lineInfo = view.state.doc.lineAt(view.state.selection.main.from); 21 | let mark = "- [ ] "; 22 | const matchMark = lineInfo.text.match(/^-\s\[\s\]\s/); 23 | 24 | if (matchMark && matchMark[0]) { 25 | mark = ""; 26 | } 27 | 28 | view.dispatch({ 29 | changes: { 30 | from: lineInfo.from, 31 | to: lineInfo.to, 32 | insert: `${mark}${lineInfo.text}`, 33 | }, 34 | selection: { anchor: view.state.selection.main.from + mark.length }, 35 | }); 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /packages/shared/src/components/NoteEditor/commands/ulist.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from "."; 2 | 3 | export const ulist: Command = { 4 | label: "Add ulist text", 5 | icon: ( 6 | 7 | 11 | 12 | ), 13 | execute: (editor) => { 14 | if (!editor || !editor.view) return; 15 | const { view } = editor; 16 | 17 | const lineInfo = view.state.doc.lineAt(view.state.selection.main.from); 18 | let mark = "- "; 19 | const matchMark = lineInfo.text.match(/^-/); 20 | 21 | if (matchMark && matchMark[0]) { 22 | mark = ""; 23 | } 24 | 25 | view.dispatch({ 26 | changes: { 27 | from: lineInfo.from, 28 | to: lineInfo.to, 29 | insert: `${mark}${lineInfo.text}`, 30 | }, 31 | selection: { anchor: view.state.selection.main.from + mark.length }, 32 | }); 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /packages/shared/src/components/NoteEditor/commands/underline.tsx: -------------------------------------------------------------------------------- 1 | import { EditorSelection } from "@codemirror/state"; 2 | 3 | import { Command } from "."; 4 | 5 | export const underline: Command = { 6 | label: "Add underline text", 7 | icon: ( 8 | 9 | 10 | 11 | ), 12 | execute: (editor) => { 13 | if (!editor || !editor.view) return; 14 | const { view } = editor; 15 | 16 | view.dispatch( 17 | view.state.changeByRange((range) => ({ 18 | changes: [ 19 | { from: range.from, insert: "" }, 20 | { from: range.to, insert: "" }, 21 | ], 22 | range: EditorSelection.range(range.from + 3, range.to + 3), 23 | })), 24 | ); 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /packages/shared/src/components/NoteList/NoteItem.tsx: -------------------------------------------------------------------------------- 1 | import { Note } from "../../recoil/types"; 2 | 3 | import { useDayjs } from "../../utils/hooks/useDayjs"; 4 | 5 | import { FilledPin, Folder, Notes } from "../../components/Icons"; 6 | 7 | import { Ellipsis, EllipsisParagraph, Flex } from "../../styles/layout"; 8 | import { Label } from "../../styles/typography"; 9 | 10 | import { NoteItemContainer } from "./style"; 11 | 12 | type Props = { 13 | title: string | JSX.Element; 14 | note: Note; 15 | selected?: boolean; 16 | category: string; 17 | onClick: (id: string) => void; 18 | children: React.ReactNode; 19 | }; 20 | 21 | export const NoteItem = ({ title, note, selected, category, onClick, children }: Props) => { 22 | const { formatTo } = useDayjs(); 23 | 24 | return ( 25 | onClick(note.id)} selected={selected}> 26 | 27 | {note.pinned && } 28 | {title} 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | {note.categoryId ? : } 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /packages/shared/src/components/NoteList/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { useEffect } from "react"; 3 | import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil"; 4 | import { v4 as uuid } from "uuid"; 5 | 6 | import { selectedCategoryIdSelector } from "../../recoil/categories.recoil"; 7 | import { editingSelector } from "../../recoil/editor.recoil"; 8 | import { activeFolderSelector } from "../../recoil/folder.recoil"; 9 | import { keywordSelector, notesSelector, notesState } from "../../recoil/notes.recoil"; 10 | import { sectionsSelector } from "../../recoil/sections.recoil"; 11 | 12 | import { Folder, Section } from "../../utils/enums"; 13 | 14 | import { Button } from "../../components/Button"; 15 | import { Plus, Trash } from "../../components/Icons"; 16 | import { Input } from "../../components/Input"; 17 | 18 | import { SearchContainer } from "./style"; 19 | 20 | type Props = { 21 | isListEmpty: boolean; 22 | }; 23 | 24 | export const SearchBar = ({ isListEmpty }: Props) => { 25 | const [notes, setNotes] = useRecoilState(notesSelector); 26 | const [noteState, setNoteState] = useRecoilState(notesState); 27 | const [keyword, setKeyword] = useRecoilState(keywordSelector); 28 | const setSection = useSetRecoilState(sectionsSelector); 29 | const activeFolder = useRecoilValue(activeFolderSelector); 30 | const selectedCategoryId = useRecoilValue(selectedCategoryIdSelector); 31 | const setEditing = useSetRecoilState(editingSelector); 32 | const isTrash = activeFolder === Folder.TRASH; 33 | 34 | const addNewNote = () => { 35 | setEditing(false); 36 | setNotes([ 37 | { 38 | id: uuid(), 39 | text: "", 40 | created: dayjs().format(), 41 | lastUpdated: dayjs().format(), 42 | categoryId: selectedCategoryId || undefined, 43 | pinned: activeFolder === Folder.PINNED, 44 | }, 45 | ]); 46 | setSection(Section.NOTE); 47 | setTimeout(() => setEditing(true), 100); 48 | }; 49 | 50 | useEffect(() => () => setKeyword(""), [setKeyword]); 51 | 52 | return ( 53 | 54 | 55 | {isTrash ? ( 56 | 69 | ) : ( 70 | 73 | )} 74 | 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /packages/shared/src/components/NoteList/style.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const NotesList = styled.div` 4 | height: 100%; 5 | display: flex; 6 | flex-direction: column; 7 | position: relative; 8 | background-color: ${(props) => props.theme.color.noteList}; 9 | border-right: 0.5px solid ${(props) => props.theme.color.border}; 10 | `; 11 | 12 | const List = styled.div` 13 | height: 100%; 14 | overflow-x: hidden; 15 | overflow-y: auto; 16 | `; 17 | 18 | const EmptyListMessage = styled.p` 19 | width: 100%; 20 | margin-top: 20px; 21 | text-align: center; 22 | color: ${(props) => props.theme.color.gray}; 23 | `; 24 | 25 | const SearchContainer = styled.div` 26 | position: sticky; 27 | width: 100%; 28 | top: 0; 29 | padding: 8px 10px; 30 | display: flex; 31 | justify-content: space-between; 32 | align-items: center; 33 | gap: 8px; 34 | border-bottom: 1px solid ${(props) => props.theme.color.border}; 35 | `; 36 | 37 | const NoteItemContainer = styled.div<{ selected?: boolean }>` 38 | position: relative; 39 | display: flex; 40 | flex-direction: column; 41 | justify-content: space-between; 42 | align-items: flex-start; 43 | padding: 10px 8px; 44 | min-height: 80px; 45 | background-color: ${(props) => 46 | props.selected ? props.theme.color.primary : props.theme.color.noteList}; 47 | border-bottom: 1px solid ${(props) => props.theme.color.border}; 48 | color: ${(props) => (props.selected ? props.theme.color.white : props.theme.color.text)}; 49 | 50 | &:hover { 51 | cursor: pointer; 52 | background-color: ${(props) => !props.selected && props.theme.color.contrastGray}; 53 | } 54 | 55 | label { 56 | color: ${(props) => 57 | props.selected ? props.theme.color.offWhite : props.theme.color.lightText}; 58 | } 59 | 60 | p { 61 | color: ${(props) => 62 | props.selected ? props.theme.color.offWhite : props.theme.color.lightText}; 63 | } 64 | 65 | .highlighted { 66 | color: ${(props) => (props.selected ? props.theme.color.primary : props.theme.color.white)}; 67 | background-color: ${(props) => 68 | props.selected ? props.theme.color.white : props.theme.color.primary}; 69 | } 70 | 71 | .pin { 72 | margin-right: 3px; 73 | color: ${(props) => (props.selected ? props.theme.color.white : props.theme.color.primary)}; 74 | } 75 | `; 76 | 77 | export { NotesList, List, EmptyListMessage, SearchContainer, NoteItemContainer }; 78 | -------------------------------------------------------------------------------- /packages/shared/src/components/NotePreviewer/EmptyEditorMessage.tsx: -------------------------------------------------------------------------------- 1 | import { useDeviceOS } from "../../utils/hooks/useDeviceOS"; 2 | 3 | import { Flex, FlexColumn } from "../../styles/layout"; 4 | import { KBD, LargeText } from "../../styles/typography"; 5 | 6 | export const EmptyEditorMessage = () => { 7 | const { ctrlKey, altKey } = useDeviceOS(); 8 | 9 | return ( 10 | 11 | Create a new note 12 | 13 | {ctrlKey} + {altKey} + N 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /packages/shared/src/components/NotePreviewer/NoteLink.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | import { Note } from "../../recoil/types"; 4 | 5 | import { getNoteTitle } from "../../utils/helpers"; 6 | 7 | export interface NoteLinkProps { 8 | uuid: string; 9 | originalText: ReactNode; 10 | notes: Note[]; 11 | handleNoteLinkClick: (note: Note) => void; 12 | } 13 | 14 | export const NoteLink = ({ notes, uuid, originalText, handleNoteLinkClick }: NoteLinkProps) => { 15 | if (!uuid.includes("https://uuid:")) { 16 | return ( 17 | 18 | {originalText} 19 | 20 | ); 21 | } 22 | const id = uuid.split("uuid:")[1]; 23 | const note = notes.find((note) => note.id === id); 24 | const title = note !== undefined ? getNoteTitle(note.text) : null; 25 | 26 | if (note && title) return handleNoteLinkClick(note)}>{title}; 27 | return <>{`{{${id}}}`}; 28 | }; 29 | -------------------------------------------------------------------------------- /packages/shared/src/components/Popover/index.tsx: -------------------------------------------------------------------------------- 1 | import { Close, Portal, Root, Trigger } from "@radix-ui/react-popover"; 2 | 3 | import { PopoverContent, PopoverItem } from "./style"; 4 | 5 | type Props = { 6 | menu: { 7 | id: string; 8 | onClick: () => void; 9 | children: JSX.Element; 10 | }[]; 11 | children: JSX.Element; 12 | }; 13 | 14 | export const Popover = ({ menu, children }: Props) => ( 15 | 16 | {children} 17 | 18 | 19 | {menu.map((item) => ( 20 | 21 | {item.children} 22 | 23 | ))} 24 | 25 | 26 | 27 | ); 28 | -------------------------------------------------------------------------------- /packages/shared/src/components/Popover/style.ts: -------------------------------------------------------------------------------- 1 | import { Content } from "@radix-ui/react-popover"; 2 | import styled from "styled-components"; 3 | 4 | const PopoverContent = styled(Content)` 5 | display: flex; 6 | flex-direction: column; 7 | background-color: ${(props) => props.theme.color.context}}; 8 | padding: 5px; 9 | border-radius: ${(props) => props.theme.radius.xsmall}; 10 | border: 1px solid ${(props) => props.theme.color.firstLayer}; 11 | min-width: 150px; 12 | box-shadow: ${(props) => props.theme.color.shadower}; 13 | z-index: 105; 14 | `; 15 | 16 | const PopoverItem = styled.div` 17 | width: 100%; 18 | display: flex; 19 | justify-content: space-between; 20 | align-items: center; 21 | background-color: transparent; 22 | border: none; 23 | padding: 5px; 24 | color: ${(props) => props.theme.color.lightText}; 25 | cursor: pointer; 26 | 27 | &:hover { 28 | background-color: ${(props) => props.theme.color.contrastGray}; 29 | } 30 | 31 | svg { 32 | opacity: 0.8; 33 | } 34 | `; 35 | 36 | export { PopoverContent, PopoverItem }; 37 | -------------------------------------------------------------------------------- /packages/shared/src/components/Select/index.tsx: -------------------------------------------------------------------------------- 1 | import { SelectContainer, StyledSelect } from "./style"; 2 | 3 | export type SelectOption = { 4 | label: string; 5 | value: string; 6 | }; 7 | 8 | type Props = { 9 | className?: string; 10 | emptyText?: string; 11 | options: SelectOption[]; 12 | value: string; 13 | onChange: (value: string) => void; 14 | }; 15 | 16 | export const Select = ({ className = "", emptyText, options, onChange, value }: Props) => { 17 | return ( 18 | 19 | onChange(event.target.value)} 22 | value={value} 23 | > 24 | {value === "" && emptyText && } 25 | {options.map((selectOption) => ( 26 | 33 | ))} 34 | 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /packages/shared/src/components/Select/style.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const SelectContainer = styled.div` 4 | position: relative; 5 | height: 30px; 6 | min-width: 130px; 7 | display: flex; 8 | align-items: center; 9 | 10 | &:after { 11 | content: "\\2304"; 12 | color: ${(props) => props.theme.color.lightText}; 13 | top: -2px; 14 | right: 6px; 15 | position: absolute; 16 | pointer-events: none; 17 | } 18 | `; 19 | 20 | const StyledSelect = styled.select` 21 | width: 100%; 22 | font-size: 1rem; 23 | padding: 5px 20px 5px 8px; 24 | background-color: ${(props) => props.theme.color.input}; 25 | color: ${(props) => props.theme.color.lightText}; 26 | border: 1px solid ${(props) => props.theme.color.border}; 27 | border-radius: ${(props) => props.theme.radius.small}; 28 | -webkit-appearance: none; 29 | 30 | &:active &:focus { 31 | border: 1px solid lighten(${(props) => props.theme.color.primary}, 15%); 32 | box-shadow: 0 0 0.2rem lighten(${(props) => props.theme.color.primary}, 15%); 33 | } 34 | `; 35 | 36 | export { SelectContainer, StyledSelect }; 37 | -------------------------------------------------------------------------------- /packages/shared/src/components/SettingsModal/Option.tsx: -------------------------------------------------------------------------------- 1 | import { Switch } from "../Switch"; 2 | 3 | import { OptionContainer } from "./style"; 4 | 5 | type Props = { 6 | title: string; 7 | description: string; 8 | toggle: () => void; 9 | checked: boolean; 10 | }; 11 | 12 | export const Option = ({ title, description, toggle, checked }: Props) => { 13 | return ( 14 | 15 |
16 |

{title}

17 |

{description}

18 |
19 | 20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/shared/src/components/SettingsModal/SelectOption.tsx: -------------------------------------------------------------------------------- 1 | import { Select, SelectOption } from "../Select"; 2 | 3 | import { SelectOptionContainer } from "./style"; 4 | 5 | type Props = { 6 | title: string; 7 | description: string; 8 | onChange: (value: string) => void; 9 | value: string; 10 | options: Array; 11 | }; 12 | 13 | export const SelectOptions = ({ title, description, onChange, value, options }: Props) => { 14 | return ( 15 | 16 |
17 |

{title}

18 |

{description}

19 |
20 | 47 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /packages/shared/src/components/Sidebar/CategoryContext.tsx: -------------------------------------------------------------------------------- 1 | import { useRecoilState } from "recoil"; 2 | 3 | import { categoriesSelector } from "../../recoil/categories.recoil"; 4 | 5 | import { ContextMenu } from "../ContextMenu"; 6 | import { Dropdown } from "../Dropdown"; 7 | import { Close, Edit } from "../Icons"; 8 | 9 | type Props = { 10 | categoryId: string; 11 | setRenamingCategoryId: (id: string) => void; 12 | children: React.ReactNode; 13 | }; 14 | 15 | export const CategoryContext = ({ categoryId, setRenamingCategoryId, children }: Props) => { 16 | const [categories, setCategories] = useRecoilState(categoriesSelector); 17 | 18 | const menu = [ 19 | { 20 | id: "rename", 21 | onClick: () => setRenamingCategoryId(categoryId), 22 | children: ( 23 | <> 24 | Rename category 25 | 26 | ), 27 | }, 28 | { 29 | id: "delete", 30 | onClick: () => setCategories(categories.filter((category) => category.id !== categoryId)), 31 | children: ( 32 | <> 33 | Delete category 34 | 35 | ), 36 | danger: true, 37 | }, 38 | ]; 39 | 40 | return ( 41 | 42 | {children} 43 | 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /packages/shared/src/components/Sidebar/CategoryList.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | import { useRecoilState, useRecoilValue } from "recoil"; 3 | 4 | import { categoriesSelector, openCategoryListSelector } from "../../recoil/categories.recoil"; 5 | 6 | import { LabelText } from "../../utils/enums"; 7 | 8 | import { IconButton } from "../Button"; 9 | import { Plus } from "../Icons"; 10 | 11 | import { AddCategoryForm } from "./AddCategoryForm"; 12 | import { CategoryContext } from "./CategoryContext"; 13 | import { CategoryOption } from "./CategoryOption"; 14 | import { CollapseCategoryListButton } from "./CollapseCategoriesButton"; 15 | import { CategoryTitle, List } from "./style"; 16 | 17 | export const CategoryList = () => { 18 | const [renamingCategoryId, setRenamingCategoryId] = useState(""); 19 | const [addingTempCategory, setAddingTempCategory] = useState(false); 20 | const categories = useRecoilValue(categoriesSelector); 21 | const [categoryListOpen, setCategoryListOpen] = useRecoilState(openCategoryListSelector); 22 | 23 | const cancelRenaming = useCallback(() => setRenamingCategoryId(""), []); 24 | 25 | return ( 26 | <> 27 | 28 | setCategoryListOpen(!categoryListOpen)} 30 | label={LabelText.COLLAPSE_CATEGORY} 31 | isListOpen={categoryListOpen} 32 | showIcon={categories.length > 0} 33 | /> 34 | setAddingTempCategory(true)}> 35 | 36 | 37 | 38 | 39 | {categoryListOpen && 40 | categories.map((category) => ( 41 | 46 | 51 | 52 | ))} 53 | 54 | {addingTempCategory && setAddingTempCategory(false)} />} 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /packages/shared/src/components/Sidebar/CategoryOption.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | import { useRecoilState, useRecoilValue } from "recoil"; 3 | 4 | import { categoriesSelector, selectedCategoryIdSelector } from "../../recoil/categories.recoil"; 5 | import { activeFolderSelector } from "../../recoil/folder.recoil"; 6 | import { Category } from "../../recoil/types"; 7 | 8 | import { Folder } from "../../utils/enums"; 9 | 10 | import { Folder as FolderIcon } from "../Icons"; 11 | import { Input } from "../Input"; 12 | 13 | import { Ellipsis, Flex } from "../../styles/layout"; 14 | 15 | import { CategoryItem, Form } from "./style"; 16 | 17 | type Props = { 18 | category: Category; 19 | renamingId: string; 20 | cancelRenaming: () => void; 21 | }; 22 | 23 | export const CategoryOption = ({ category, renamingId, cancelRenaming }: Props) => { 24 | const [tempName, setTempName] = useState(category.name); 25 | const activeFolder = useRecoilValue(activeFolderSelector); 26 | const [selectedCategoryId, setSelectedCategory] = useRecoilState(selectedCategoryIdSelector); 27 | const [categoryState, updateCategories] = useRecoilState(categoriesSelector); 28 | 29 | const isCategoryFolder = useMemo(() => activeFolder === Folder.CATEGORY, [activeFolder]); 30 | 31 | const handleCategorySelect = () => setSelectedCategory(category.id); 32 | const handleRename = () => { 33 | if (categoryState && renamingId) { 34 | updateCategories( 35 | categoryState.map((c) => 36 | c.id === renamingId 37 | ? { 38 | ...c, 39 | name: tempName, 40 | } 41 | : c, 42 | ), 43 | ); 44 | } 45 | cancelRenaming(); 46 | }; 47 | 48 | const renaming = useMemo(() => renamingId == category.id, [renamingId, category]); 49 | 50 | useEffect(() => { 51 | if (renaming) { 52 | setTimeout(() => document.getElementById("category-input")?.focus(), 100); 53 | } 54 | }, [renaming]); 55 | 56 | return ( 57 | 61 | 62 | 63 | {renaming ? ( 64 |
e.stopPropagation()}> 65 | 72 |
73 | ) : ( 74 | {category.name} 75 | )} 76 |
77 |
78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /packages/shared/src/components/Sidebar/CollapseCategoriesButton.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronDown, ChevronRight, Stack } from "../Icons"; 2 | 3 | import { Label } from "../../styles/typography"; 4 | 5 | import { CollapseButton } from "./style"; 6 | 7 | type Props = { 8 | onClick: () => void; 9 | label: string; 10 | isListOpen: boolean; 11 | showIcon: boolean; 12 | }; 13 | 14 | export const CollapseCategoryListButton = ({ onClick, label, isListOpen, showIcon }: Props) => { 15 | return ( 16 | 17 | {showIcon ? ( 18 | isListOpen ? ( 19 | 20 | ) : ( 21 | 22 | ) 23 | ) : ( 24 | 25 | )} 26 | 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /packages/shared/src/components/Sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { useRecoilState, useRecoilValue } from "recoil"; 3 | 4 | import { activeFolderSelector } from "../../recoil/folder.recoil"; 5 | import { sectionsSelector } from "../../recoil/sections.recoil"; 6 | 7 | import { Folder, Section } from "../../utils/enums"; 8 | import { useWindowDimensions } from "../../utils/hooks/useWindowDimensions"; 9 | 10 | import { Gear, Logo, Notes, Pin, Trash } from "../Icons"; 11 | 12 | import { FlexColumn } from "../../styles/layout"; 13 | 14 | import { CategoryList } from "./CategoryList"; 15 | import { Header, SidebarButton, StyledSidebar } from "./style"; 16 | 17 | type Props = { 18 | showSettings: () => void; 19 | }; 20 | 21 | export const Sidebar = ({ showSettings }: Props) => { 22 | const { isSmallDevice } = useWindowDimensions(); 23 | const section = useRecoilValue(sectionsSelector); 24 | const [activeFolder, setActiveFolder] = useRecoilState(activeFolderSelector); 25 | const inView = useMemo(() => section === Section.MENU, [section]); 26 | 27 | const handleFolderChange = (folder: Folder) => { 28 | setActiveFolder(folder); 29 | }; 30 | 31 | return ( 32 | <> 33 | {(inView || !isSmallDevice) && ( 34 | 35 | 36 |
37 | 38 | Noteup 39 |
40 | handleFolderChange(Folder.ALL)} 43 | > 44 | Notes 45 | 46 | handleFolderChange(Folder.PINNED)} 49 | > 50 | Pinned 51 | 52 | handleFolderChange(Folder.TRASH)} 55 | > 56 | Trash 57 | 58 | 59 |
60 | 61 | 62 | Settings 63 | 64 | 65 |
66 | )} 67 | 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /packages/shared/src/components/SplitPanel.tsx: -------------------------------------------------------------------------------- 1 | import Pane from "react-split-pane"; 2 | 3 | import { useWindowDimensions } from "../utils/hooks/useWindowDimensions"; 4 | 5 | type Props = { 6 | split?: "vertical" | "horizontal"; 7 | minSize?: number; 8 | maxSize?: number; 9 | defaultSize?: number; 10 | children: JSX.Element | JSX.Element[]; 11 | }; 12 | 13 | export const SplitPane = ({ split, minSize, maxSize, defaultSize, children }: Props) => { 14 | const { isSmallDevice } = useWindowDimensions(); 15 | 16 | if (isSmallDevice) { 17 | return <>{children}; 18 | } 19 | 20 | return ( 21 | 22 | {children} 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/shared/src/components/Switch/index.tsx: -------------------------------------------------------------------------------- 1 | import { Label, Slider } from "./styled"; 2 | 3 | type Props = { 4 | testId?: string; 5 | toggle: () => void; 6 | checked: boolean; 7 | }; 8 | 9 | export const Switch = ({ testId, toggle, checked }: Props) => { 10 | return ( 11 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /packages/shared/src/components/Switch/styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const Label = styled.label` 4 | position: relative; 5 | display: inline-block; 6 | min-width: 45px; 7 | width: 45px; 8 | height: 24px; 9 | 10 | input { 11 | opacity: 0; 12 | width: 0; 13 | height: 0; 14 | 15 | &:checked + .slider { 16 | background: ${(props) => props.theme.color.green}; 17 | } 18 | 19 | &:focus + .slider { 20 | box-shadow: 0 0 1px #72ce6e; 21 | } 22 | 23 | &:checked + .slider:before { 24 | transform: translateX(21px); 25 | } 26 | } 27 | `; 28 | 29 | const Slider = styled.span` 30 | position: absolute; 31 | cursor: pointer; 32 | top: 0; 33 | left: 0; 34 | right: 0; 35 | bottom: 0; 36 | background-color: ${(props) => props.theme.color.slider}; 37 | transition: 0.4s; 38 | border-radius: 34px; 39 | 40 | &:before { 41 | position: absolute; 42 | content: ""; 43 | height: 20px; 44 | width: 20px; 45 | left: 2px; 46 | bottom: 2px; 47 | background: white; 48 | transition: 0.4s; 49 | border-radius: 50%; 50 | box-shadow: ${(props) => props.theme.color.shadower}; 51 | } 52 | `; 53 | 54 | export { Label, Slider }; 55 | -------------------------------------------------------------------------------- /packages/shared/src/components/Tabs/Tab.tsx: -------------------------------------------------------------------------------- 1 | import { RenderedIcon } from "../Icons"; 2 | 3 | import { TabContainer } from "./style"; 4 | 5 | type Props = { 6 | label: string; 7 | activeTab: string; 8 | onClick: (label: string) => void; 9 | icon: RenderedIcon; 10 | }; 11 | 12 | export const Tab = ({ activeTab, label, icon: Icon, onClick }: Props) => { 13 | return ( 14 | onClick(label)} 19 | > 20 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /packages/shared/src/components/Tabs/TabPanel.tsx: -------------------------------------------------------------------------------- 1 | import { RenderedIcon } from "../Icons"; 2 | 3 | type Props = { 4 | label: string; 5 | icon: RenderedIcon; 6 | children: JSX.Element[] | JSX.Element; 7 | }; 8 | 9 | export const TabPanel = ({ children }: Props) => { 10 | return
{children}
; 11 | }; 12 | -------------------------------------------------------------------------------- /packages/shared/src/components/Tabs/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useState } from "react"; 2 | 3 | import { Tab } from "./Tab"; 4 | import { TabContent, TabList, TabsContainer } from "./style"; 5 | 6 | type Props = { 7 | children: JSX.Element[]; 8 | }; 9 | 10 | export const Tabs = ({ children }: Props) => { 11 | const [activeTab, setActiveTab] = useState("Preferences"); 12 | 13 | return ( 14 | 15 | 16 | {children.map((child) => ( 17 | 24 | ))} 25 | 26 | 27 | {children.map((child) => { 28 | if (child.props.label !== activeTab) return; 29 | 30 | return ( 31 | 32 |

{child.props.label}

33 | {child.props.children} 34 |
35 | ); 36 | })} 37 |
38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /packages/shared/src/components/Tabs/style.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const TabContainer = styled.div<{ active?: boolean }>` 4 | min-width: 200px; 5 | display: flex; 6 | align-items: center; 7 | gap: 10px; 8 | padding: 0.75rem; 9 | margin: 0.25rem 0; 10 | cursor: pointer; 11 | border-radius: ${(props) => props.theme.radius.small}; 12 | font-weight: 600; 13 | font-size: 0.95rem; 14 | background-color: ${(props) => props.active && props.theme.color.tab}; 15 | color: ${(props) => props.theme.color.lightText}; 16 | 17 | svg { 18 | color: ${(props) => (props.active ? props.theme.color.primary : props.theme.color.text)}; 19 | } 20 | 21 | &:hover { 22 | color: ${(props) => !props.active && props.theme.color.black}; 23 | background-color: ${(props) => !props.active && props.theme.color.tab}; 24 | } 25 | 26 | @media (max-width: 500px) { 27 | min-width: 30%; 28 | justify-content: center; 29 | 30 | span { 31 | display: none; 32 | } 33 | } 34 | `; 35 | 36 | const TabsContainer = styled.div` 37 | display: flex; 38 | flex-direction: row; 39 | align-items: flex-start; 40 | justify-content: flex-start; 41 | width: 100%; 42 | 43 | h3 { 44 | margin-top: 15px; 45 | color: ${(props) => props.theme.color.text}; 46 | } 47 | 48 | p { 49 | font-size: 0.95rem; 50 | color: ${(props) => props.theme.color.text}; 51 | } 52 | 53 | @media (max-width: 500px) { 54 | flex-direction: column; 55 | 56 | h3 { 57 | margin-top: 0; 58 | } 59 | } 60 | `; 61 | 62 | const TabList = styled.nav` 63 | flex: 0 0 220px; 64 | height: 100%; 65 | margin-top: 15px; 66 | padding: 0 15px; 67 | margin-right: 15px; 68 | border-right: 1px solid ${(props) => props.theme.color.border}; 69 | 70 | @media (max-width: 500px) { 71 | flex: initial; 72 | width: 100%; 73 | height: 50px; 74 | display: flex; 75 | justify-content: space-between; 76 | flex-direction: row; 77 | padding: 5px 10px; 78 | margin: 0; 79 | } 80 | `; 81 | 82 | const TabContent = styled.div` 83 | flex: 1; 84 | height: 100%; 85 | height: 60vh; 86 | max-height: 500px; 87 | overflow-y: auto; 88 | padding-right: 40px; 89 | 90 | p { 91 | margin: 10px 0; 92 | } 93 | 94 | @media (max-width: 500px) { 95 | width: 100%; 96 | padding: 15px 10px; 97 | height: 100%; 98 | max-height: none; 99 | } 100 | `; 101 | 102 | export { TabContainer, TabsContainer, TabList, TabContent }; 103 | -------------------------------------------------------------------------------- /packages/shared/src/recoil/categories.recoil.ts: -------------------------------------------------------------------------------- 1 | import { DefaultValue, RecoilState, atom, selector } from "recoil"; 2 | import { recoilPersist } from "recoil-persist"; 3 | 4 | import { Folder } from "../utils/enums"; 5 | 6 | import { activeFolderSelector } from "./folder.recoil"; 7 | import { CategoryState } from "./types"; 8 | 9 | const { persistAtom } = recoilPersist(); 10 | 11 | export const categoryState: RecoilState = atom({ 12 | key: "categories-state", 13 | default: { 14 | categories: [], 15 | selectedCategoryId: null, 16 | categoryListOpen: true, 17 | }, 18 | effects_UNSTABLE: [persistAtom], 19 | }); 20 | 21 | export const categoriesSelector = selector({ 22 | key: "categories-selector", 23 | get: ({ get }) => get(categoryState).categories, 24 | set: ({ get, set }, categories) => 25 | !(categories instanceof DefaultValue) && 26 | set(categoryState, { 27 | ...get(categoryState), 28 | categories, 29 | }), 30 | }); 31 | 32 | export const selectedCategoryIdSelector = selector({ 33 | key: "selected-category-id-selector", 34 | get: ({ get }) => get(categoryState).selectedCategoryId, 35 | set: ({ set, get }, categoryId) => { 36 | if (categoryId === null || typeof categoryId === "string") { 37 | set(categoryState, { 38 | ...get(categoryState), 39 | selectedCategoryId: categoryId, 40 | }); 41 | 42 | if (categoryId) { 43 | set(activeFolderSelector, Folder.CATEGORY); 44 | } 45 | } 46 | }, 47 | }); 48 | 49 | export const selectedCategorySelector = selector({ 50 | key: "selected-category-selector", 51 | get: ({ get }) => { 52 | const state = get(categoryState); 53 | return state.categories.find((category) => category.id === state.selectedCategoryId); 54 | }, 55 | set: ({ set, get }, category) => { 56 | if (category instanceof DefaultValue || !category) return; 57 | const state = get(categoryState); 58 | set(categoryState, { 59 | ...state, 60 | categories: state.categories.map((cat) => (cat.id === category.id ? category : cat)), 61 | }); 62 | }, 63 | }); 64 | 65 | export const openCategoryListSelector = selector({ 66 | key: "open-category-list-selector", 67 | get: ({ get }) => get(categoryState).categoryListOpen, 68 | set: ({ get, set }, status) => 69 | !(status instanceof DefaultValue) && 70 | set(categoryState, { 71 | ...get(categoryState), 72 | categoryListOpen: status, 73 | }), 74 | }); 75 | -------------------------------------------------------------------------------- /packages/shared/src/recoil/folder.recoil.ts: -------------------------------------------------------------------------------- 1 | import { DefaultValue, RecoilState, atom, selector } from "recoil"; 2 | import { recoilPersist } from "recoil-persist"; 3 | 4 | import { Folder, Section } from "../utils/enums"; 5 | import { getNotesSorter } from "../utils/sorting"; 6 | 7 | import { selectedCategoryIdSelector } from "./categories.recoil"; 8 | import { notesSelector, selectNoteIdSelector } from "./notes.recoil"; 9 | import { sectionsSelector } from "./sections.recoil"; 10 | import { sortKeySelector } from "./settings.recoil"; 11 | 12 | const { persistAtom } = recoilPersist(); 13 | 14 | export const folderState: RecoilState = atom({ 15 | key: "folder-state", 16 | default: Folder.ALL, 17 | effects_UNSTABLE: [persistAtom], 18 | }); 19 | 20 | export const activeFolderSelector = selector({ 21 | key: "active-folder-selector", 22 | get: ({ get }) => get(folderState), 23 | set: ({ get, set }, folder) => { 24 | if (folder instanceof DefaultValue) return; 25 | const notes = get(notesSelector); 26 | const sortOrderKey = get(sortKeySelector); 27 | const categoryId = get(selectedCategoryIdSelector); 28 | const availableNotes = !sortOrderKey 29 | ? notes.filter((note) => !note.trash) 30 | : notes.filter((note) => !note.trash).sort(getNotesSorter(sortOrderKey)); 31 | 32 | const firstNote = { 33 | [Folder.ALL]: () => availableNotes[0], 34 | [Folder.CATEGORY]: () => availableNotes.find((note) => note.categoryId === categoryId), 35 | [Folder.PINNED]: () => availableNotes.find((note) => note.pinned), 36 | [Folder.TRASH]: () => notes.find((note) => note.trash), 37 | }[folder](); 38 | 39 | set(selectNoteIdSelector, firstNote ? firstNote.id : ""); 40 | set(folderState, folder); 41 | set(sectionsSelector, Section.LIST); 42 | 43 | if (folder !== Folder.CATEGORY) { 44 | set(selectedCategoryIdSelector, null); 45 | } 46 | }, 47 | }); 48 | -------------------------------------------------------------------------------- /packages/shared/src/recoil/screen.recoil.ts: -------------------------------------------------------------------------------- 1 | import { DefaultValue, RecoilState, atom, selector } from "recoil"; 2 | import { recoilPersist } from "recoil-persist"; 3 | import type { ScreenState } from "./types"; 4 | 5 | const { persistAtom } = recoilPersist(); 6 | 7 | export const screenState: RecoilState = atom({ 8 | key: "screen-state", 9 | default: { 10 | fullScreen: false, 11 | }, 12 | effects_UNSTABLE: [persistAtom], 13 | }); 14 | 15 | export const fullScreenSelector = selector({ 16 | key: "full-screen-selector", 17 | get: ({ get }) => get(screenState).fullScreen, 18 | set: ({ get, set }, fullScreen) => { 19 | if (fullScreen instanceof DefaultValue) return; 20 | set(screenState, { 21 | ...get(screenState), 22 | fullScreen, 23 | }); 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /packages/shared/src/recoil/sections.recoil.ts: -------------------------------------------------------------------------------- 1 | import { DefaultValue, RecoilState, atom, selector } from "recoil"; 2 | import { recoilPersist } from "recoil-persist"; 3 | 4 | import { Section } from "../utils/enums"; 5 | 6 | import { SectionsState } from "./types"; 7 | 8 | const { persistAtom } = recoilPersist(); 9 | 10 | export const sectionsState: RecoilState = atom({ 11 | key: "sections-state", 12 | default: Section.MENU, 13 | effects_UNSTABLE: [persistAtom], 14 | }); 15 | 16 | export const sectionsSelector = selector({ 17 | key: "sections-selector", 18 | get: ({ get }) => get(sectionsState), 19 | set: ({ set }, section) => !(section instanceof DefaultValue) && set(sectionsState, section), 20 | }); 21 | -------------------------------------------------------------------------------- /packages/shared/src/recoil/settings.recoil.ts: -------------------------------------------------------------------------------- 1 | import { DefaultValue, RecoilState, atom, selector } from "recoil"; 2 | import { recoilPersist } from "recoil-persist"; 3 | 4 | import { NotesSortKey } from "../utils/enums"; 5 | 6 | import { SettingsState } from "./types"; 7 | 8 | const { persistAtom } = recoilPersist(); 9 | 10 | export const settingsState: RecoilState = atom({ 11 | key: "settings-state", 12 | default: { 13 | theme: "light", 14 | notesSortKey: NotesSortKey.LAST_UPDATED, 15 | }, 16 | effects_UNSTABLE: [persistAtom], 17 | }); 18 | 19 | export const notesSortKeySelector = selector({ 20 | key: "notes-sort-key", 21 | get: ({ get }) => get(settingsState).notesSortKey, 22 | }); 23 | 24 | export const themeSelector = selector({ 25 | key: "theme-selector", 26 | get: ({ get }) => get(settingsState).theme, 27 | set: ({ get, set }, theme) => { 28 | if (theme instanceof DefaultValue) return; 29 | const currentState: SettingsState = get(settingsState); 30 | set(settingsState, { 31 | ...currentState, 32 | theme, 33 | }); 34 | }, 35 | }); 36 | 37 | export const sortKeySelector = selector({ 38 | key: "sort-key-selector", 39 | get: ({ get }) => get(settingsState).notesSortKey, 40 | set: ({ set, get }, sortKey) => 41 | !(sortKey instanceof DefaultValue) && 42 | set(settingsState, { 43 | ...get(settingsState), 44 | notesSortKey: sortKey, 45 | }), 46 | }); 47 | -------------------------------------------------------------------------------- /packages/shared/src/recoil/types.ts: -------------------------------------------------------------------------------- 1 | import { NotesSortKey, PreviewThemeKey, Section } from "../utils/enums"; 2 | 3 | type EditorTheme = "github" | "xcode" | "duotone"; 4 | 5 | export type Note = { 6 | id: string; 7 | text: string; 8 | created: string; 9 | lastUpdated: string; 10 | categoryId?: string; 11 | deleted?: boolean; 12 | trash?: boolean; 13 | pinned?: boolean; 14 | }; 15 | 16 | export type Category = { 17 | id: string; 18 | name: string; 19 | }; 20 | 21 | export type NotesState = { 22 | notes: Note[]; 23 | keyword: string; 24 | sortBy: keyof NotesSortKey; 25 | selectedNoteId: string | null; 26 | }; 27 | 28 | export type CategoryState = { 29 | categories: Category[]; 30 | selectedCategoryId: string | null; 31 | categoryListOpen: boolean; 32 | }; 33 | 34 | export type SettingsState = { 35 | theme: "light" | "dark"; 36 | notesSortKey: NotesSortKey; 37 | }; 38 | 39 | export type EditorState = { 40 | autoComplete: boolean; 41 | breakLines: boolean; 42 | lineNumbers: boolean; 43 | foldGutter: boolean; 44 | editorTheme: EditorTheme; 45 | previewerTheme: PreviewThemeKey; 46 | renderHTML: boolean; 47 | editing: boolean; 48 | split: boolean; 49 | toolbar: boolean; 50 | }; 51 | 52 | export type SectionsState = Section; 53 | 54 | export type ScreenState = { 55 | fullScreen: boolean; 56 | }; 57 | -------------------------------------------------------------------------------- /packages/shared/src/styles/layout.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | import { lightTheme } from "./theme/colors"; 4 | 5 | type FlexProps = { 6 | width?: string; 7 | height?: string; 8 | margin?: string; 9 | padding?: string; 10 | justifyContent?: string; 11 | alignItems?: string; 12 | gap?: number; 13 | bg?: keyof typeof lightTheme; 14 | }; 15 | 16 | const Container = styled.div` 17 | width: 100vw; 18 | height: 100vh; 19 | overflow: hidden; 20 | `; 21 | 22 | const Flex = styled.div` 23 | position: relative; 24 | width: ${(props) => props.width || "100%"}; 25 | height: ${(props) => props.height}; 26 | margin: ${(props) => props.margin || "0"}; 27 | padding: ${(props) => props.padding || "0"}; 28 | display: flex; 29 | justify-content: ${(props) => props.justifyContent || "flex-start"}; 30 | align-items: ${(props) => props.alignItems || "center"}; 31 | gap: ${(props) => props.gap || 0}px; 32 | `; 33 | 34 | const FlexColumn = styled(Flex)` 35 | flex-direction: column; 36 | align-items: ${(props) => props.alignItems || "flex-start"}; 37 | background-color: ${(props) => props.bg && props.theme.color[props.bg]}; 38 | `; 39 | 40 | const Ellipsis = styled.div` 41 | display: block; 42 | white-space: nowrap; 43 | overflow: hidden; 44 | text-overflow: ellipsis; 45 | font-size: 15px; 46 | max-width: 85%; 47 | `; 48 | 49 | const EllipsisParagraph = styled.p` 50 | overflow: hidden; 51 | text-overflow: ellipsis; 52 | display: -webkit-box; 53 | -webkit-line-clamp: 2; 54 | -webkit-box-orient: vertical; 55 | font-size: 12px; 56 | `; 57 | 58 | export { Container, Flex, FlexColumn, Ellipsis, EllipsisParagraph }; 59 | -------------------------------------------------------------------------------- /packages/shared/src/styles/theme/colors.ts: -------------------------------------------------------------------------------- 1 | export const lightTheme = { 2 | black: "#000000", 3 | border: "#cccccc", 4 | codeBlock: "#f4f4f4", 5 | context: "#ffffff", 6 | contrastGray: "#eaeaea", 7 | danger: "#f26d6f", 8 | darkerGray: "#878D9F", 9 | darkestGray: "#878D9F", 10 | darkGray: "#848484", 11 | firstLayer: "#ffffff", 12 | gray: "#a7a7a7", 13 | green: "#72ce6e", 14 | hover: "rgba(0,0,0,0.08)", 15 | input: "#ffffff", 16 | insetShadow: "0 1px 0 rgba(0, 0, 0, 0.2), 0 0 0 2px #fff inset", 17 | keyBlock: "#f7f7f7", 18 | lightGray: "#f7f4f4", 19 | lightText: "#595858", 20 | noteList: "#efefef", 21 | offWhite: "#ffffffb3", 22 | overlayColor: "rgba(0, 0, 0, 0.15)", 23 | primary: "#1f75fe", 24 | scrollBar: "#dbdbdb", 25 | secondLayer: "#ffffff", 26 | shadow: "rgba(0, 0, 0, 0.1) 0px 1px 4px", 27 | shadower: "rgba(99, 99, 99, 0.2) 0px 2px 8px 0px", 28 | shadowBottom: "rgba(0, 0, 0, 0.04) 0px 3px 5px", 29 | slider: "#d0d0d0", 30 | tab: "#ededed", 31 | text: "#404040", 32 | white: "#ffffff", 33 | }; 34 | 35 | export const darkTheme = { 36 | black: "#000000", 37 | border: "#090909", 38 | codeBlock: "#525252", 39 | context: "#3a3a3a", 40 | contrastGray: "#4c4c4c", 41 | danger: "#f26d6f", 42 | darkerGray: "#b8b8b8", 43 | darkestGray: "#b8b8b8", 44 | darkGray: "#111111", 45 | firstLayer: "#222222", 46 | gray: "#f2f2f2", 47 | green: "#6ec46a", 48 | hover: "rgba(0,0,0,0.15)", 49 | input: "#404040", 50 | insetShadow: "0 1px 0 rgb(255 255 255 / 20%), 0 0 0 2px #222 inset", 51 | keyBlock: "#262626", 52 | lightGray: "#f7f4f4", 53 | lightText: "#d0d0d0", 54 | noteList: "#242424", 55 | offWhite: "#ffffffb3", 56 | overlayColor: "rgba(0, 0, 0, 0.70)", 57 | primary: "#1f75fe", 58 | scrollBar: "#606060", 59 | secondary: "#2e2a36", 60 | secondLayer: "#303030", 61 | shadow: "none", 62 | shadower: "0 10px 20px rgb(0 0 0 / 20%), 0 5px 5px rgb(0 0 0 / 25%)", 63 | shadowBottom: "rgba(0, 0, 0, 0.2) 0px 3px 5px", 64 | slider: "#1a1a1a", 65 | tab: "#242424", 66 | text: "#ffffff", 67 | white: "#ffffff", 68 | }; 69 | -------------------------------------------------------------------------------- /packages/shared/src/styles/theme/fonts.ts: -------------------------------------------------------------------------------- 1 | const fontWeights = { 2 | light: 300, 3 | regular: 400, 4 | medium: 500, 5 | bold: 700, 6 | }; 7 | 8 | const fontSizes = { 9 | small: "10px", 10 | label: "12px", 11 | body: "14px", 12 | large: "16px", 13 | h5: "18px", 14 | h4: "20px", 15 | h3: "24px", 16 | h2: "26px", 17 | h1: "34px", 18 | }; 19 | 20 | export { fontWeights, fontSizes }; 21 | -------------------------------------------------------------------------------- /packages/shared/src/styles/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { darkTheme, lightTheme } from "./colors"; 2 | import { fontSizes, fontWeights } from "./fonts"; 3 | import { radius } from "./radius"; 4 | import { spaces } from "./spacing"; 5 | 6 | export const themes = { 7 | spaces, 8 | color: lightTheme || darkTheme, 9 | fontSizes, 10 | fontWeights, 11 | radius, 12 | }; 13 | 14 | export type MyTheme = typeof themes; 15 | -------------------------------------------------------------------------------- /packages/shared/src/styles/theme/radius.ts: -------------------------------------------------------------------------------- 1 | export const radius = { 2 | xsmall: "3px", 3 | small: "4px", 4 | regular: "5px", 5 | medium: "10px", 6 | large: "14px", 7 | xlarge: "20px", 8 | round: "100px", 9 | }; 10 | -------------------------------------------------------------------------------- /packages/shared/src/styles/theme/spacing.ts: -------------------------------------------------------------------------------- 1 | export const spaces = { 2 | desktopEditor: "46px", 3 | desktopEditorWithToolbar: "80px", 4 | desktopPreview: "46px", 5 | mobileEditor: "89px", 6 | }; 7 | -------------------------------------------------------------------------------- /packages/shared/src/styles/theme/styled.d.ts: -------------------------------------------------------------------------------- 1 | import { MyTheme } from "."; 2 | 3 | declare module "styled-components" { 4 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 5 | export interface DefaultTheme extends MyTheme {} 6 | } 7 | -------------------------------------------------------------------------------- /packages/shared/src/styles/typography.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Label = styled.label` 4 | font-size: ${(props) => props.theme.fontSizes.label}; 5 | color: ${(props) => props.theme.color.lightText}; 6 | `; 7 | 8 | export const LargeText = styled.p` 9 | font-size: ${(props) => props.theme.fontSizes.large}; 10 | color: ${(props) => props.theme.color.lightText}; 11 | `; 12 | 13 | export const KBD = styled.kbd` 14 | background-color: ${(props) => props.theme.color.keyBlock}; 15 | border: 1px solid ${(props) => props.theme.color.border}; 16 | border-radius: 3px; 17 | box-shadow: ${(props) => props.theme.color.insetShadow}; 18 | color: ${(props) => props.theme.color.lightText}; 19 | display: inline-block; 20 | font-family: Arial, sans-serif; 21 | font-size: 14px; 22 | line-height: 1.4; 23 | margin: 0 1.5px; 24 | padding: 1.5px 8.5px; 25 | `; 26 | -------------------------------------------------------------------------------- /packages/shared/src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | import { EditorThemeKey, Folder, NotesSortKey, PreviewThemeKey, Section } from "../utils/enums"; 2 | 3 | export const folderMap: Record = { 4 | [Folder.ALL]: "All Notes", 5 | [Folder.PINNED]: "Pinned", 6 | [Folder.TRASH]: "Trash", 7 | [Folder.CATEGORY]: "Category", 8 | }; 9 | 10 | export const notesSortOptions = [ 11 | { value: NotesSortKey.TITLE, label: "Title" }, 12 | { value: NotesSortKey.CREATED_DATE, label: "Date Created" }, 13 | { value: NotesSortKey.LAST_UPDATED, label: "Last Updated" }, 14 | ]; 15 | 16 | export const themeEditorOptions = [ 17 | { value: EditorThemeKey.GITHUB, label: "Github" }, 18 | { value: EditorThemeKey.XCODE, label: "Xcode" }, 19 | { value: EditorThemeKey.DUOTONE, label: "Duotone" }, 20 | ]; 21 | 22 | export const themePreviewOptions = [ 23 | { value: PreviewThemeKey.DUOTONE, label: "Duotone" }, 24 | { value: PreviewThemeKey.COLDDARK, label: "Cold Dark" }, 25 | { value: PreviewThemeKey.VS, label: "Visual Studio" }, 26 | { value: PreviewThemeKey.ONE, label: "One" }, 27 | ]; 28 | 29 | export const navHeaders: { 30 | [key in Section]: { 31 | [key: string]: string; 32 | }; 33 | } = { 34 | [Section.LIST]: { 35 | [Folder.ALL]: "All notes", 36 | [Folder.PINNED]: "Pinned notes", 37 | [Folder.TRASH]: "Trashed notes", 38 | [Folder.CATEGORY]: "Category notes", 39 | }, 40 | [Section.NOTE]: { 41 | editing: "Editing note", 42 | "not-editing": "Preview note", 43 | }, 44 | [Section.MENU]: { 45 | header: "", 46 | }, 47 | }; 48 | 49 | export const emptyListMessage = { 50 | [Folder.ALL]: "- No notes -", 51 | [Folder.PINNED]: "- No pinned notes -", 52 | [Folder.TRASH]: "- Trash is empty -", 53 | [Folder.CATEGORY]: "- Empty category -", 54 | }; 55 | 56 | export const defaultNote = { 57 | id: "default-note", 58 | text: '# Welcome to Noteup!\n\nNoteup is a free, open-source GitHub-flavored markdown note-taking app for the web, mobile(PWA) and desktop. Your notes are saved in the local storage but are available for download.\n\nView the source on [Github](https://github.com/elementsinteractive/Noteup).\n\n## Features\n\n- **Plain text notes** - take notes in an IDE-like environment\n- **Markdown preview** - view rendered HTML\n- **Split screen editing** - preview the markdown while you edit it\n- **Linked notes** - use {{uuid}} syntax to link to notes within other notes\n- **Syntax highlighting** \n\nSeveral theme options are available through the settings menu.\n\n- **Keyboard shortcuts** - use the keyboard for all common tasks - creating notes and categories, toggling settings, and other options\n- **Multi-cursor editing** - supports multiple cursors and other [Codemirror](https://codemirror.net/) options\n- **Search notes** - easily search all notes or notes within a category\n- **No tracking or analytics**\n\nEasily insert your code blocks into your notes\n\n```js\nconst text = "This is a string";\n\nconsole.log(text);\n```\n\n```python\nfact = "JavaScript is amazing!"\n\nprint(fact)\n```\n\n## Roadmap\n\n- [x] Tandem scroll for side-by-side editing\n- [x] Quick command bar (WYSIWYG style)\n- [x] Add a landing page\n- [ ] Add a page with markdown help commands\n- [ ] Note sharing\n- [ ] Account sync\n- [ ] Cross-platform sync\n- [x] Extra download options (like `.pdf`)\n\n\n\n', 59 | created: "2022-10-10", 60 | lastUpdated: "2022-10-11", 61 | }; 62 | -------------------------------------------------------------------------------- /packages/shared/src/utils/editorThemes.ts: -------------------------------------------------------------------------------- 1 | import { duotoneDark, duotoneLight } from "@uiw/codemirror-theme-duotone"; 2 | import { githubDark, githubLight } from "@uiw/codemirror-theme-github"; 3 | import { xcodeDark, xcodeLight } from "@uiw/codemirror-theme-xcode"; 4 | import { 5 | coldarkCold, 6 | coldarkDark, 7 | duotoneDark as dtd, 8 | duotoneLight as dtl, 9 | oneDark, 10 | oneLight, 11 | vs, 12 | vscDarkPlus, 13 | } from "react-syntax-highlighter/dist/esm/styles/prism"; 14 | 15 | import { EditorThemeKey, PreviewThemeKey } from "./enums"; 16 | 17 | export const editorThemes = { 18 | light: { 19 | [EditorThemeKey.GITHUB]: githubLight, 20 | [EditorThemeKey.XCODE]: xcodeLight, 21 | [EditorThemeKey.DUOTONE]: duotoneLight, 22 | }, 23 | dark: { 24 | [EditorThemeKey.GITHUB]: githubDark, 25 | [EditorThemeKey.XCODE]: xcodeDark, 26 | [EditorThemeKey.DUOTONE]: duotoneDark, 27 | }, 28 | }; 29 | 30 | export const previewThemes = { 31 | light: { 32 | [PreviewThemeKey.DUOTONE]: dtl, 33 | [PreviewThemeKey.ONE]: oneLight, 34 | [PreviewThemeKey.VS]: vs, 35 | [PreviewThemeKey.COLDDARK]: coldarkCold, 36 | }, 37 | dark: { 38 | [PreviewThemeKey.DUOTONE]: dtd, 39 | [PreviewThemeKey.ONE]: oneDark, 40 | [PreviewThemeKey.VS]: vscDarkPlus, 41 | [PreviewThemeKey.COLDDARK]: coldarkDark, 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /packages/shared/src/utils/enums.ts: -------------------------------------------------------------------------------- 1 | export enum Folder { 2 | ALL = "ALL", 3 | CATEGORY = "CATEGORY", 4 | PINNED = "PINNED", 5 | TRASH = "TRASH", 6 | } 7 | 8 | export enum Section { 9 | MENU = "MENU", 10 | LIST = "LIST", 11 | NOTE = "NOTE", 12 | } 13 | 14 | export enum Shortcuts { 15 | NEW_NOTE = "ctrl+alt+n", 16 | NEW_CATEGORY = "ctrl+alt+c", 17 | DELETE_NOTE = "ctrl+alt+u", 18 | DOWNLOAD_NOTES = "ctrl+alt+o", 19 | PREVIEW = "alt+ctrl+p", 20 | TOGGLE_THEME = "alt+ctrl+k", 21 | SEARCH = "alt+ctrl+f", 22 | TOGGLE_FULL_SCREEN = "alt+ctrl+b", 23 | } 24 | 25 | export enum ContextMenuEnum { 26 | CATEGORY = "CATEGORY", 27 | NOTE = "NOTE", 28 | } 29 | 30 | export enum NotesSortKey { 31 | LAST_UPDATED = "lastUpdated", 32 | TITLE = "title", 33 | CREATED_DATE = "created_date", 34 | } 35 | 36 | export enum EditorThemeKey { 37 | GITHUB = "github", 38 | XCODE = "xcode", 39 | DUOTONE = "duotone", 40 | } 41 | 42 | export enum PreviewThemeKey { 43 | COLDDARK = "colddark", 44 | ONE = "one", 45 | DUOTONE = "duotone", 46 | VS = "vs", 47 | } 48 | 49 | export enum LabelText { 50 | ADD_CATEGORY = "Add category", 51 | COLLAPSE_CATEGORY = "Collapse Category List", 52 | NOTES = "Notes", 53 | CREATE_NEW_NOTE = "Create new note", 54 | DELETE_PERMANENTLY = "Delete permanently", 55 | DOWNLOAD = "Download", 56 | PINNED = "Pinned", 57 | MARK_AS_PINNED = "Mark as pinned", 58 | MOVE_TO_TRASH = "Move to trash", 59 | NEW_CATEGORY = "New category", 60 | NEW_NOTE = "New note", 61 | REMOVE_CATEGORY = "Remove category", 62 | REMOVE_FAVORITE = "Remove favorite", 63 | MOVE_CATEGORY = "Move category", 64 | RESTORE_FROM_TRASH = "Restore from trash", 65 | SETTINGS = "Settings", 66 | SYNC_NOTES = "Sync notes", 67 | TRASH = "Trash", 68 | RENAME = "Rename category", 69 | DOWNLOAD_ALL_NOTES = "Download all notes", 70 | BACKUP_ALL_NOTES = "Export backup", 71 | IMPORT_BACKUP = "Import backup", 72 | TOGGLE_PINNED = "Toggle pinned", 73 | COPY_REFERENCE_TO_NOTE = "Copy reference", 74 | } 75 | -------------------------------------------------------------------------------- /packages/shared/src/utils/exports.tsx: -------------------------------------------------------------------------------- 1 | // import { save } from "@tauri-apps/api/dialog"; 2 | // import { BaseDirectory, writeBinaryFile, writeTextFile } from "@tauri-apps/api/fs"; 3 | // import dayjs from "dayjs"; 4 | // import { saveAs } from "file-saver"; 5 | // import { jsPDF } from "jspdf"; 6 | // import { renderToStaticMarkup } from "react-dom/server"; 7 | // import { RecoilRoot } from "recoil"; 8 | 9 | // import { Category, Note } from "@/recoil/types"; 10 | 11 | // import { NotePreview } from "@/views/NotePreviewer"; 12 | 13 | // import { ThemeWrapper } from "@/components/ThemeWrapper"; 14 | 15 | // import { isTauri } from "./helpers"; 16 | 17 | // export const downloadPdf = async (note: Note) => { 18 | // const element = document.getElementById("pdf-preview"); 19 | // if (!element) return; 20 | // const doc = new jsPDF("p", "pt", [793.706, 841.89]); 21 | // element.innerHTML = renderToStaticMarkup( 22 | // 23 | // 24 | // 25 | // 26 | // , 27 | // ); 28 | 29 | // doc.setFontSize(9); 30 | // doc.html(element, { 31 | // autoPaging: "text", 32 | // callback: function (doc) { 33 | // if (isTauri) { 34 | // save({ 35 | // filters: [ 36 | // { 37 | // name: "files", 38 | // extensions: ["pdf"], 39 | // }, 40 | // ], 41 | // }).then((path) => 42 | // writeBinaryFile(path, doc.output("arraybuffer"), { 43 | // dir: BaseDirectory.App, 44 | // }), 45 | // ); 46 | // } else { 47 | // doc.save("note.pdf"); 48 | // } 49 | // // element.innerHTML = ""; 50 | // }, 51 | // }); 52 | // }; 53 | 54 | // export const downloadMarkdown = (note: Note): void => { 55 | // if (isTauri) { 56 | // save({ 57 | // filters: [ 58 | // { 59 | // name: "files", 60 | // extensions: ["md"], 61 | // }, 62 | // ], 63 | // }).then((path) => 64 | // writeTextFile(path, note.text, { 65 | // dir: BaseDirectory.App, 66 | // }), 67 | // ); 68 | // } else { 69 | // const blob = new Blob([note.text], { 70 | // type: "text/plain;charset=utf-8", 71 | // }); 72 | 73 | // saveAs(blob, "note.md"); 74 | // } 75 | // }; 76 | 77 | // export const backupNotes = (notes: Note[], categories: Category[]) => { 78 | // if (isTauri) { 79 | // save({ 80 | // filters: [ 81 | // { 82 | // name: "files", 83 | // extensions: ["json"], 84 | // }, 85 | // ], 86 | // }).then((path) => 87 | // writeTextFile(path, JSON.stringify({ notes, categories }), { 88 | // dir: BaseDirectory.App, 89 | // }), 90 | // ); 91 | // } else { 92 | // const json = JSON.stringify({ notes, categories }); 93 | // const blob = new Blob([json], { type: "application/json" }); 94 | 95 | // saveAs(blob, `noteup-backup-${dayjs().format("YYYY-MM-DD")}.json`); 96 | // } 97 | // }; 98 | 99 | export {}; 100 | -------------------------------------------------------------------------------- /packages/shared/src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Note } from "../recoil/types"; 2 | 3 | import { LabelText } from "./enums"; 4 | 5 | export const removeDuplicateNotes = (arr: Note[]): Note[] => { 6 | const uniqueIds: string[] = []; 7 | 8 | return arr.filter((element) => { 9 | const isDuplicate = uniqueIds.includes(element.id); 10 | 11 | if (!isDuplicate) { 12 | uniqueIds.push(element.id); 13 | 14 | return true; 15 | } 16 | 17 | return false; 18 | }); 19 | }; 20 | 21 | export const getNoteTitle = (text: string): string => { 22 | const noteText = text.trim().match(/[^#]{1,45}/); 23 | return noteText ? noteText[0].trim().split(/\r?\n/)[0] : LabelText.NEW_NOTE; 24 | }; 25 | 26 | export const getNoteBody = (text: string): string => { 27 | const noteText = text.trim().match(/[^#]{1,200}/); 28 | const trimmedText = 29 | noteText && 30 | noteText?.[0] 31 | .trim() 32 | .split(/\r?\n/)[2] 33 | ?.replace(/[^a-z0-9]/gi, " "); 34 | return trimmedText ? `${trimmedText} ...` : ""; 35 | }; 36 | 37 | export const copyToClipboard = (text: string) => { 38 | navigator.clipboard.writeText(text); 39 | }; 40 | -------------------------------------------------------------------------------- /packages/shared/src/utils/hooks/useDayjs.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import relativeTime from "dayjs/plugin/relativeTime"; 3 | 4 | dayjs.extend(relativeTime); 5 | 6 | export const useDayjs = () => { 7 | const formatTo = (date: string) => { 8 | const diff = dayjs().diff(date, "day", true); 9 | if (diff > 7) { 10 | return dayjs(date).format("DD-MM-YY"); 11 | } else { 12 | return dayjs().to(dayjs(date)); 13 | } 14 | }; 15 | 16 | return { formatTo }; 17 | }; 18 | -------------------------------------------------------------------------------- /packages/shared/src/utils/hooks/useDeviceOS.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | export const useDeviceOS = () => { 4 | const platform = useMemo(() => window.navigator.platform, []); 5 | const isMacOS = useMemo(() => platform.toLowerCase().startsWith("mac"), [platform]); 6 | 7 | return { 8 | platform, 9 | isMacOS, 10 | altKey: isMacOS ? "⌥" : "ALT", 11 | ctrlKey: isMacOS ? "⌃" : "CTRL", 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /packages/shared/src/utils/hooks/useKey.ts: -------------------------------------------------------------------------------- 1 | import mousetrap from "mousetrap"; 2 | import "mousetrap-global-bind"; 3 | import { useEffect, useRef } from "react"; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-empty-function 6 | const noop = () => {}; 7 | 8 | export const useKey = (key: string | undefined, action: () => void) => { 9 | const actionRef = useRef(noop); 10 | actionRef.current = action; 11 | 12 | useEffect(() => { 13 | if (key) { 14 | mousetrap.bind(key, (event: Event) => { 15 | event.preventDefault(); 16 | if (actionRef.current) { 17 | actionRef.current(); 18 | } 19 | }); 20 | } 21 | 22 | return () => { 23 | !!key && mousetrap.unbind(key); 24 | }; 25 | }, [key]); 26 | }; 27 | -------------------------------------------------------------------------------- /packages/shared/src/utils/hooks/useWindowDimensions.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | function getWindowDimensions() { 4 | const { innerWidth: width, innerHeight: height } = window; 5 | return { 6 | width, 7 | height, 8 | }; 9 | } 10 | 11 | export function useWindowDimensions() { 12 | const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions()); 13 | 14 | useEffect(() => { 15 | function handleResize() { 16 | setWindowDimensions(getWindowDimensions()); 17 | } 18 | 19 | window.addEventListener("resize", handleResize); 20 | return () => window.removeEventListener("resize", handleResize); 21 | }, []); 22 | 23 | return { ...windowDimensions, isSmallDevice: windowDimensions.width < 500 }; 24 | } 25 | -------------------------------------------------------------------------------- /packages/shared/src/utils/sorting.ts: -------------------------------------------------------------------------------- 1 | import { Note } from "../recoil/types"; 2 | 3 | import { NotesSortKey } from "./enums"; 4 | import { getNoteTitle } from "./helpers"; 5 | 6 | export type NotesSortStrategy = { 7 | sort: (a: Note, b: Note) => number; 8 | }; 9 | 10 | const withPinned = (sortFunction: NotesSortStrategy["sort"]) => (a: Note, b: Note) => { 11 | if (a.pinned && !b.pinned) return -1; 12 | if (!a.pinned && b.pinned) return 1; 13 | 14 | return sortFunction(a, b); 15 | }; 16 | 17 | const createdDate: NotesSortStrategy = { 18 | sort: (a: Note, b: Note): number => { 19 | const dateA = new Date(a.created); 20 | const dateB = new Date(b.created); 21 | 22 | return dateA < dateB ? 1 : -1; 23 | }, 24 | }; 25 | 26 | const lastUpdated: NotesSortStrategy = { 27 | sort: (a: Note, b: Note): number => { 28 | const dateA = new Date(a.lastUpdated); 29 | const dateB = new Date(b.lastUpdated); 30 | 31 | return dateA < dateB ? 1 : -1; 32 | }, 33 | }; 34 | 35 | const title: NotesSortStrategy = { 36 | sort: (a: Note, b: Note): number => { 37 | const titleA = getNoteTitle(a.text); 38 | const titleB = getNoteTitle(b.text); 39 | 40 | if (titleA === titleB) return 0; 41 | 42 | return titleA > titleB ? 1 : -1; 43 | }, 44 | }; 45 | 46 | export const sortStrategyMap: { [key in NotesSortKey]: NotesSortStrategy } = { 47 | [NotesSortKey.LAST_UPDATED]: lastUpdated, 48 | [NotesSortKey.TITLE]: title, 49 | [NotesSortKey.CREATED_DATE]: createdDate, 50 | }; 51 | 52 | export const getNotesSorter = (notesSortKey: NotesSortKey) => 53 | withPinned(sortStrategyMap[notesSortKey].sort); 54 | -------------------------------------------------------------------------------- /packages/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./build", 5 | "baseUrl": "./" 6 | }, 7 | "include": ["./src/**/*"], 8 | "exclude": ["./src/**/*.test.tsx"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/web/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 2, 3 | printWidth: 100, 4 | singleQuote: false, 5 | trailingComma: "all", 6 | bracketSpacing: true, 7 | importOrder: [ 8 | "^@/recoil/(.*)$", 9 | "^@/utils/(.*)$", 10 | "^@/views/(.*)$", 11 | "^@/components/(.*)$", 12 | "^@/assets/(.*)$", 13 | "^@/styles/(.*)$", 14 | "^[./]", 15 | ], 16 | importOrderSeparation: true, 17 | importOrderSortSpecifiers: true, 18 | plugins: [require("@trivago/prettier-plugin-sort-imports")], 19 | }; 20 | -------------------------------------------------------------------------------- /packages/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Noteup 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /packages/web/patches/react-split-pane+0.1.92.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/react-split-pane/index.d.ts b/node_modules/react-split-pane/index.d.ts 2 | index d116f54..6297ee0 100644 3 | --- a/node_modules/react-split-pane/index.d.ts 4 | +++ b/node_modules/react-split-pane/index.d.ts 5 | @@ -25,6 +25,7 @@ export type SplitPaneProps = { 6 | pane2Style?: React.CSSProperties; 7 | resizerClassName?: string; 8 | step?: number; 9 | + children?: React.ReactNode | React.ReactNode[]; 10 | }; 11 | 12 | export type SplitPaneState = { 13 | -------------------------------------------------------------------------------- /packages/web/public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /packages/web/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GarliqBread/Noteup/3553f589518bad98c884cdf17f90f69ed4b10c21/packages/web/public/apple-touch-icon.png -------------------------------------------------------------------------------- /packages/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GarliqBread/Noteup/3553f589518bad98c884cdf17f90f69ed4b10c21/packages/web/public/favicon.ico -------------------------------------------------------------------------------- /packages/web/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /packages/web/public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GarliqBread/Noteup/3553f589518bad98c884cdf17f90f69ed4b10c21/packages/web/public/pwa-192x192.png -------------------------------------------------------------------------------- /packages/web/public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GarliqBread/Noteup/3553f589518bad98c884cdf17f90f69ed4b10c21/packages/web/public/pwa-512x512.png -------------------------------------------------------------------------------- /packages/web/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Allow: / 4 | -------------------------------------------------------------------------------- /packages/web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, Route, Routes } from "react-router-dom"; 2 | 3 | import { AppContainer } from "@/views/AppContainer"; 4 | 5 | function App() { 6 | return ( 7 | 8 | 9 | } /> 10 | 11 | 12 | ); 13 | } 14 | 15 | export default App; 16 | -------------------------------------------------------------------------------- /packages/web/src/components/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import { StyledButton } from "@noteup/shared/components/Button/style"; 2 | import { ChangeEvent, useRef } from "react"; 3 | 4 | type ButtonProps = { 5 | testId?: string; 6 | className?: string; 7 | title?: string; 8 | type?: "button" | "submit" | "reset"; 9 | variant?: string; 10 | disabled?: boolean; 11 | onClick?: () => void; 12 | onUpload?: (file: File) => void; 13 | children: string | React.ReactNode; 14 | }; 15 | 16 | export const UploadButton = ({ 17 | className, 18 | title, 19 | variant, 20 | disabled, 21 | onUpload, 22 | children, 23 | }: ButtonProps) => { 24 | const inputRef = useRef(null); 25 | 26 | const handleClick = () => { 27 | if (inputRef.current) { 28 | inputRef.current.click(); 29 | } 30 | }; 31 | 32 | const handleFileInput = (e: ChangeEvent) => { 33 | if (e.target.files && onUpload) { 34 | onUpload(e.target.files[0]); 35 | } 36 | }; 37 | 38 | return ( 39 |
40 | 51 | 58 | {children} 59 | 60 |
61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /packages/web/src/components/KeyboardShortcuts.ts: -------------------------------------------------------------------------------- 1 | import { selectedCategoryIdSelector } from "@noteup/shared/recoil/categories.recoil"; 2 | import { editingSelector } from "@noteup/shared/recoil/editor.recoil"; 3 | import { activeFolderSelector } from "@noteup/shared/recoil/folder.recoil"; 4 | import { notesSelector, selectedNoteSelector } from "@noteup/shared/recoil/notes.recoil"; 5 | import { fullScreenSelector } from "@noteup/shared/recoil/screen.recoil"; 6 | import { themeSelector } from "@noteup/shared/recoil/settings.recoil"; 7 | import { Folder, Shortcuts } from "@noteup/shared/utils/enums"; 8 | import { useKey } from "@noteup/shared/utils/hooks/useKey"; 9 | import dayjs from "dayjs"; 10 | import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil"; 11 | import { v4 as uuid } from "uuid"; 12 | 13 | import { downloadMarkdown } from "@/utils/exports"; 14 | 15 | export const KeyboardShortcuts = () => { 16 | const setNotes = useSetRecoilState(notesSelector); 17 | const selectedCategoryId = useRecoilValue(selectedCategoryIdSelector); 18 | const [activeFolder, setActiveFolder] = useRecoilState(activeFolderSelector); 19 | const [selectedNote, setSelectedNote] = useRecoilState(selectedNoteSelector); 20 | const [editing, setEditing] = useRecoilState(editingSelector); 21 | const [theme, setTheme] = useRecoilState(themeSelector); 22 | const [fullScreen, setFullScreen] = useRecoilState(fullScreenSelector); 23 | 24 | const createNewNote = () => { 25 | setEditing(false); 26 | setNotes([ 27 | { 28 | id: uuid(), 29 | text: "", 30 | created: dayjs().format(), 31 | lastUpdated: dayjs().format(), 32 | categoryId: selectedCategoryId || undefined, 33 | pinned: activeFolder === Folder.PINNED, 34 | }, 35 | ]); 36 | setTimeout(() => setEditing(true), 100); 37 | if (activeFolder === Folder.TRASH) { 38 | setActiveFolder(Folder.ALL); 39 | } 40 | }; 41 | 42 | const deleteCurrentNote = () => { 43 | if (selectedNote) { 44 | setSelectedNote({ 45 | ...selectedNote, 46 | trash: true, 47 | }); 48 | } 49 | }; 50 | 51 | const handleDownloadNotes = () => !!selectedNote && downloadMarkdown(selectedNote); 52 | 53 | const toggleEditing = () => setEditing(!editing); 54 | 55 | const toggleTheme = () => setTheme(theme === "dark" ? "light" : "dark"); 56 | 57 | const toggleFullScreen = () => setFullScreen(!fullScreen); 58 | 59 | useKey(Shortcuts.NEW_NOTE, createNewNote); 60 | useKey(Shortcuts.DELETE_NOTE, deleteCurrentNote); 61 | useKey(Shortcuts.DOWNLOAD_NOTES, handleDownloadNotes); 62 | useKey(Shortcuts.PREVIEW, toggleEditing); 63 | useKey(Shortcuts.TOGGLE_THEME, toggleTheme); 64 | useKey(Shortcuts.TOGGLE_FULL_SCREEN, toggleFullScreen); 65 | 66 | return null; 67 | }; 68 | -------------------------------------------------------------------------------- /packages/web/src/components/MobileNav/index.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from "@noteup/shared/components/Button"; 2 | import { ArrowLeft, Gear } from "@noteup/shared/components/Icons"; 3 | import { editingSelector } from "@noteup/shared/recoil/editor.recoil"; 4 | import { activeFolderSelector } from "@noteup/shared/recoil/folder.recoil"; 5 | import { sectionsSelector } from "@noteup/shared/recoil/sections.recoil"; 6 | import { navHeaders } from "@noteup/shared/utils/constants"; 7 | import { Section } from "@noteup/shared/utils/enums"; 8 | import { useWindowDimensions } from "@noteup/shared/utils/hooks/useWindowDimensions"; 9 | import { useMemo } from "react"; 10 | import { useRecoilState, useRecoilValue } from "recoil"; 11 | 12 | import { Nav } from "./style"; 13 | 14 | type Props = { 15 | openSettings: () => void; 16 | }; 17 | 18 | export const MobileNav = ({ openSettings }: Props) => { 19 | const { isSmallDevice } = useWindowDimensions(); 20 | const activeFolder = useRecoilValue(activeFolderSelector); 21 | const editing = useRecoilValue(editingSelector); 22 | const [section, setSection] = useRecoilState(sectionsSelector); 23 | 24 | const isMenu = section === Section.MENU; 25 | const header = useMemo(() => { 26 | if (section === Section.MENU) { 27 | return navHeaders[section].header; 28 | } 29 | if (section === Section.NOTE) { 30 | return navHeaders[section][editing ? "editing" : "not-editing"]; 31 | } 32 | if (section === Section.LIST) { 33 | return navHeaders[section][activeFolder]; 34 | } 35 | }, [section, activeFolder, editing]); 36 | 37 | const handleNavigation = () => { 38 | if (isMenu) return; 39 | setSection(section === Section.NOTE ? Section.LIST : Section.MENU); 40 | }; 41 | 42 | return ( 43 | <> 44 | {isSmallDevice && ( 45 | 54 | )} 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /packages/web/src/components/MobileNav/style.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const Nav = styled.nav<{ hidden: boolean }>` 4 | position: sticky; 5 | top: 0; 6 | display: ${(props) => props.hidden && "none !important"}; 7 | background-color: ${(props) => props.theme.color.secondLayer}; 8 | justify-content: space-between; 9 | align-items: center; 10 | padding: 5px 8px; 11 | color: ${(props) => props.theme.color.text}; 12 | z-index: 90; 13 | 14 | svg { 15 | color: ${(props) => props.theme.color.text}; 16 | } 17 | 18 | @media (max-width: 500px) { 19 | display: flex; 20 | } 21 | `; 22 | 23 | export { Nav }; 24 | -------------------------------------------------------------------------------- /packages/web/src/components/ThemeWrapper.tsx: -------------------------------------------------------------------------------- 1 | import GlobalStyles from "@/reset.css"; 2 | import { settingsState } from "@noteup/shared/recoil/settings.recoil"; 3 | import { themes } from "@noteup/shared/styles/theme"; 4 | import { darkTheme, lightTheme } from "@noteup/shared/styles/theme/colors"; 5 | import { useRecoilValue } from "recoil"; 6 | import { ThemeProvider } from "styled-components"; 7 | 8 | export const wrapWithTheme = (children: JSX.Element): JSX.Element => ( 9 | {children} 10 | ); 11 | interface Props { 12 | children: JSX.Element; 13 | } 14 | 15 | export const ThemeWrapper = ({ children }: Props) => { 16 | const settings = useRecoilValue(settingsState); 17 | 18 | const themeObject = { 19 | ...themes, 20 | color: settings.theme === "light" ? lightTheme : darkTheme, 21 | mode: settings.theme, 22 | }; 23 | 24 | return ( 25 | 26 | 27 | {children} 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /packages/web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { RecoilRoot } from "recoil"; 4 | 5 | import { ThemeWrapper } from "@/components/ThemeWrapper"; 6 | 7 | import App from "./App"; 8 | 9 | const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); 10 | root.render( 11 | 12 | 13 | 14 | 15 | 16 | 17 | , 18 | ); 19 | -------------------------------------------------------------------------------- /packages/web/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/web/src/reset.css.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from "styled-components"; 2 | 3 | export default createGlobalStyle` 4 | * { 5 | border: 0; 6 | box-sizing: inherit; 7 | -webkit-font-smoothing: auto; 8 | font-weight: inherit; 9 | margin: 0; 10 | outline: 0; 11 | padding: 0; 12 | text-decoration: none; 13 | text-rendering: optimizeLegibility; 14 | -webkit-appearance: none; 15 | -moz-appearance: none; 16 | } 17 | html { 18 | display: flex; 19 | min-height: 100%; 20 | width: 100%; 21 | box-sizing: border-box; 22 | font-size: 16px; 23 | line-height: 1.5; 24 | color: #16171a; 25 | padding: 0; 26 | margin: 0; 27 | -webkit-font-smoothing: auto; 28 | -webkit-tap-highlight-color: rgba(0,0,0,0); 29 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, 30 | sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 31 | } 32 | body { 33 | box-sizing: border-box; 34 | width: 100%; 35 | height: 100%; 36 | background-color: ${(props) => props.theme.color.firstLayer}; 37 | overscroll-behavior-y: none; 38 | -webkit-overflow-scrolling: touch; 39 | } 40 | #root { 41 | height: 100vh; 42 | width: 100vw; 43 | } 44 | a { 45 | color: currentColor; 46 | text-decoration: none; 47 | } 48 | a:hover { 49 | cursor: pointer; 50 | } 51 | code, p, span { 52 | color: ${(props) => props.theme.color.text}; 53 | } 54 | code { 55 | background-color: ${(props) => props.theme.color.codeBlock}; 56 | } 57 | .code-mirror { 58 | width: 100%; 59 | height: ${({ theme }) => `calc(100% - ${theme.spaces.desktopEditor})`}; 60 | 61 | &-toolbar { 62 | height: ${({ theme }) => `calc(100% - ${theme.spaces.desktopEditorWithToolbar})`}; 63 | } 64 | 65 | @media (max-width: 500px) { 66 | height: ${({ theme }) => `calc(100% - ${theme.spaces.mobileEditor})`}; 67 | } 68 | } 69 | ::-webkit-scrollbar { 70 | width: 6px; 71 | height: 6px; 72 | background-color: ${(props) => props.theme.color.scrollBar}; 73 | } 74 | ::-webkit-scrollbar-thumb { 75 | background: #1f6ce0; 76 | border-radius: 2px; 77 | } 78 | #pdf-preview { 79 | position: absolute; 80 | width: 793px; 81 | z-index: -1; 82 | 83 | .previewer { 84 | min-height: 841px; 85 | } 86 | } 87 | .Resizer { 88 | opacity: 0.2; 89 | z-index: 97; 90 | box-sizing: border-box; 91 | background-clip: padding-box; 92 | } 93 | .Resizer:hover { 94 | transition: all 0.5s ease; 95 | } 96 | .Resizer.vertical { 97 | margin: 0 -5px; 98 | border-left: 5px solid rgba(255, 255, 255, 0); 99 | border-right: 5px solid rgba(255, 255, 255, 0); 100 | cursor: col-resize; 101 | } 102 | .Resizer.vertical:hover { 103 | border-left: 5px solid rgba(0, 0, 0, 0.5); 104 | border-right: 5px solid rgba(0, 0, 0, 0.5); 105 | } 106 | .Resizer.disabled { 107 | cursor: not-allowed; 108 | } 109 | .Resizer.disabled:hover { 110 | border-color: transparent; 111 | } 112 | .Pane { 113 | overflow: hidden; 114 | } 115 | `; 116 | -------------------------------------------------------------------------------- /packages/web/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom"; 6 | -------------------------------------------------------------------------------- /packages/web/src/tests/unit/components/Button.test.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@noteup/shared/components/Button"; 2 | import { render } from "@testing-library/react"; 3 | 4 | import { wrapWithTheme } from "@/components/ThemeWrapper"; 5 | 6 | describe(")); 13 | expect(component).toBeTruthy(); 14 | }); 15 | }); 16 | 17 | test("Button is disabled", async () => { 18 | const buttonProps = { 19 | testId: "test-button", 20 | disabled: true, 21 | onClick: jest.fn(), 22 | }; 23 | 24 | const component = render(wrapWithTheme()); 25 | const button = component.getByTestId("test-button"); 26 | expect(button).toBeDisabled(); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/web/src/tests/unit/components/Dropdown.test.tsx: -------------------------------------------------------------------------------- 1 | import { Dropdown } from "@noteup/shared/components/Dropdown"; 2 | import { fireEvent, render } from "@testing-library/react"; 3 | 4 | import { wrapWithTheme } from "@/components/ThemeWrapper"; 5 | 6 | describe("", () => { 7 | it("renders the Dropdown component", () => { 8 | const dropdownProps = { 9 | menu: [ 10 | { 11 | id: "", 12 | children: test, 13 | }, 14 | ], 15 | }; 16 | const component = render(wrapWithTheme()); 17 | expect(component).toBeTruthy(); 18 | }); 19 | }); 20 | 21 | test("Dropdown is disabled when clicked", async () => { 22 | const dropdownProps = { 23 | menu: [ 24 | { 25 | id: "", 26 | children: test, 27 | }, 28 | ], 29 | }; 30 | const component = render(wrapWithTheme()); 31 | const trigger = component.getByTestId("trigger"); 32 | fireEvent.click(trigger); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/web/src/tests/unit/components/EditorBar.test.tsx: -------------------------------------------------------------------------------- 1 | import { EditorBar } from "@noteup/shared/components/NoteEditor/EditorBar"; 2 | import { render } from "@testing-library/react"; 3 | import { RecoilRoot } from "recoil"; 4 | 5 | import { wrapWithTheme } from "@/components/ThemeWrapper"; 6 | 7 | describe("", () => { 8 | it("renders the EditorBar component", () => { 9 | const component = render( 10 | wrapWithTheme( 11 | 12 | null} downloadPdf={() => null} /> 13 | , 14 | ), 15 | ); 16 | expect(component).toBeTruthy(); 17 | const themeButton = component.getAllByTitle("Change theme")[0]; 18 | expect(themeButton).toBeTruthy(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /packages/web/src/tests/unit/components/Input.test.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "@noteup/shared/components/Input"; 2 | import { render } from "@testing-library/react"; 3 | 4 | import { wrapWithTheme } from "@/components/ThemeWrapper"; 5 | 6 | describe("", () => { 7 | it("renders the Input component", () => { 8 | const inputProps = { 9 | testId: "test-input", 10 | onChange: jest.fn(), 11 | value: "", 12 | }; 13 | const component = render(wrapWithTheme()); 14 | expect(component).toBeTruthy(); 15 | }); 16 | }); 17 | 18 | test("Input has correct value", async () => { 19 | const inputProps = { 20 | testId: "test-input", 21 | autoFocus: true, 22 | onChange: jest.fn(), 23 | value: "test", 24 | }; 25 | 26 | const component = render(wrapWithTheme()); 27 | const input = component.getByTestId("test-input"); 28 | expect(input).toHaveValue("test"); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/web/src/tests/unit/components/Select.test.tsx: -------------------------------------------------------------------------------- 1 | import { Select } from "@noteup/shared/components/Select"; 2 | import { fireEvent, render } from "@testing-library/react"; 3 | 4 | import { wrapWithTheme } from "@/components/ThemeWrapper"; 5 | 6 | describe(")); 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | 23 | test("Select opens when clicked", async () => { 24 | const selectProps = { 25 | options: [ 26 | { 27 | value: "test-value", 28 | label: "test-label", 29 | }, 30 | ], 31 | value: "", 32 | onChange: jest.fn(), 33 | }; 34 | 35 | const component = render(wrapWithTheme(