├── .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 | [](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 | 
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 |
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 | You need to enable JavaScript to run this app.
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 | backupNotes(notes, categories)}
43 | >
44 | {LabelText.BACKUP_ALL_NOTES}
45 |
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 | {category}
37 |
38 | {formatTo(note.lastUpdated)}
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 |
61 | setNoteState({
62 | ...noteState,
63 | notes: notes.filter((note) => !note.trash),
64 | })
65 | }
66 | >
67 |
68 |
69 | ) : (
70 |
71 |
72 |
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 && {emptyText} }
25 | {options.map((selectOption) => (
26 |
31 | {selectOption.label}
32 |
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 |
21 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/packages/shared/src/components/SettingsModal/Shortcut.tsx:
--------------------------------------------------------------------------------
1 | import { useDeviceOS } from "../../utils/hooks/useDeviceOS";
2 |
3 | import { KBD } from "../../styles/typography";
4 |
5 | import { ShortcutContainer } from "./style";
6 |
7 | type Props = {
8 | action: string;
9 | letter: string;
10 | };
11 |
12 | export const Shortcut = ({ action, letter }: Props) => {
13 | const { altKey, ctrlKey } = useDeviceOS();
14 |
15 | return (
16 |
17 | {action}
18 |
19 | {ctrlKey} {altKey} {letter}
20 |
21 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/packages/shared/src/components/SettingsModal/style.ts:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const OptionContainer = styled.div`
4 | display: flex;
5 | align-items: center;
6 | justify-content: space-between;
7 | padding: 15px 0;
8 | border-bottom: 1px solid ${(props) => props.theme.color.border};
9 | color: ${(props) => props.theme.color.text};
10 |
11 | &:last-of-type {
12 | border-bottom: none;
13 | }
14 |
15 | h3 {
16 | margin-top: 0;
17 | margin-bottom: 0.5rem;
18 | font-weight: 500;
19 | font-size: 1rem;
20 | }
21 |
22 | .description {
23 | font-size: 0.9rem;
24 | color: ${(props) => props.theme.color.lightText};
25 | margin: 0;
26 | line-height: 1.3;
27 | }
28 | `;
29 |
30 | const SelectOptionContainer = styled.div`
31 | display: flex;
32 | align-items: center;
33 | justify-content: space-between;
34 | padding: 1rem 0;
35 | border-bottom: 1px solid ${(props) => props.theme.color.border};
36 | &:last-of-type {
37 | border-bottom: none;
38 | }
39 | h3 {
40 | margin-top: 0;
41 | margin-bottom: 0.5rem;
42 | font-weight: 500;
43 | font-size: 1rem;
44 | }
45 | .description {
46 | font-size: 0.9rem;
47 | color: ${(props) => props.theme.color.lightText};
48 | margin: 0;
49 | line-height: 1.3;
50 | }
51 | `;
52 |
53 | const ShortcutContainer = styled.div`
54 | display: flex;
55 | align-items: center;
56 | justify-content: space-between;
57 | padding: 5px 0;
58 | margin-top: 5px;
59 | font-size: 0.95rem;
60 | color: ${(props) => props.theme.color.lightText};
61 |
62 | .keys {
63 | width: 180px;
64 | }
65 | `;
66 |
67 | export { OptionContainer, SelectOptionContainer, ShortcutContainer };
68 |
--------------------------------------------------------------------------------
/packages/shared/src/components/Sidebar/AddCategoryForm.tsx:
--------------------------------------------------------------------------------
1 | import { FormEvent, useState } from "react";
2 | import { useRecoilState } from "recoil";
3 | import { v4 as uuid } from "uuid";
4 |
5 | import { categoriesSelector } from "../../recoil/categories.recoil";
6 |
7 | import { Input } from "../Input";
8 |
9 | import { CategoryForm } from "./style";
10 |
11 | type Props = {
12 | closeForm: () => void;
13 | };
14 |
15 | export const AddCategoryForm = ({ closeForm }: Props) => {
16 | const [tempName, setTempName] = useState("");
17 | const [categories, setCategories] = useRecoilState(categoriesSelector);
18 |
19 | const resetForm = () => {
20 | setTempName("");
21 | closeForm();
22 | };
23 |
24 | const createCategory = (e: FormEvent) => {
25 | e.preventDefault();
26 | setCategories([
27 | ...categories,
28 | {
29 | name: tempName,
30 | id: uuid(),
31 | },
32 | ]);
33 | resetForm();
34 | };
35 |
36 | return (
37 |
38 |
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 |
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 | CATEGORIES
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 |
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 |
12 |
13 |
14 |
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 | {label}
21 |
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 ;
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 | You need to enable JavaScript to run this app.
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 |
46 |
47 |
48 |
49 | {header}
50 |
51 |
52 |
53 |
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(" ", () => {
7 | it("renders the Button component", () => {
8 | const buttonProps = {
9 | testId: "test-button",
10 | onClick: jest.fn(),
11 | };
12 | const component = render(wrapWithTheme(test ));
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(test ));
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(" ", () => {
7 | it("renders the Select component", () => {
8 | const selectProps = {
9 | options: [
10 | {
11 | value: "test",
12 | label: "test",
13 | },
14 | ],
15 | value: "",
16 | onChange: jest.fn(),
17 | };
18 | const component = render(wrapWithTheme( ));
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( ));
36 | const dropdown = component.getByTestId("test-select");
37 | fireEvent.click(dropdown);
38 |
39 | const visibleItem = component.getAllByTestId("test-value");
40 | expect(visibleItem).toBeTruthy();
41 | });
42 |
--------------------------------------------------------------------------------
/packages/web/src/tests/unit/components/Switch.test.tsx:
--------------------------------------------------------------------------------
1 | import { Switch } from "@noteup/shared/components/Switch";
2 | import { render } from "@testing-library/react";
3 |
4 | import { wrapWithTheme } from "@/components/ThemeWrapper";
5 |
6 | describe(" ", () => {
7 | it("renders the Switch component", () => {
8 | const switchProps = {
9 | testId: "test-switch",
10 | toggle: jest.fn(),
11 | checked: false,
12 | };
13 |
14 | const component = render(wrapWithTheme( ));
15 | expect(component).toBeTruthy();
16 |
17 | const checkbox = component.getByTestId("test-switch") as HTMLInputElement;
18 | expect(checkbox.checked).toEqual(false);
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/packages/web/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/web/src/utils/exports.tsx:
--------------------------------------------------------------------------------
1 | import { Category, Note } from "@noteup/shared/recoil/types";
2 | import dayjs from "dayjs";
3 | import { saveAs } from "file-saver";
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: function (doc) {
28 | doc.save("note.pdf");
29 | },
30 | });
31 | };
32 |
33 | export const downloadMarkdown = (note: Note): void => {
34 | const blob = new Blob([note.text], {
35 | type: "text/plain;charset=utf-8",
36 | });
37 |
38 | saveAs(blob, "note.md");
39 | };
40 |
41 | export const backupNotes = (notes: Note[], categories: Category[]) => {
42 | const json = JSON.stringify({ notes, categories });
43 | const blob = new Blob([json], { type: "application/json" });
44 |
45 | saveAs(blob, `noteup-backup-${dayjs().format("YYYY-MM-DD")}.json`);
46 | };
47 |
--------------------------------------------------------------------------------
/packages/web/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 | import { MobileNav } from "@/components/MobileNav";
16 |
17 | export const AppContainer = () => {
18 | const [showSettings, setShowSettings] = useState(false);
19 | const isFullScreen = useRecoilValue(fullScreenSelector);
20 |
21 | return (
22 |
23 | {isFullScreen ? (
24 |
25 | ) : (
26 | <>
27 | setShowSettings(true)} />
28 |
29 | setShowSettings(true)} />
30 |
31 |
32 |
33 |
34 |
35 |
36 | {showSettings && setShowSettings(false)} />}
37 | >
38 | )}
39 |
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/packages/web/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 { sectionsSelector } from "@noteup/shared/recoil/sections.recoil";
6 | import { FlexColumn } from "@noteup/shared/styles/layout";
7 | import { Section } from "@noteup/shared/utils/enums";
8 | import { useWindowDimensions } from "@noteup/shared/utils/hooks/useWindowDimensions";
9 | import { ReactCodeMirrorRef } from "@uiw/react-codemirror";
10 | import { useMemo, useRef } from "react";
11 | import { useRecoilState, useRecoilValue } from "recoil";
12 |
13 | import { downloadMarkdown, downloadPdf } from "@/utils/exports";
14 |
15 | import { NoteEditor } from "@/views/NoteEditor";
16 | import { NotePreview } from "@/views/NotePreviewer";
17 |
18 | import { SplitScreenEditor } from "./SplitScreenEditor";
19 |
20 | export const NoteContainer = () => {
21 | const { isSmallDevice } = useWindowDimensions();
22 | const editorRef = useRef(null);
23 | const section = useRecoilValue(sectionsSelector);
24 | const editing = useRecoilValue(editingSelector);
25 | const split = useRecoilValue(splitSelector);
26 | const [note, setNote] = useRecoilState(selectedNoteSelector);
27 |
28 | const editorView = useMemo(() => section === Section.NOTE, [section]);
29 |
30 | return (
31 |
32 |
33 |
34 | {((isSmallDevice && editorView) || !isSmallDevice) && (
35 | <>
36 | {note ? (
37 | <>
38 | {editing && split && !isSmallDevice ? (
39 |
40 | ) : (
41 | <>
42 | {!editing && }
43 | {editing && }
44 | >
45 | )}
46 | >
47 | ) : (
48 |
49 | )}
50 | >
51 | )}
52 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/packages/web/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/web/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/web/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 | backupNotes(notes, categories)}
43 | >
44 | {LabelText.BACKUP_ALL_NOTES}
45 |
46 | Import Noteup JSON file.
47 |
53 | {LabelText.IMPORT_BACKUP}
54 |
55 | >
56 | );
57 | };
58 |
--------------------------------------------------------------------------------
/packages/web/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/web/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/web/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/web/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from "@vitejs/plugin-react";
2 | import { defineConfig } from "vite";
3 | import { VitePWA } from "vite-plugin-pwa";
4 | import tsconfigPaths from "vite-tsconfig-paths";
5 |
6 | export default defineConfig({
7 | plugins: [
8 | react(),
9 | tsconfigPaths(),
10 | VitePWA({
11 | base: "/",
12 | includeAssets: ["favicon.ico", "favicon.svg", "robots.txt", "apple-touch-icon.png"],
13 | injectRegister: "auto",
14 | registerType: "autoUpdate",
15 | workbox: {
16 | globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
17 | },
18 | manifest: {
19 | name: "Noteup",
20 | short_name: "Noteup",
21 | description: "Markdown note-taking made simple",
22 | display: "standalone",
23 | theme_color: "#ffffff",
24 | background_color: "#ffffff",
25 | start_url: "/",
26 | icons: [
27 | {
28 | src: "/favicon.ico",
29 | sizes: "64x64 32x32 24x24 16x16",
30 | type: "image/x-icon",
31 | },
32 | {
33 | src: "/pwa-192x192.png",
34 | type: "image/png",
35 | sizes: "192x192",
36 | },
37 | {
38 | src: "/pwa-512x512.png.png",
39 | type: "image/png",
40 | sizes: "512x512",
41 | },
42 | {
43 | src: "/pwa-512x512.png",
44 | type: "image/png",
45 | sizes: "512x512",
46 | purpose: "maskable",
47 | },
48 | ],
49 | },
50 | }),
51 | ],
52 | server: { port: 3000 },
53 | build: {
54 | chunkSizeWarningLimit: 1500,
55 | outDir: "build",
56 | rollupOptions: {
57 | output: {
58 | manualChunks(id) {
59 | if (id.includes("node_modules")) {
60 | return id.toString().split("node_modules/")[1].split("/")[0].toString();
61 | }
62 | },
63 | },
64 | },
65 | },
66 | });
67 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | # include packages in subfolders (e.g. apps/ and packages/)
3 | - "packages/**"
4 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "downlevelIteration": true,
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "esModuleInterop": true,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "noFallthroughCasesInSwitch": true,
13 | "module": "esnext",
14 | "moduleResolution": "node",
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "noEmit": true,
18 | "sourceMap": true,
19 | "jsx": "react-jsx",
20 | "baseUrl": "./"
21 | },
22 | "include": ["**/*.ts", "**/*.tsx", "**/*.js"],
23 | "exclude": ["**/*.spec.ts", "**/*.spec.tsx"]
24 | }
25 |
--------------------------------------------------------------------------------