Repository: taniarascia/takenote Branch: master Commit: e0eddbb9a21a Files: 164 Total size: 404.2 KB Directory structure: gitextract_n64ay1gg/ ├── .all-contributorsrc ├── .dockerignore ├── .editorconfig ├── .eslintrc.js ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .prettierrc ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── config/ │ ├── cypress.config.json │ ├── jest.config.js │ ├── nodemon.config.json │ ├── webpack.common.js │ ├── webpack.dev.js │ └── webpack.prod.js ├── deploy.sh ├── kubernetes.yml ├── package.json ├── public/ │ ├── _redirects │ ├── manifest.json │ ├── robots.txt │ └── template.html ├── seed.js ├── src/ │ ├── client/ │ │ ├── api/ │ │ │ └── index.ts │ │ ├── components/ │ │ │ ├── AppSidebar/ │ │ │ │ ├── ActionButton.tsx │ │ │ │ ├── AddCategoryButton.tsx │ │ │ │ ├── AddCategoryForm.tsx │ │ │ │ ├── CollapseCategoryButton.tsx │ │ │ │ ├── FolderOption.tsx │ │ │ │ └── ScratchpadOption.tsx │ │ │ ├── Editor/ │ │ │ │ ├── EmptyEditor.tsx │ │ │ │ ├── NoteLink.tsx │ │ │ │ └── PreviewEditor.tsx │ │ │ ├── LandingPage.tsx │ │ │ ├── LastSyncedNotification.tsx │ │ │ ├── NoteList/ │ │ │ │ ├── ContextMenuOption.tsx │ │ │ │ ├── NoteListButton.tsx │ │ │ │ ├── SearchBar.tsx │ │ │ │ └── SelectCategory.tsx │ │ │ ├── Select.tsx │ │ │ ├── SettingsModal/ │ │ │ │ ├── IconButton.tsx │ │ │ │ ├── IconButtonUploader.tsx │ │ │ │ ├── Option.tsx │ │ │ │ ├── SelectOptions.tsx │ │ │ │ └── Shortcut.tsx │ │ │ ├── Switch.tsx │ │ │ └── Tabs/ │ │ │ ├── Tab.tsx │ │ │ ├── TabPanel.tsx │ │ │ └── Tabs.tsx │ │ ├── containers/ │ │ │ ├── App.tsx │ │ │ ├── AppSidebar.tsx │ │ │ ├── CategoryList.tsx │ │ │ ├── CategoryOption.tsx │ │ │ ├── ContextMenu.tsx │ │ │ ├── ContextMenuOptions.tsx │ │ │ ├── KeyboardShortcuts.tsx │ │ │ ├── NoteEditor.tsx │ │ │ ├── NoteList.tsx │ │ │ ├── NoteMenuBar.tsx │ │ │ ├── SettingsModal.tsx │ │ │ └── TakeNoteApp.tsx │ │ ├── contexts/ │ │ │ └── TempStateContext.tsx │ │ ├── global.d.ts │ │ ├── index.tsx │ │ ├── router/ │ │ │ ├── PrivateRoute.tsx │ │ │ └── PublicRoute.tsx │ │ ├── sagas/ │ │ │ └── index.ts │ │ ├── selectors/ │ │ │ └── index.ts │ │ ├── serviceWorker.ts │ │ ├── setupTests.ts │ │ ├── slices/ │ │ │ ├── auth.ts │ │ │ ├── category.ts │ │ │ ├── index.ts │ │ │ ├── note.ts │ │ │ ├── settings.ts │ │ │ └── sync.ts │ │ ├── styles/ │ │ │ ├── _app-sidebar.scss │ │ │ ├── _buttons.scss │ │ │ ├── _dark.scss │ │ │ ├── _editor.scss │ │ │ ├── _forms.scss │ │ │ ├── _helpers.scss │ │ │ ├── _landing-page.scss │ │ │ ├── _layout.scss │ │ │ ├── _light-theme.scss │ │ │ ├── _mixins.scss │ │ │ ├── _modal.scss │ │ │ ├── _new-moon.scss │ │ │ ├── _note-menu-bar.scss │ │ │ ├── _note-sidebar.scss │ │ │ ├── _previewer.scss │ │ │ ├── _scaffolding.scss │ │ │ ├── _tabs.scss │ │ │ ├── _variables.scss │ │ │ └── index.scss │ │ ├── types/ │ │ │ └── index.ts │ │ └── utils/ │ │ ├── constants.ts │ │ ├── enums.ts │ │ ├── helpers.ts │ │ ├── history.ts │ │ ├── hooks.ts │ │ ├── notesSortStrategies.ts │ │ └── reactMarkdownPlugins.ts │ ├── resources/ │ │ ├── LabelText.ts │ │ └── TestID.ts │ └── server/ │ ├── handlers/ │ │ ├── auth.ts │ │ └── sync.ts │ ├── index.ts │ ├── initializeServer.ts │ ├── middleware/ │ │ ├── checkAuth.ts │ │ └── getUser.ts │ ├── router/ │ │ ├── auth.ts │ │ ├── index.ts │ │ └── sync.ts │ └── utils/ │ ├── constants.ts │ ├── data/ │ │ ├── scratchpadNote.ts │ │ └── welcomeNote.ts │ ├── enums.ts │ └── helpers.ts ├── tests/ │ ├── __mocks__/ │ │ └── styleMock.ts │ ├── e2e/ │ │ ├── integration/ │ │ │ ├── category.test.ts │ │ │ ├── note.test.ts │ │ │ └── settings.test.ts │ │ ├── plugins/ │ │ │ ├── cy-ts-preprocessor.js │ │ │ └── index.js │ │ ├── support/ │ │ │ ├── commands.js │ │ │ └── index.js │ │ ├── tsconfig.json │ │ └── utils/ │ │ ├── testCategoryHelperUtils.ts │ │ ├── testHelperEnums.ts │ │ ├── testHelperUtils.ts │ │ ├── testNotesHelperUtils.ts │ │ └── testSettingsUtils.ts │ └── unit/ │ ├── client/ │ │ ├── components/ │ │ │ ├── AppSidebar/ │ │ │ │ ├── ActionButton.test.tsx │ │ │ │ ├── AddCategoryButton.test.tsx │ │ │ │ ├── AddCategoryForm.test.tsx │ │ │ │ ├── CollapseCategoryButton.test.tsx │ │ │ │ ├── FolderOption.test.tsx │ │ │ │ └── ScratchpadOption.test.tsx │ │ │ ├── LastSyncedNotification.test.tsx │ │ │ ├── NoteList/ │ │ │ │ ├── NoteListButton.test.tsx │ │ │ │ └── SearchBar.test.tsx │ │ │ ├── SettingsModal/ │ │ │ │ ├── IconButton.test.tsx │ │ │ │ └── IconButtonUploader.test.tsx │ │ │ ├── Switch.test.tsx │ │ │ └── editor/ │ │ │ ├── EditorEmpty.test.tsx │ │ │ └── PreviewEditor.test.tsx │ │ ├── containers/ │ │ │ ├── ContextMenuOptions.test.tsx │ │ │ └── TakeNoteApp.test.tsx │ │ ├── slices/ │ │ │ ├── auth.test.ts │ │ │ ├── category.test.ts │ │ │ ├── note.test.ts │ │ │ ├── settings.test.ts │ │ │ └── sync.test.ts │ │ ├── testHelpers.tsx │ │ └── utils/ │ │ └── index.test.ts │ └── server/ │ └── middleware/ │ └── checkAuth.test.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .all-contributorsrc ================================================ { "projectName": "takenote", "projectOwner": "taniarascia", "repoType": "github", "repoHost": "https://github.com", "files": [ "README.md" ], "imageSize": 50, "commit": true, "commitConvention": "none", "contributors": [ { "login": "taniarascia", "name": "Tania Rascia", "avatar_url": "https://avatars3.githubusercontent.com/u/11951801?v=4", "profile": "https://www.taniarascia.com", "contributions": [ "code", "ideas", "bug" ] }, { "login": "hankolsen", "name": "hankolsen", "avatar_url": "https://avatars3.githubusercontent.com/u/1008390?v=4", "profile": "https://github.com/hankolsen", "contributions": [ "code", "bug", "test" ] }, { "login": "joseph-perez", "name": "Joseph Perez", "avatar_url": "https://avatars0.githubusercontent.com/u/7772649?v=4", "profile": "https://github.com/joseph-perez", "contributions": [ "code" ] }, { "login": "dagda1", "name": "Paul", "avatar_url": "https://avatars0.githubusercontent.com/u/118328?v=4", "profile": "https://cutting.scot", "contributions": [ "code", "test" ] }, { "login": "MartinRosenberg", "name": "Martin Rosenberg", "avatar_url": "https://avatars2.githubusercontent.com/u/2382147?v=4", "profile": "https://martinbrosenberg.com/", "contributions": [ "code", "bug", "maintenance" ] }, { "login": "meowwwls", "name": "Melissa", "avatar_url": "https://avatars3.githubusercontent.com/u/16426195?v=4", "profile": "http://codepen.io/meowwwls", "contributions": [ "code" ] }, { "login": "jjtowle", "name": "Jason Towle", "avatar_url": "https://avatars0.githubusercontent.com/u/41359068?v=4", "profile": "https://github.com/jjtowle", "contributions": [ "code" ] }, { "login": "markerikson", "name": "Mark Erikson", "avatar_url": "https://avatars1.githubusercontent.com/u/1128784?v=4", "profile": "http://blog.isquaredsoftware.com", "contributions": [ "ideas" ] }, { "login": "alphonseb", "name": "Alphonse Bouy", "avatar_url": "https://avatars2.githubusercontent.com/u/32797759?v=4", "profile": "http://www.alphonsebouy.fr", "contributions": [ "bug" ] }, { "login": "dave2kb", "name": "dave2kb", "avatar_url": "https://avatars1.githubusercontent.com/u/30696030?v=4", "profile": "https://github.com/dave2kb", "contributions": [ "design", "ideas" ] }, { "login": "Dantaro", "name": "Devin McIntyre", "avatar_url": "https://avatars3.githubusercontent.com/u/2750903?v=4", "profile": "https://github.com/Dantaro", "contributions": [ "code" ] }, { "login": "jeffslofish", "name": "Jeffrey Fisher", "avatar_url": "https://avatars0.githubusercontent.com/u/1240484?v=4", "profile": "http://slofish.io", "contributions": [ "bug" ] }, { "login": "dong-alex", "name": "Alex Dong", "avatar_url": "https://avatars2.githubusercontent.com/u/23242741?v=4", "profile": "https://github.com/dong-alex", "contributions": [ "code" ] }, { "login": "Publicker", "name": "Publicker", "avatar_url": "https://avatars2.githubusercontent.com/u/52673485?v=4", "profile": "https://github.com/Publicker", "contributions": [ "code" ] }, { "login": "kleyu", "name": "Jakub Naskręski", "avatar_url": "https://avatars2.githubusercontent.com/u/36169811?v=4", "profile": "https://github.com/kleyu", "contributions": [ "code", "bug", "test" ] }, { "login": "opw0011", "name": "Benny O", "avatar_url": "https://avatars2.githubusercontent.com/u/10897048?v=4", "profile": "https://opw0011.github.io/", "contributions": [ "code" ] }, { "login": "justDOindev", "name": "Justin Payne", "avatar_url": "https://avatars3.githubusercontent.com/u/44042682?v=4", "profile": "https://github.com/justDOindev", "contributions": [ "code" ] }, { "login": "yikjin", "name": "marshmallow", "avatar_url": "https://avatars2.githubusercontent.com/u/34995304?v=4", "profile": "https://yikjin.github.io", "contributions": [ "maintenance" ] }, { "login": "Jfelix61", "name": "Jose Felix ", "avatar_url": "https://avatars2.githubusercontent.com/u/21092519?v=4", "profile": "http://jfelix.info", "contributions": [ "code" ] }, { "login": "xboston", "name": "Nikolay Kirsh", "avatar_url": "https://avatars1.githubusercontent.com/u/201306?v=4", "profile": "https://xboston.dev", "contributions": [ "code" ] }, { "login": "Mudassar045", "name": "Mudassar Ali", "avatar_url": "https://avatars0.githubusercontent.com/u/24487349?v=4", "profile": "https://github.com/Mudassar045", "contributions": [ "code" ] }, { "login": "NathanBland", "name": "Nathan Bland", "avatar_url": "https://avatars1.githubusercontent.com/u/926111?v=4", "profile": "https://nathanbland.github.io/", "contributions": [ "bug", "code" ] }, { "login": "siliconeidolon", "name": "Craig Lam", "avatar_url": "https://avatars1.githubusercontent.com/u/8170456?v=4", "profile": "http://craiglam.com", "contributions": [ "code", "bug", "test" ] }, { "login": "ashinzekene", "name": "Ashinze Ekene", "avatar_url": "https://avatars2.githubusercontent.com/u/20991583?v=4", "profile": "https://twitter.com/ashinzekene", "contributions": [ "bug", "code" ] }, { "login": "harrySullivan", "name": "Harry Sullivan", "avatar_url": "https://avatars0.githubusercontent.com/u/38230536?v=4", "profile": "https://adityasriram.ga", "contributions": [ "code" ] }, { "login": "moudev", "name": "Mauricio Martínez", "avatar_url": "https://avatars2.githubusercontent.com/u/13499566?v=4", "profile": "https://github.com/moudev", "contributions": [ "code" ] }, { "login": "BlackHole1", "name": "Black-Hole", "avatar_url": "https://avatars0.githubusercontent.com/u/8198408?v=4", "profile": "http://www.bugs.cc/", "contributions": [ "code" ] }, { "login": "yogan", "name": "Frank Blendinger", "avatar_url": "https://avatars0.githubusercontent.com/u/122564?v=4", "profile": "https://zogan.de/", "contributions": [ "code" ] }, { "login": "osiux", "name": "Eduardo Reveles", "avatar_url": "https://avatars2.githubusercontent.com/u/204463?v=4", "profile": "https://www.osiux.ws", "contributions": [ "code" ] }, { "login": "leofrozenyogurt", "name": "Leo Royzengurt", "avatar_url": "https://avatars2.githubusercontent.com/u/2198384?v=4", "profile": "https://github.com/leofrozenyogurt", "contributions": [ "code", "bug" ] }, { "login": "kcvgan", "name": "kcvgan", "avatar_url": "https://avatars1.githubusercontent.com/u/13578888?v=4", "profile": "https://github.com/kcvgan", "contributions": [ "code", "bug" ] }, { "login": "codytowstik", "name": "Cody Towstik", "avatar_url": "https://avatars1.githubusercontent.com/u/10625608?v=4", "profile": "https://github.com/codytowstik", "contributions": [ "code", "test", "bug" ] }, { "login": "vincentdoerig", "name": "Vincent Dörig", "avatar_url": "https://avatars3.githubusercontent.com/u/24668338?v=4", "profile": "https://github.com/vincentdoerig", "contributions": [ "test", "code" ] }, { "login": "miqh", "name": "Michael Huynh", "avatar_url": "https://avatars3.githubusercontent.com/u/43751307?v=4", "profile": "https://github.com/miqh", "contributions": [ "code", "bug" ] }, { "login": "code128", "name": "Joshua Bloom", "avatar_url": "https://avatars0.githubusercontent.com/u/43435?v=4", "profile": "https://github.com/code128", "contributions": [ "code" ] }, { "login": "Mxchaeltrxn", "name": "Mxchaeltrxn", "avatar_url": "https://avatars3.githubusercontent.com/u/34886045?v=4", "profile": "https://github.com/Mxchaeltrxn", "contributions": [ "code", "test" ] }, { "login": "KonradStanski", "name": "Konrad Staniszewski", "avatar_url": "https://avatars2.githubusercontent.com/u/38778413?v=4", "profile": "https://konradstaniszewski.com", "contributions": [ "doc" ] }, { "login": "yohix", "name": "Yohix", "avatar_url": "https://avatars3.githubusercontent.com/u/61746440?v=4", "profile": "https://github.com/yohix", "contributions": [ "maintenance" ] }, { "login": "jackson-elfers", "name": "Jackson Elfers", "avatar_url": "https://avatars1.githubusercontent.com/u/55408089?v=4", "profile": "https://github.com/jackson-elfers", "contributions": [ "code" ] }, { "login": "vamshi-tg", "name": "Vamshi", "avatar_url": "https://avatars2.githubusercontent.com/u/32225088?v=4", "profile": "https://github.com/vamshi-tg", "contributions": [ "code" ] }, { "login": "pavlakissimos", "name": "Simos", "avatar_url": "https://avatars1.githubusercontent.com/u/19609475?v=4", "profile": "https://github.com/pavlakissimos", "contributions": [ "code", "test" ] }, { "login": "ggonza89", "name": "Yankee", "avatar_url": "https://avatars0.githubusercontent.com/u/5530647?v=4", "profile": "https://github.com/ggonza89", "contributions": [ "code", "ideas", "test" ] }, { "login": "G-Milevski", "name": "G-Milevski", "avatar_url": "https://avatars2.githubusercontent.com/u/25174255?v=4", "profile": "https://github.com/G-Milevski", "contributions": [ "code" ] }, { "login": "kodyclemens", "name": "Kody Clemens", "avatar_url": "https://avatars0.githubusercontent.com/u/43357615?v=4", "profile": "https://kodyclemens.com", "contributions": [ "code", "test", "bug" ] }, { "login": "qpeela", "name": "Vladimir Yamshikov", "avatar_url": "https://avatars3.githubusercontent.com/u/5824914?v=4", "profile": "https://github.com/qpeela", "contributions": [ "code", "bug" ] }, { "login": "ronan696", "name": "Ronan D'Souza", "avatar_url": "https://avatars1.githubusercontent.com/u/13074003?v=4", "profile": "https://about.me/ronan696", "contributions": [ "code" ] }, { "login": "ModProg", "name": "Roland Fredenhagen", "avatar_url": "https://avatars0.githubusercontent.com/u/11978847?v=4", "profile": "http://modprog.de", "contributions": [ "code" ] }, { "login": "PranjaliPatil14", "name": "Pranjali Pramod Patil", "avatar_url": "https://avatars2.githubusercontent.com/u/31987627?v=4", "profile": "https://github.com/PranjaliPatil14", "contributions": [ "test" ] }, { "login": "cbrgm", "name": "Chris Bargmann", "avatar_url": "https://avatars1.githubusercontent.com/u/24737434?v=4", "profile": "https://cbrgm.net", "contributions": [ "ideas", "code" ] }, { "login": "Jadhielv", "name": "Jadhiel Vélez", "avatar_url": "https://avatars3.githubusercontent.com/u/24376900?v=4", "profile": "https://www.linkedin.com/in/jadhielv", "contributions": [ "code", "bug" ] }, { "login": "machadolucasvp", "name": "Lucas Machado", "avatar_url": "https://avatars0.githubusercontent.com/u/44952113?v=4", "profile": "https://github.com/machadolucasvp", "contributions": [ "code", "bug", "test" ] }, { "login": "xsteadybcgo", "name": "xsteadybcgo", "avatar_url": "https://avatars3.githubusercontent.com/u/19681921?v=4", "profile": "https://github.com/xsteadybcgo", "contributions": [ "bug" ] }, { "login": "Rwandarushya", "name": "Marius Robert RWANDARUSHYA", "avatar_url": "https://avatars2.githubusercontent.com/u/49269745?v=4", "profile": "https://github.com/Rwandarushya", "contributions": [ "test" ] }, { "login": "Isaackomeza", "name": "Isaac Komezusenge", "avatar_url": "https://avatars1.githubusercontent.com/u/66563235?v=4", "profile": "https://github.com/Isaackomeza", "contributions": [ "test" ] }, { "login": "maximeish", "name": "Maxime Ishimwe", "avatar_url": "https://avatars0.githubusercontent.com/u/54126307?v=4", "profile": "https://github.com/maximeish", "contributions": [ "test" ] }, { "login": "marcosspn", "name": "Marcos Spanholi", "avatar_url": "https://avatars3.githubusercontent.com/u/2171424?v=4", "profile": "https://github.com/marcosspn", "contributions": [ "test" ] }, { "login": "roshanrajeev", "name": "Roshan Rajeev", "avatar_url": "https://avatars2.githubusercontent.com/u/52269241?v=4", "profile": "http://roshanrajeev.xyz", "contributions": [ "code" ] }, { "login": "fistonhn", "name": "fistonhn", "avatar_url": "https://avatars0.githubusercontent.com/u/55746279?v=4", "profile": "https://github.com/fistonhn", "contributions": [ "test" ] }, { "login": "raffaeleferri", "name": "Raffaele Ferri", "avatar_url": "https://avatars0.githubusercontent.com/u/75796924?v=4", "profile": "https://github.com/raffaeleferri", "contributions": [ "maintenance" ] }, { "login": "joshwambere", "name": "Dusabe Johnson", "avatar_url": "https://avatars2.githubusercontent.com/u/59834399?v=4", "profile": "https://github.com/joshwambere", "contributions": [ "test" ] }, { "login": "tomasvn", "name": "tomasvn", "avatar_url": "https://avatars.githubusercontent.com/u/17225564?v=4", "profile": "https://github.com/tomasvn", "contributions": [ "code" ] }, { "login": "lucasvribeiro", "name": "Lucas Ribeiro", "avatar_url": "https://avatars.githubusercontent.com/u/12684816?v=4", "profile": "http://www.lucasribeiro.dev", "contributions": [ "code", "test" ] }, { "login": "Bartek532", "name": "Bartosz Zagrodzki", "avatar_url": "https://avatars.githubusercontent.com/u/57185551?v=4", "profile": "http://bartek532.github.io/portfolio", "contributions": [ "code" ] }, { "login": "mookkiah", "name": "Mahendran Mookkiah", "avatar_url": "https://avatars.githubusercontent.com/u/8975264?v=4", "profile": "https://www.linkedin.com/in/mookkiah/", "contributions": [ "code" ] }, { "login": "hkhattabii", "name": "hkhattabii", "avatar_url": "https://avatars.githubusercontent.com/u/54418529?v=4", "profile": "https://github.com/hkhattabii", "contributions": [ "code" ] }, { "login": "Federico-Pomponii", "name": "Federico Pomponii", "avatar_url": "https://avatars.githubusercontent.com/u/6978411?v=4", "profile": "https://github.com/Federico-Pomponii", "contributions": [ "code" ] } ], "contributorsPerLine": 7, "skipCi": true } ================================================ FILE: .dockerignore ================================================ node_modules dist .git .vscode !src !public !docs !config/* !package.json !package-lock.json ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf indent_size = 2 indent_style = space insert_final_newline = true trim_trailing_whitespace = true ================================================ FILE: .eslintrc.js ================================================ /** * ESLint Configuration */ module.exports = { parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 2020, sourceType: 'module', ecmaFeatures: { modules: true, }, }, extends: [ 'plugin:react/recommended', 'plugin:import/typescript', 'plugin:import/errors', 'plugin:import/warnings', 'plugin:prettier/recommended', 'prettier/react', ], plugins: ['import'], rules: { // Separate import groups with newline by section 'import/order': [ 'error', { groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'unknown'], 'newlines-between': 'always', }, ], 'no-console': 1, // Warning to reduce console logs used throughout app 'react/prop-types': 0, // Not using prop-types because we have TypeScript 'newline-before-return': 1, 'no-useless-return': 1, 'prefer-const': 1, 'no-useless-return': 1, 'no-unused-vars': 0, }, settings: { 'import/resolver': { // Allow `@/` to map to `src/client/` alias: { map: [ ['@', './src/client'], ['@resources', './src/resources'], ], extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'], }, }, react: { version: 'detect', }, }, } ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '[Bug]' labels: 'Type: Bug' assignees: '' --- **To Reproduce** Steps to reproduce the behavior: **Expected behavior** A clear and concise description of what you expected to happen. **Actual behavior** What actually happened. **Notes** ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '[Feature]' labels: 'Type: Feature' assignees: '' --- **Problem** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Solution** A clear and concise description of what you want to happen. **Notes** ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Description Please read the [Contribution Guidelines](../CONTRIBUTING.md) before opening a pull request. Include a summary of the change and which issue is fixed. Closes # <-- link the issue number here ### Browser checklist This PR has been tested in the following browsers: - [ ] Chrome - [ ] Firefox - [ ] Safari ### Testing checklist - [ ] End-to-end tests have been created if necessary ================================================ FILE: .gitignore ================================================ /node_modules /coverage /dist /cypress .idea .DS_Store .env .vscode npm-debug.log* .coveralls.yml **/*/videos tests/e2e/videos ================================================ FILE: .prettierrc ================================================ { "bracketSpacing": true, "printWidth": 100, "semi": false, "singleQuote": true, "tabWidth": 2, "trailingComma": "es5" } ================================================ FILE: .travis.yml ================================================ language: node_js node_js: - '12' addons: apt: packages: # Ubuntu 16+ does not install this dependency by default, so we need to install it ourselves - libgconf-2-4 - snapd cache: # Caches $HOME/.npm when npm ci is default script command # Caches node_modules in all other cases npm: false # directories: # we also need to cache folder with Cypress binary # - ~/.cache services: # Use Docker command line - docker install: # Install dependencies for tests - echo "MTY1LjIyNy42MC4zMyBlY2RzYS1zaGEyLW5pc3RwMjU2IEFBQUFFMlZqWkhOaExYTm9ZVEl0Ym1semRIQXlOVFlBQUFBSWJtbHpkSEF5TlRZQUFBQkJCTVVPRlVVT3BxSzNmWkMzUUxJNmsrL2Vlc1l5YVVaNGZXbkRUaWNia1pjMmJIR1ltMG4wVk9RaW5mK0NYY2xhWmZTaVBNQ0xZakJUUzkrUWxWSFpPZ009" | base64 -d >> $HOME/.ssh/known_hosts - sudo snap install doctl - npm ci before_script: # Start server and client for tests - echo -e "CLIENT_ID=abc\nDEMO=true" > .env - npm run client & script: # Run unit, component, and e2e tests - npm run test:coverage:ci && npm run test:e2e # Commenting out the deploy phase as the demo is static and no longer requires a server # So Travis is currently only being used for running tests, not deploying # deploy: # # Build Docker container and push to Docker Hub # # Pull into DigitalOcean container and start # provider: script # script: bash deploy.sh # on: # branch: master ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. ## v0.7.2 10/27/2020 Refactoring. - Add SonarQube via SonarCloud (https://sonarcloud.io/dashboard?id=taniarascia_takenote) - Fix bugs and code smells - Fix #422 /app redirect - Add demo environment variable - Add compression - Remove prettier and associated massive Webpack bundle ## v0.7.1 10/25/2020 Add GitHub integration. ### Changed - Add code for integrating GitHub sync (https://github.com/taniarascia/takenote/pull/389) - Create demo mode for takenote.dev deployment - Add tests for selectors - Hide note list in Scratchpad view - Localized dates - Various bug fixes - Additional settings ## v0.6.1 10/19/2020 Add top menu bar. ### Changed - Add top menu bar for preview, sync, settings, and other note options - Update settings UI - Add sync test back - Organize end-to-end tests ## v0.6.0 10/16/2020 Upgrade to webpack 5. ### Changed - Updated all packages, notable webpack 4 to webpack 5 - Updated webpack config to reflect breaking changes - Manually brought in polyfills for Node packages that are no longer polyfilled by webpack - Moved settings to bottom of app sidebar - Removed sync button - Removed unnecessary patches ## v0.5.0 02/22/2020 GitHub authentication. ### Added - Log in/log out functionality implemented using GitHub OAuth ### Changed - Refactored large files into smaller components - Added folder structure and technologies to README - Modify deployment scripts and Dockerfile to allow local development with GitHub authentication - Prompt to confirm exit added when notes have not yet been synced ## v0.4.0 02/03/2020 Initial release. ### Added - Created `CHANGELOG.md` - Added Node/TypeScript backend for REST API calls - Created CI/CD pipeline with `deploy.sh`, `.travis.yml` and `Dockerfile` ### Changed - Migrated website from Netlify to DigitalOcean - Added instructions for new local development to README - Removed Netlify badge from README - Removed Create React App and replaced with custom Webpack setup ### Removed - Removed Service Worker due to the application no longer being fully client side ================================================ FILE: CONTRIBUTING.md ================================================ # Contribution Guidelines TakeNote is an open source project, and contributions of any kind are welcome and appreciated. Open issues, bugs, and enhancements are all listed on the [issues](https://github.com/taniarascia/takenote/issues) tab and labeled accordingly. Feel free to open bug tickets and make feature requests. Easy bugs and features will be tagged with the `good first issue` label. ## Issues If you encounter a bug, please file a bug report. If you have a feature to request, please open a feature request. If you would like to work on an issue or feature, there is no need to request permission. Please add tests to any new features. ## Pull Requests In order to create a pull request for TakeNote, follow the GitHub instructions for [Creating a pull request from a fork](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork). Please link your pull request to an existing issue. ## Folder Structure Description of the project files and directories. ```bash ├── config/ # Configuration │ ├── cypress.config.js # Cypress end-to-end test configuration │ ├── jest.config.js # Jest unit/component test configuration │ ├── nodemon.config.json # Nodemon configuration │ ├── webpack.common.js # Webpack shared configuration │ ├── webpack.dev.js # Webpack development configuration (dev server) │ └── webpack.prod.js # Webpack productuon configuration (dist output) ├── assets/ # Supplemental assets ├── public/ # Files that will write to dist on build ├── src/ # All TakeNote app source files │ ├── resources/ # Shared resources │ ├── client/ # React client side code │ │ ├── api/ # Temporary placeholders for mock API calls │ │ ├── components/ # React components that are not connected to Redux │ │ ├── containers/ # React Redux connected containers │ │ ├── contexts/ # React context global state without Redux │ │ ├── router/ # React private and public routes │ │ ├── sagas/ # Redux sagas │ │ ├── selectors/ # Redux Toolkit selectors │ │ ├── slices/ # Redux Toolkit slices │ │ ├── styles/ # Sass style files │ │ ├── types/ # TypeScript types │ │ ├── utils/ # Utility functions │ │ └── index.tsx # Client side entry point │ └── server/ # Node/Express server side code │ ├── handlers/ # Functions for API endpoints │ ├── middleware/ # Middleware for API endpoints │ ├── router/ # Route API endpoints │ ├── utils/ # Backend utilities │ └── index.ts # Server entrypoint ├── tests/ # Test suites │ ├── e2e/ # Cypress end-to-end tests │ └── unit/ # React Testing Library and Jest tests ├── .dockerignore # Files ignored by Docker ├── .editorconfig # Configures editor rules ├── .gitignore # Files ignored by git ├── .prettierrc # Code convention enforced by Prettier ├── .travis.yml # Continuous integration and deployment config ├── CHANGELOG.md # List of significant changes ├── deploy.sh # Deployment script for Docker in production ├── Dockerfile # Docker build instructions ├── LICENSE # License for this open source project ├── package-lock.json # Package lockfile ├── package.json # Dependencies and additional information ├── README.md ├── seed.js # Seed the app with data for testing └── tsconfig.json # Typescript configuration ``` ## Scripts An explanation of the `package.json` scripts. | Command | Description | | --------------- | ------------------------------------------- | | `dev` | Run TakeNote in a development environment | | `dev:test` | Run TakeNote in a testing environment | | `client` | Start a webpack dev server for the frontend | | `server` | Start a nodemon dev server for the backend | | `build` | Create a production build of TakeNote | | `start` | Start a production server for TakeNote | | `test` | Run unit and component tests | | `test:e2e` | Run end-to-end tests in the command line | | `test:e2e:open` | Open end-to-end tests in a browser | | `test:coverage` | Get test coverage | ## Technologies This project is possible thanks to all these open source languages, libraries, and frameworks. | Tech | Description | | --------------------------------------------- | ----------------------------------------- | | [Codemirror](https://codemirror.net/) | Browser-based text editor | | [TypeScript](https://www.typescriptlang.org/) | Static type-checking programming language | | [Node.js](https://nodejs.org/en/) | JavaScript runtime for the backend | | [Express](https://expressjs.com/) | Server framework | | [React](https://reactjs.org/) | Front end user interface | | [Redux](https://redux.js.org/) | Global state management | | [Webpack](https://webpack.js.org/) | Asset bundler | | [Sass](https://sass-lang.com/) | Style preprocessor | | [OAuth](https://oauth.net/) | Protocol for secure authorization | | [ESLint](https://eslint.org/) | TypeScript linting | | [Jest](https://jestjs.io/) | Unit testing framework | | [Cypress](https://www.cypress.io/) | End-to-end testing framework | ## Styleguide Coding conventions are enforced by [ESLint](.eslintrc.js) and [Prettier](.prettierrc). - No semicolons - Single quotes - Two space indentation - Trailing commas in arrays and objects - [Non-default exports](https://humanwhocodes.com/blog/2019/01/stop-using-default-exports-javascript-module/) are preferred for components - Module imports are ordered and separated: **built-in** -> **external** -> **internal** -> **css/assets/other** - TypeScript: strict mode, with no implicitly any - React: functional style with Hooks (no classes) - `const` preferred over `let` ================================================ FILE: Dockerfile ================================================ # Use small Alpine Linux image FROM node:12-alpine # Set environment variables ENV PORT=5000 ARG CLIENT_ID COPY . app/ WORKDIR app/ # Make sure dependencies exist for Webpack loaders RUN apk add --no-cache \ autoconf \ automake \ bash \ g++ \ libc6-compat \ libjpeg-turbo-dev \ libpng-dev \ make \ nasm RUN npm ci --only-production --silent # Build production client side React application RUN npm run build # Expose port for Node EXPOSE $PORT # Start Node server ENTRYPOINT npm run prod ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2020 Tania Rascia Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

Coverage Status

A web-based notes app for developers. (Demo only)

![Screenshot](./assets/takenote-light.png) ## Features - **Plain text notes** - take notes in an IDE-like environment that makes no assumptions - **Markdown preview** - view rendered HTML - **Linked notes** - use `{{uuid}}` syntax to link to notes within other notes - **Syntax highlighting** - light and dark mode available (based on the beautiful [New Moon theme](https://taniarascia.github.io/new-moon/)) - **Keyboard shortcuts** - use the keyboard for all common tasks - creating notes and categories, toggling settings, and other options - **Drag and drop** - drag a note or multiple notes to categories, favorites, or trash - **Multi-cursor editing** - supports multiple cursors and other [Codemirror](https://codemirror.net/) options - **Search notes** - easily search all notes, or notes within a category - **Prettify notes** - use Prettier on the fly for your Markdown - **No WYSIWYG** - made for developers, by developers - **No database** - notes are only stored in the browser's local storage and are available for download and export to you alone - **No tracking or analytics** - 'nuff said - **GitHub integration** - self-hosted option is available for auto-syncing to a GitHub repository (not available in the demo) ## About TakeNote is a note-taking app for the web. You can use the demo app at [takenote.dev](https://takenote.dev). It is a static site without a database and does not sync your notes to the cloud. The notes are persisted temporarily in local storage, but you can download all notes in markdown format as a zip. Hidden within the code is an alternate version that contain a Node/Express server and integration with GitHub. This version involves creating an OAuth application for GitHub and signing up to it with private repository permissions. Instead of backing up to local storage, your notes will back up to a private repository in your account called `takenote-data`. Due to the following reasons I'm choosing not to deploy or maintain this portion of the application: - I do not want to maintain a free app with users alongside my career and other commitments - I do not want to request private repository permissions from users - I do not want to maintain an active server - I do not want to worry about GitHub rate limiting from the server - There is no way to batch create many files from the GitHub API, leading to a suboptimal GitHub storage solution However, I'm leaving the code available so you can feel free to host your own TakeNote instance or study the code for learning purposes. I do not provide support or guidance for these purposes. TakeNote was created with TypeScript, React, Redux, Node, Express, Codemirror, Webpack, Jest, Cypress, Feather Icons, ESLint, and Mousetrap, among other awesome open-source software. ## Reviews > _"I think the lack of extra crap is a feature."_ — Craig Lam ## Demo Development Clone and install. ```bash git clone git@github.com:taniarascia/takenote cd takenote npm i ``` Run a development server. ```bash npm run client ``` ## Full Application Development (self-hosted) ### Pre-Installation Before working on TakeNote locally, you must create a GitHub OAuth app for development. Go to your GitHub profile settings, and click on **Developer Settings**. Click the **New OAuth App** button. - **Application name**: TakeNote Development - **Homepage URL**: `http://localhost:3000` - **Authorization callback URL**: `http://localhost:3000/api/auth/callback` Create a `.env` file in the root of the project, and add the app's client ID and secret. Remove `DEMO` variable to enable GitHub integration. ```bash CLIENT_ID=xxx CLIENT_SECRET=xxxx DEMO=true ``` > Change the URLs to port `5000` in production mode or Docker. ### Installation ```bash git clone git@github.com:taniarascia/takenote cd takenote npm i ``` #### Development mode In the development environment, an Express server is running on port `5000` to handle all API calls, and a hot Webpack dev server is running on port `3000` for the React frontend. To run both of these servers concurrently, run the `dev` command. ```bash npm run dev ``` Go to `localhost:3000` to view the app. > API requests will be proxied to port `5000` automatically. #### Production mode In the production environment, the React app is built, and Express redirects all incoming requests to the `dist` directory on port `5000`. ```bash npm run build && npm run start ``` Go to `localhost:5000` to view the app. #### Run in Docker Follow these instructions to build an image and run a container. ```bash # Build Docker image docker build --build-arg CLIENT_ID=xxx -t takenote:mytag . # Run Docker container in port 5000 docker run \ -e CLIENT_ID=xxx \ -e CLIENT_SECRET=xxxx \ -e NODE_ENV=development \ -p 5000:5000 \ takenote:mytag ``` Go to `localhost:5000` to view the app. > Note: You will see some errors during the installation phase, but these are simply warnings that unnecessary packages do not exist, since the Node Alpine base image is minimal. ### Seed data To seed the app with some test data, paste the contents of `seed.js` into your browser console. ## Testing Run unit and component/integration tests. ```bash npm run test ``` > If using Jest Runner in VSCode, add `"jestrunner.configPath": "config/jest.config.js"` to your settings Run Cypress end-to-end tests. ```bash # In one window, run the application npm run client # In another window, run the end-to-end tests npm run test:e2e:open ``` ## Contributing TakeNote is an open source project, and contributions of any kind are welcome and appreciated. Open issues, bugs, and feature requests are all listed on the [issues](https://github.com/taniarascia/takenote/issues) tab and labeled accordingly. Feel free to open bug tickets and make feature requests. Easy bugs and features will be tagged with the `good first issue` label. View [CONTRIBUTING.md](CONTRIBUTING.md) to learn about the style guide, folder structure, scripts, and how to contribute. ## Contributors Thanks goes to these wonderful people:

Tania Rascia

💻 🤔 🐛

hankolsen

💻 🐛 ⚠️

Joseph Perez

💻

Paul

💻 ⚠️

Martin Rosenberg

💻 🐛 🚧

Melissa

💻

Jason Towle

💻

Mark Erikson

🤔

Alphonse Bouy

🐛

dave2kb

🎨 🤔

Devin McIntyre

💻

Jeffrey Fisher

🐛

Alex Dong

💻

Publicker

💻

Jakub Naskręski

💻 🐛 ⚠️

Benny O

💻

Justin Payne

💻

marshmallow

🚧

Jose Felix

💻

Nikolay Kirsh

💻

Mudassar Ali

💻

Nathan Bland

🐛 💻

Craig Lam

💻 🐛 ⚠️

Ashinze Ekene

🐛 💻

Harry Sullivan

💻

Mauricio Martínez

💻

Black-Hole

💻

Frank Blendinger

💻

Eduardo Reveles

💻

Leo Royzengurt

💻 🐛

kcvgan

💻 🐛

Cody Towstik

💻 ⚠️ 🐛

Vincent Dörig

⚠️ 💻

Michael Huynh

💻 🐛

Joshua Bloom

💻

Mxchaeltrxn

💻 ⚠️

Konrad Staniszewski

📖

Yohix

🚧

Jackson Elfers

💻

Vamshi

💻

Simos

💻 ⚠️

Yankee

💻 🤔 ⚠️

G-Milevski

💻

Kody Clemens

💻 ⚠️ 🐛

Vladimir Yamshikov

💻 🐛

Ronan D'Souza

💻

Roland Fredenhagen

💻

Pranjali Pramod Patil

⚠️

Chris Bargmann

🤔 💻

Jadhiel Vélez

💻 🐛

Lucas Machado

💻 🐛 ⚠️

xsteadybcgo

🐛

Marius Robert RWANDARUSHYA

⚠️

Isaac Komezusenge

⚠️

Maxime Ishimwe

⚠️

Marcos Spanholi

⚠️

Roshan Rajeev

💻

fistonhn

⚠️

Raffaele Ferri

🚧

Dusabe Johnson

⚠️

tomasvn

💻

Lucas Ribeiro

💻 ⚠️

Bartosz Zagrodzki

💻

Mahendran Mookkiah

💻

hkhattabii

💻

Federico Pomponii

💻
## Acknowledgements - A big thank you to [David Bock](https://dkbock.com/) for logo design. ## Author - [Tania Rascia](https://www.taniarascia.com) ## License This project is open source and available under the [MIT License](LICENSE). ================================================ FILE: config/cypress.config.json ================================================ { "baseUrl": "http://localhost:3000", "integrationFolder": "tests/e2e/integration", "pluginsFile": "tests/e2e/plugins/index.js", "supportFile": "tests/e2e/support/index.js", "fixturesFolder": "tests/e2e/fixtures", "videosFolder": "tests/e2e/videos", "video": false } ================================================ FILE: config/jest.config.js ================================================ module.exports = { // Setting the root to the actual root, since this file is in root/config preset: 'ts-jest', rootDir: '../', roots: ['/src', '/tests/unit'], transform: { '^.+\\.tsx?$': 'ts-jest', '\\.(html|xml|txt|md)$': 'jest-raw-loader', }, setupFilesAfterEnv: ['@testing-library/jest-dom', 'jest-extended'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], moduleNameMapper: { // Allow `@/` to map to `src/client/` in Jest tests '@/(.*)$': '/src/client/$1', '@resources/(.*)$': '/src/resources/$1', '\\.(css|less)$': '/tests/__mocks__/styleMock.ts', }, globals: { 'ts-jest': { diagnostics: false, }, }, } ================================================ FILE: config/nodemon.config.json ================================================ { "ignore": [ ".git", ".eslintrc", "node_modules/**", "src/client/**", "test/**", "public/**", "*.test.js", "webpack.*.js" ], "watch": ["src/server/"], "ext": "ts", "exec": "cross-env NODE_ENV=development node --inspect -r ts-node/register ./src/server/index.ts" } ================================================ FILE: config/webpack.common.js ================================================ const path = require('path') const dotenv = require('dotenv') const webpack = require('webpack') const { CleanWebpackPlugin } = require('clean-webpack-plugin') const CopyWebpackPlugin = require('copy-webpack-plugin') /** * Obtain client id for OAuth link in React * * If in development mode or local production mode, search the .env file for * client id. If using Docker, pass a build arg. */ const getEnvFromDotEnvFile = dotenv.config() let envKeys if (getEnvFromDotEnvFile.error) { console.log('Getting environment variables from build args for production') // eslint-disable-line envKeys = { 'process.env.CLIENT_ID': JSON.stringify(process.env.CLIENT_ID), 'process.env.DEMO': JSON.stringify(process.env.DEMO), 'process.env.NODE_ENV': JSON.stringify('production'), } } else { envKeys = { 'process.env.CLIENT_ID': JSON.stringify(getEnvFromDotEnvFile.parsed['CLIENT_ID']), 'process.env.DEMO': JSON.stringify(getEnvFromDotEnvFile.parsed['DEMO']), } } module.exports = { entry: ['./src/client/index.tsx'], output: { path: path.resolve(__dirname, '../dist'), filename: '[name].[fullhash].bundle.js', publicPath: '/', }, module: { rules: [ /** * TypeScript (.ts/.tsx files) * * The TypeScript loader will compile all .ts/.tsx files to .js. Babel is * not necessary here since TypeScript is taking care of all transpiling. */ { test: /\.ts(x?)$/, loader: 'ts-loader', exclude: /node_modules/, }, // Fonts { test: /\.(woff(2)?|eot|ttf|otf)$/, type: 'asset/inline', }, // Markdown { test: /\.md$/, type: 'asset/source', }, // Images { test: /\.(?:ico|gif|png|jpg|jpeg|webp|svg)$/i, type: 'asset/resource', }, ], }, resolve: { // Resolve in this order extensions: ['*', '.js', '.jsx', '.ts', '.tsx', '.md'], // Allow `@/` to map to `src/client/` alias: { '@': path.resolve(__dirname, '../src/client'), '@resources': path.resolve(__dirname, '../src/resources'), stream: 'stream-browserify', path: 'path-browserify', }, }, plugins: [ // Get environment variables in React new webpack.DefinePlugin(envKeys), new CleanWebpackPlugin(), new CopyWebpackPlugin({ patterns: [ { from: path.resolve(__dirname, '../public'), globOptions: { ignore: ['*.DS_Store', 'favicon.ico', 'template.html'], }, }, ], }), new webpack.ProvidePlugin({ process: 'process/browser', }), ], } ================================================ FILE: config/webpack.dev.js ================================================ const webpack = require('webpack') const { merge } = require('webpack-merge') const HtmlWebpackPlugin = require('html-webpack-plugin') const common = require('./webpack.common.js') module.exports = merge(common, { mode: 'development', devtool: 'eval-source-map', module: { rules: [ // Styles { test: /\.(scss|css)$/, use: [ 'style-loader', { loader: 'css-loader', options: { sourceMap: true, importLoaders: 1 }, }, { loader: 'sass-loader', options: { sourceMap: true } }, ], }, ], }, devServer: { historyApiFallback: true, proxy: { '/api': 'http://localhost:5000', }, open: true, compress: true, hot: true, port: 3000, }, plugins: [ new webpack.HotModuleReplacementPlugin(), new HtmlWebpackPlugin({ template: './public/template.html', favicon: './public/favicon.ico', }), ], }) ================================================ FILE: config/webpack.prod.js ================================================ const webpack = require('webpack') const { merge } = require('webpack-merge') const MiniCssExtractPlugin = require('mini-css-extract-plugin') const HtmlWebpackPlugin = require('html-webpack-plugin') const CssMinimizerPlugin = require('css-minimizer-webpack-plugin') const common = require('./webpack.common.js') // Disable React DevTools in production const disableReactDevtools = ` ` module.exports = merge(common, { mode: 'production', devtool: false, module: { rules: [ // Styles { test: /\.(scss|css)$/, use: [ MiniCssExtractPlugin.loader, { loader: 'css-loader', options: { importLoaders: 1, }, }, 'sass-loader', ], }, ], }, plugins: [ new MiniCssExtractPlugin({ filename: 'styles/[name].[contenthash].css', chunkFilename: 'styles/[name].[id].[contenthash].css', ignoreOrder: false, }), new HtmlWebpackPlugin({ template: './public/template.html', favicon: './public/favicon.ico', hash: true, disableReactDevtools, }), new webpack.SourceMapDevToolPlugin({ exclude: ['/node_modules/'], }), ], performance: { hints: 'warning', maxEntrypointSize: 512000, maxAssetSize: 512000, }, optimization: { minimizer: [new CssMinimizerPlugin(), '...'], runtimeChunk: 'multiple', splitChunks: { // Cache vendors since this code won't change very often cacheGroups: { vendor: { test: /[\\/]node_modules[\\/](react|react-dom|axios|redux|react-redux)[\\/]/, name: 'vendors', chunks: 'all', enforce: true, }, }, }, }, }) ================================================ FILE: deploy.sh ================================================ #!/bin/sh # Stop script from running if there are any errors set -e # Docker image IMAGE="taniarascia/takenote" # Git version with git hash and tags (if they exist) to be used as Docker tag GIT_VERSION=$(git describe --always --abbrev --tags --long) # Build and tag new Docker image and push up to Docker Hub echo "Building and tagging new Docker image: ${IMAGE}:${GIT_VERSION}" docker build --build-arg DEMO=true CLIENT_ID=${CLIENT_ID} -t ${IMAGE}:${GIT_VERSION} . docker tag ${IMAGE}:${GIT_VERSION} ${IMAGE}:latest # Login to Docker Hub and push newest build echo "Logging into Docker and pushing ${IMAGE}:${GIT_VERSION}" echo "${DOCKER_PASSWORD}" | docker login -u "${DOCKER_USERNAME}" --password-stdin docker push ${IMAGE}:${GIT_VERSION} docker push ${IMAGE}:latest # Login to DigitalOcean command line echo "Authorizing DigitalOcean" doctl auth init -t "${DO_ACCESS_TOKEN}" # Decode SSH key echo ${DO_SSH_KEY} | base64 -d > deploy_key chmod 600 deploy_key # Log into Droplet, stop the currently running container and start the new one echo "Stopping container name current and starting ${IMAGE}:${GIT_VERSION}" doctl compute ssh ${DROPLET} --ssh-key-path deploy_key --ssh-command "docker pull ${IMAGE}:${GIT_VERSION} && docker stop current && docker rm current && docker run --name=current --restart unless-stopped -e DEMO=true CLIENT_ID=${CLIENT_ID} -e CLIENT_SECRET=${CLIENT_SECRET} -d -p 80:5000 ${IMAGE}:${GIT_VERSION} && docker system prune -a -f && docker image prune -a -f" ================================================ FILE: kubernetes.yml ================================================ apiVersion: v1 data: client_id: // base64 encoded string client_secret: // base64 encoded string kind: Secret metadata: name: takenote-pwd labels: app: takenote component: server --- apiVersion: apps/v1 kind: Deployment metadata: name: takenote labels: app: takenote component: server spec: replicas: 1 selector: matchLabels: app: takenote component: server template: metadata: labels: app: takenote component: server spec: containers: - image: name: takenote env: - name: CLIENT_ID valueFrom: secretKeyRef: name: takenote-pwd key: client_id - name: CLIENT_SECRET valueFrom: secretKeyRef: name: takenote-pwd key: client_secret - name: NODE_ENV value: development ports: - containerPort: 5000 name: web --- apiVersion: v1 kind: Service metadata: name: takenote labels: app: takenote component: server spec: ports: - protocol: TCP port: 8080 targetPort: 5000 name: web selector: app: takenote component: server type: ClusterIP ================================================ FILE: package.json ================================================ { "name": "takenote", "version": "0.7.2", "description": "A web-based notes app for developers.", "author": "Tania Rascia", "license": "MIT", "private": false, "main": "src/server/index.ts", "scripts": { "dev": "concurrently \"npm run server\" \"npm run client\"", "client": "cross-env NODE_ENV=development webpack serve --config config/webpack.dev.js", "server": "nodemon --config config/nodemon.config.json", "build": "cross-env NODE_ENV=production webpack --config config/webpack.prod.js", "prod": "node -r ts-node/register/transpile-only src/server/index.ts", "start": "npm run client", "test": "jest --config config/jest.config.js", "test:e2e": "cypress run --config-file config/cypress.config.json", "test:e2e:open": "cypress open --config-file config/cypress.config.json", "test:coverage": "jest --config config/jest.config.js --coverage --watchAll=false", "test:coverage:ci": "jest --config config/jest.config.js --ci --coverage --watchAll=false && cat ./coverage/lcov.info | coveralls", "format": "prettier --write \"./**/*.{js,jsx,ts,tsx,css,scss,md}\"", "eslint": "eslint src/**/*.{ts,tsx}" }, "repository": { "type": "git", "url": "git+https://github.com/taniarascia/takenote" }, "keywords": [ "notes", "notes-app", "note-taking", "markdown", "markdown-editor", "redux", "react", "typescript", "react-hooks", "react-hooks-redux", "github" ], "bugs": { "url": "https://github.com/taniarascia/takenote/issues" }, "homepage": "https://takenote.dev", "husky": { "hooks": { "pre-commit": "lint-staged" } }, "lint-staged": { "**/*.{js,jsx,ts,tsx}": [ "eslint --fix" ], "**/*.{json,css,scss,md}": [ "prettier --write" ] }, "dependencies": { "@reduxjs/toolkit": "^1.4.0", "axios": "^0.21.1", "clipboard-polyfill": "^3.0.1", "codemirror": "^5.58.1", "compression": "^1.7.4", "cookie-parser": "^1.4.5", "cors": "^2.8.5", "dayjs": "^1.9.3", "express": "^4.17.1", "helmet": "^4.1.1", "jszip": "^3.5.0", "mousetrap": "^1.6.5", "mousetrap-global-bind": "^1.1.0", "path-browserify": "^1.0.1", "prettier": "^2.1.2", "process": "^0.11.10", "react": "^16.14.0", "react-beautiful-dnd": "^13.0.0", "react-codemirror2": "^7.2.1", "react-device-detect": "^1.14.0", "react-dom": "^16.14.0", "react-feather": "^2.0.8", "react-helmet-async": "^1.0.7", "react-markdown": "^4.3.1", "react-redux": "^7.2.1", "react-router-dom": "^5.2.0", "react-split-pane": "^0.1.92", "redux": "^4.0.5", "redux-saga": "^1.1.3", "stream-browserify": "^3.0.0", "unist-util-visit": "^2.0.3", "uuid": "^8.3.1" }, "devDependencies": { "@cypress/webpack-preprocessor": "^5.4.8", "@testing-library/cypress": "^7.0.1", "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@types/axios": "^0.14.0", "@types/codemirror": "0.0.98", "@types/compression": "^1.7.0", "@types/cookie-parser": "^1.4.2", "@types/cors": "^2.8.8", "@types/express": "^4.17.8", "@types/faker": "^5.1.2", "@types/helmet": "0.0.48", "@types/jest": "^26.0.14", "@types/jszip": "^3.4.1", "@types/lodash": "^4.14.162", "@types/node": "^14.11.8", "@types/prettier": "^2.1.3", "@types/react": "^16.9.52", "@types/react-beautiful-dnd": "^13.0.0", "@types/react-dom": "^16.9.8", "@types/react-helmet-async": "^1.0.3", "@types/react-redux": "^7.1.9", "@types/react-router": "^5.1.8", "@types/react-router-dom": "^5.1.6", "@types/testing-library__cypress": "^5.0.8", "@types/uuid": "^8.3.0", "@typescript-eslint/eslint-plugin": "^4.4.1", "@typescript-eslint/parser": "^4.4.1", "clean-webpack-plugin": "^3.0.0", "clipboardy": "^2.3.0", "concurrently": "^5.3.0", "copy-webpack-plugin": "^6.2.1", "coveralls": "^3.1.0", "cross-env": "^7.0.2", "css-loader": "^5.0.0", "css-minimizer-webpack-plugin": "^1.1.5", "cypress": "^5.4.0", "cypress-file-upload": "^4.1.1", "dotenv": "^8.2.0", "eslint": "^7.11.0", "eslint-config-prettier": "^6.13.0", "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-import": "^2.22.1", "eslint-plugin-prettier": "^3.1.4", "eslint-plugin-react": "^7.21.4", "faker": "^5.1.0", "html-webpack-plugin": "^5.0.0-alpha.7", "husky": "^4.3.0", "image-webpack-loader": "^7.0.1", "jest": "^26.5.3", "jest-extended": "^0.11.5", "jest-raw-loader": "^1.0.1", "lint-staged": "^10.4.1", "mini-css-extract-plugin": "^1.0.0", "node-sass": "^4.14.1", "nodemon": "^2.0.5", "sass-loader": "^10.0.3", "style-loader": "^2.0.0", "ts-jest": "^26.4.1", "ts-loader": "^8.0.5", "ts-node": "^9.0.0", "typescript": "^4.0.3", "webpack": "^5.1.3", "webpack-cli": "^4.0.0", "webpack-dev-server": "^3.11.0", "webpack-merge": "^5.2.0" } } ================================================ FILE: public/_redirects ================================================ /* /index.html 200 ================================================ FILE: public/manifest.json ================================================ { "short_name": "TakeNote", "name": "A web-based notes app for developers.", "icons": [ { "src": "favicon.ico", "sizes": "64x64 32x32 24x24 16x16", "type": "image/x-icon" }, { "src": "logo192.png", "type": "image/png", "sizes": "192x192" }, { "src": "logo512.png", "type": "image/png", "sizes": "512x512" } ], "start_url": ".", "display": "standalone", "theme_color": "#5183f5", "background_color": "#ffffff" } ================================================ FILE: public/robots.txt ================================================ User-agent: * ================================================ FILE: public/template.html ================================================ TakeNote <%= htmlWebpackPlugin.options.disableReactDevtools %>
================================================ FILE: seed.js ================================================ const categories = [ { id: 'goals', name: 'goals', }, { id: 'health', name: 'health', }, { id: 'design', name: 'design', }, { id: 'development', name: 'development', }, { id: 'personal', name: 'personal', }, { id: 'recipes', name: 'recipes', }, ] const notes = [ { id: 'e0196fd9-d644-4ca8-aa58-467b8082993e', text: '## How Strings are Indexed\n\nEach of the characters in a string correspond to an index number, starting with `0`.\n\nTo demonstrate, we will create a string with the value `How are you?`.\n\n| H | o | w | | a | r | e | | y | o | u | ? |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |', created: '2019-10-14T17:03:27-05:00', lastUpdated: '2019-10-14T17:03:27-05:00', category: '', favorite: false, }, { id: '6a2923a6-8fed-4277-9286-49125c91d876', text: "Writing a Simple MVC App in Plain JavaScript\n\n---\ndate: 2019-07-30\ntitle: 'Writing a Simple MVC App in Plain JavaScript'\ntemplate: post\nthumbnail: '../thumbnails/triangle.png'\nslug: javascript-mvc-todo-app\ncategories:\n - Popular\n - Code\ntags:\n - javascript\n - mvc\n - architecture\n---\n\nI wanted to write a simple application in plain JavaScript using the [model-view-controller](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller) architectural pattern. So I did, and here it is. Hopefully it helps you understand MVC, as it's a difficult concept to wrap your head around when you're first starting out.\n\nI made [this todo app](https://taniarascia.github.io/mvc), which is a simple little browser app that allows you to CRUD (create, read, update, and delete) todos. It just consists of an `index.html`, `style.css`, and `script.js`, so nice and simple and dependency/framework-free for learning purposes.\n\n#### Prerequisites\n\n- Basic JavaScript and HTML\n- Familiarity with [the latest JavaScript syntax](https://www.taniarascia.com/es6-syntax-and-feature-overview/)\n\n#### Goals\n\nCreate a todo app in the browser with plain JavaScript, and get familiar with the concepts of MVC (and OOP - object-oriented programming).\n\n- [View demo](https://taniarascia.github.io/mvc)\n- [View source](https://github.com/taniarascia/mvc)\n\n> **Note:** Since this app uses the latest JavaScript features (ES2017), it won't work as-is on some browsers like Safari without using Babel to compile to backwards-compatible JavaScript syntax.\n\n## What is Model View Controller?\n\nMVC is one possible pattern for organizing your code. It's a popular one.\n\n- **Model** - Manages the data of an application\n- **View** - A visual representation of the model\n- **Controller** - Links the user and the system\n\nThe **model** is the data. In this todo application, that'll be the actual todos, and the methods that will add, edit, or delete them.\n\nThe **view** is how the data is displayed. In this todo application, that will be the rendered HTML in the DOM and CSS.\n\nThe **controller** connects the model and the view. It takes user input, such as clicking or typing, and handles callbacks for user interactions.\n\nThe model never touches the view. The view never touches the model. The controller connects them.\n\n> I'd like to mention that doing MVC for a simple todo app is actually a ton of boilerplate. It would really be overcomplicating things if this was the app you wanted to create and you made this whole system. The point is to try to understand it on a small level so you can understand why a scaled system might use it.", created: '2019-10-14T17:01:39-05:00', lastUpdated: '2019-10-14T17:01:49-05:00', category: 'development', favorite: false, }, { id: 'a4c5ea72-34ed-496d-aa90-096df7e1ffbd', text: "# Create and Deploy a Node JS Server\n\nRecently, I wanted to create and host a Node server, and discovered that [Heroku](https://heroku.com) is an excellent cloud platform service that has free hobby hosting for Node and PostgreSQL, among many other languages and databases.\n\nThis tutorial walks through creating a local REST API with Node using an Express server and PostgreSQL database. It also lists the instructions for deploying to Heroku.\n\n#### Prerequisites\n\nThis guide uses installation instructions for macOS and assumes a prior knowledge of:\n\n- [Command line usage](/how-to-use-the-command-line-for-apple-macos-and-linux/)\n- [Basic JavaScript](/javascript-day-one/)\n- [Basic Node.js and npm](/how-to-install-and-use-node-js-and-npm-mac-and-windows/)\n- [SQL](/overview-of-sql-commands-and-pdo-operations/) and [PostgreSQL](https://blog.logrocket.com/setting-up-a-restful-api-with-node-js-and-postgresql-d96d6fc892d8/)\n- [Understanding REST/REST APIs](https://code.tutsplus.com/tutorials/code-your-first-api-with-nodejs-and-express-understanding-rest-apis--cms-31697)\n\n#### Goals\n\nThis walkthrough will have three parts:\n\n- [Setting up a local **PostgreSQL database**](#set-up-postgresql-database)\n- [Setting up a local **Node/Express API server**](#create-express-api)\n- [Deploying the Node, Express, PostgreSQL API to **Heroku**](#deploy-app-to-heroku)\n\nWe'll create a local, simple REST API in Node.js that runs on an Express server and utilizes PostgreSQL for a database. Then we'll deploy it to Heroku.\n\nI also have a few production tips for validation and rate limiting.\n\n- [4. Production tips](#production-tips)\n\n## Set Up PostgreSQL Database\n\nWe're going to:\n\n- Install PostgreSQL\n- Create a user\n- Create a database, table, and entry to the table\n\nThis will be a very quick runthrough - if it's your first time using PostgreSQL, or Express, I recommend reading [Setting up a RESTful API with Node.js and PostgreSQL](https://blog.logrocket.com/setting-up-a-restful-api-with-node-js-and-postgresql-d96d6fc892d8/).\n\nInstall and start PostgreSQL.\n\n```bash\nbrew install postgresql\nbrew services start postgresql\n```\n\nLogin to `postgres`.\n\n```bash\npsql postgres\n```\n\nCreate a user and password and give them create database access.\n\n```bash\nCREATE ROLE api_user WITH LOGIN PASSWORD 'password';\nALTER ROLE api_user CREATEDB;\n```\n\nLog out of the root user and log in to the newly created user.\n\n```bash\n\\q\npsql -d postgres -U api_user\n```\n\nCreate a `books_api` database and connect to it.\n\n```sql\nCREATE DATABASE books_api;\n\\c books_api\n```\n\nCreate a `books` table with `ID`, `author`, and `title`.\n\n```sql\nCREATE TABLE books (\n ID SERIAL PRIMARY KEY,\n author VARCHAR(255) NOT NULL,\n title VARCHAR(255) NOT NULL\n);\n```\n\nInsert one entry into the new table.\n\n```sql\nINSERT INTO books (author, title)\nVALUES ('J.K. Rowling', 'Harry Potter');\n```\n\n## Create Express API\n\nThe Express API will set up an Express server and route to two endpoints, `GET` and `POST`.\n\nCreate the following files:\n\n- `.env` - file containing environment variables (does not get version controlled)\n- `package.json` - information about the project and dependencies\n- `init.sql` - file to initialize PostgreSQL table\n- `config.js` - will create the database connection\n- `index.js` - the Express server\n\n```bash\ntouch .env package.json init.sql config.js index.js\n```", created: '2019-10-14T17:00:31-05:00', lastUpdated: '2019-10-14T17:00:55-05:00', category: '', favorite: false, trash: true, }, { id: '645fdc64-8511-469d-a2db-c7f04a36a9af', text: "# Roll Your Own Comment System\n\nA while ago, I [migrated my site from WordPress to Gatsby](/migrating-from-wordpress-to-gatsby/), a static site generator that runs on JavaScript/React. Gatsby [recommends Disqus](https://www.gatsbyjs.org/docs/adding-comments/) as an option for comments, and I briefly migrated all my comments over to it...until I looked at my site on a browser window without adblocker installed. I could see dozens of scripts injected into the site and even worse - truly egregious buzzfeed-esque ads embedded between all the comments. I decided it immediately had to go.\n\nI had no comments for a bit, but I felt like I had no idea what the reception of my articles was without having any place for people to leave comments. Occasionally people will leave useful critiques or tips on tutorials that can help future visitors as well, so I wanted to try adding something very simple back in.\n\nI looked at all the options, but I really didn't want to invest in setting up some third party code that I couldn't rely on, or something with ads. So I figured I'd set one up myself. I designed the simplest possible comment system in a day, which this blog now runs on.\n\nHere's some pros and cons to rolling your own comment system:\n\n#### Pros\n\n- Free\n- No ads\n- No third party scripts injected into your site\n- Complete control over functionality and design\n- Can be as simple or complicated as you want\n- Little to no spam because spambots aren't set up to spam your custom content\n- Easy to migrate - it all exists in one Heroku + Postgres server\n\n#### Cons\n\n- More work to set up\n- Less features\n\nIf you've also struggled with this and wondered if there could be an easier way, or are just intrigued to see one person's implementation, read on!\n ", created: '2019-10-14T16:58:09-05:00', lastUpdated: '2019-10-14T17:01:53-05:00', category: 'development', favorite: false, }, { id: 'b2808149-a40f-4f3c-83bc-94db34881241', text: "# Developer Blogs to Follow\n\nI recently discovered that I ended up on the Hacker Noon awards for [Personal Developer Blog of the Year 2019](https://hackernoon.com/personal-developer-blog-of-the-year-hacker-noon-noonies-awards-2019-hz2tu32ql), which is an amazing honor! I got third place. I thought that was pretty neat, so I figured I'd mention it. Thank you all for reading, subscribing, and sharing my content!\n\nIn 2017, [I wrote a list](/web-developers-and-bloggers-i-follow-2017/) of some bloggers I follow, though much of the list wasn't actually web development related. I have a few favorites blogs I keep an eye on right now, so I'll share them with you.\n\nEveryone on this list has their own personal website/blog that isn't hosted on some third party like Medium, most of them have no ads, and I think they're all cool people in general.\n\n## Robin Wieruch\n\n- [robinwieruch.de](https://www.robinwieruch.de/)\n\nChances are, if you're looking for something about Firebase or React/Redux, you've probably ended up on Robin's blog. With good reason - he has tons of great tutorials.\n\n## Khalil Stemmler\n\n- [khalilstemmler.com](https://khalilstemmler.com/)\n\nKhalil is filling an all-too-rare niche in web development blogs, which is how to build large scale applications properly, specifically with TypeScript and Node. He's bridging the gap between intermediate and advanced, which is a difficult area to cover. Check it out if you're looking for something beyond \"Hello, World\"!\n\n## Flavio Copes\n\n- [flaviocopes.com](https://flaviocopes.com/)\n\nNo one is more prolific than Flavio. I honestly don't know how he has time to breathe, much less write all these tutorials. Not only does he write a blog post _every single day_, but he has endless handbooks, courses, and tutorials. Some of the posts are more like snippets, but you'll find nice, succint helpful stuff on there.\n\n## Dan Abramov\n\n- [overreacted.io](https://overreacted.io/)\n\nIf you're into JavaScript, and especially React, I'm sure you already know and love our React Overlord, Dan Abramov. Dan is known for his amazing contributions to JavaScript - Create React App and Redux - and his blog is known for long, insightful posts that cover unique areas of JavaScript and development in general. His [Things I Don't Know](https://overreacted.io/things-i-dont-know-as-of-2018/) and [Things I Know](https://overreacted.io/the-elements-of-ui-engineering/) have inspired many spinoff articles, including my [Everything I Know as a Software Developer Without a Degree](/everything-i-know-as-a-software-developer-without-a-degree/) post.\n\n## Swyx", created: '2019-10-14T16:57:24-05:00', lastUpdated: '2019-10-14T16:57:42-05:00', category: '', favorite: true, }, { id: 'fa23b58e-2c2e-4c67-b6cd-4f7817ba7e89', text: "# This, Bind, Call, and Apply\n\nThe [`this`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this) keyword is a very important concept in JavaScript, and also a particularly confusing one to both new developers and those who have experience in other programming languages. In JavaScript, `this` is a reference to an object. The object that `this` refers to can vary, implicitly based on whether it is global, on an object, or in a constructor, and can also vary explicitly based on usage of the `Function` prototype methods `bind`, `call`, and `apply`.\n\nAlthough `this` is a bit of a complex topic, it is also one that appears as soon as you begin writing your first JavaScript programs. Whether you're trying to access an element or event in [the Document Object Model (DOM)](https://www.digitalocean.com/community/tutorial_series/understanding-the-dom-document-object-model), building classes for writing in the object-oriented programming style, or using the properties and methods of regular objects, you will encounter `this`.\n\nIn this article, you'll learn what `this` refers to implicitly based on context, and you'll learn how to use the `bind`, `call`, and `apply` methods to explicitly determine the value of `this`.\n\n## Implicit Context\n\nThere are four main contexts in which the value of `this` can be implicitly inferred:\n\n- the global context\n- as a method within an object\n- as a constructor on a function or class\n- as a DOM event handler\n\n### Global\n\nIn the global context, `this` refers to the [global object](https://developer.mozilla.org/en-US/docs/Glossary/Global_object). When you're working in a browser, the global context is would be `window`. When you're working in Node.js, the global context is `global`.\n\n> **Note:** If you are not yet familiar with the concept of scope in JavaScript, please review [Understanding Variables, Scope, and Hoisting in JavaScript](/understanding-variables-scope-hoisting-in-javascript).\n\nFor the examples, you will practice the code in the browser's Developer Tools console. Read [How to Use the JavaScript Developer Console](/how-to-use-the-javascript-developer-console) if you are not familiar with running JavaScript code in the browser.\n\nIf you log the value of `this` without any other code, you will see what object `this` refers to.\n\n```js\nconsole.log(this)\n```\n\n```terminal\nWindow {postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, parent: Window, …}\n```\n\nYou can see that `this` is `window`, which is the global object of a browser.\n\nIn [Understanding Variables, Scope, and Hoisting in JavaScript](/understanding-variables-scope-hoisting-in-javascript), you learned that functions have their own context for variables. You might be tempted to think that `this` would follow the same rules inside a function, but it does not. A top-level function will still retain the `this` reference of the global object.\n\nYou write a top-level function, or a function that is not associated with any object, like this:\n\n```js\nfunction printThis() {\n console.log(this)\n}", created: '2019-10-14T16:54:08-05:00', lastUpdated: '2019-10-14T16:57:48-05:00', category: '', favorite: false, }, ] localStorage.setItem('categories', JSON.stringify(categories)) localStorage.setItem('notes', JSON.stringify(notes)) window.location.reload() ================================================ FILE: src/client/api/index.ts ================================================ import { v4 as uuid } from 'uuid' import dayjs from 'dayjs' import { NoteItem, SyncPayload, SettingsState } from '@/types' const scratchpadNote = { id: uuid(), text: `# Scratchpad The easiest note to find.`, category: '', scratchpad: true, favorite: false, created: dayjs().format(), lastUpdated: dayjs().format(), } const markdown = `# Welcome to Takenote! TakeNote is a free, open-source notes app for the web. It is a demo project only, and does not integrate with any database or cloud. Your notes are saved in local storage and will not be permanently persisted, but are available for download. View the source on [Github](https://github.com/taniarascia/takenote). ## Features - **Plain text notes** - take notes in an IDE-like environment that makes no assumptions - **Markdown preview** - view rendered HTML - **Linked notes** - use \`{{uuid}}\` syntax to link to notes within other notes - **Syntax highlighting** - light and dark mode available (based on the beautiful [New Moon theme](https://taniarascia.github.io/new-moon/)) - **Keyboard shortcuts** - use the keyboard for all common tasks - creating notes and categories, toggling settings, and other options - **Drag and drop** - drag a note or multiple notes to categories, favorites, or trash - **Multi-cursor editing** - supports multiple cursors and other [Codemirror](https://codemirror.net/) options - **Search notes** - easily search all notes, or notes within a category - **Prettify notes** - use Prettier on the fly for your Markdown - **No WYSIWYG** - made for developers, by developers - **No database** - notes are only stored in the browser's local storage and are available for download and export to you alone - **No tracking or analytics** - 'nuff said - **GitHub integration** - self-hosted option is available for auto-syncing to a GitHub repository (not available in the demo) ` const welcomeNote = { id: uuid(), text: markdown, category: '', favorite: false, created: dayjs().format(), lastUpdated: dayjs().format(), } type PromiseCallback = (value?: any) => void type GetLocalStorage = ( key: string, errorMessage?: string ) => (resolve: PromiseCallback, reject: PromiseCallback) => void const getLocalStorage: GetLocalStorage = (key, errorMessage = 'Something went wrong') => ( resolve, reject ) => { const data = localStorage.getItem(key) if (data) { resolve(JSON.parse(data)) } else { reject({ message: errorMessage, }) } } const getUserNotes = () => (resolve: PromiseCallback, reject: PromiseCallback) => { const notes: any = localStorage.getItem('notes') // check if there is any data in localstorage if (!notes) { // if there is none (i.e. new user), create the welcomeNote and scratchpadNote resolve([scratchpadNote, welcomeNote]) } else if (Array.isArray(JSON.parse(notes))) { // if there is (existing user), show the user's notes resolve( // find does not work if the array is empty. JSON.parse(notes).length === 0 || !JSON.parse(notes).find((note: NoteItem) => note.scratchpad) ? [scratchpadNote, ...JSON.parse(notes)] : JSON.parse(notes) ) } else { reject({ message: 'Something went wrong', }) } } export const saveState = ({ categories, notes }: SyncPayload) => new Promise((resolve) => { localStorage.setItem('categories', JSON.stringify(categories)) localStorage.setItem('notes', JSON.stringify(notes)) resolve({ categories: JSON.parse(localStorage.getItem('categories') || '[]'), notes: JSON.parse(localStorage.getItem('notes') || '[]'), }) }) export const saveSettings = ({ isOpen, ...settings }: SettingsState) => Promise.resolve(localStorage.setItem('settings', JSON.stringify(settings))) export const requestNotes = () => new Promise(getUserNotes()) export const requestCategories = () => new Promise(getLocalStorage('categories')) export const requestSettings = () => new Promise(getLocalStorage('settings')) ================================================ FILE: src/client/components/AppSidebar/ActionButton.tsx ================================================ import React, { MouseEventHandler } from 'react' import { Icon } from 'react-feather' import { iconColor } from '@/utils/constants' export interface ActionButtonProps { dataTestID: string disabled?: boolean handler: MouseEventHandler icon: Icon label: string text: string } export const ActionButton: React.FC = ({ dataTestID, disabled = false, handler, icon: IconCmp, label, text, }) => { return ( ) } ================================================ FILE: src/client/components/AppSidebar/AddCategoryButton.tsx ================================================ import React from 'react' import { Plus } from 'react-feather' import { iconColor } from '@/utils/constants' export interface AddCategoryButtonProps { dataTestID: string handler: (adding: boolean) => void label: string } export const AddCategoryButton: React.FC = ({ dataTestID, handler, label, }) => { return ( ) } ================================================ FILE: src/client/components/AppSidebar/AddCategoryForm.tsx ================================================ import React from 'react' import { TestID } from '@resources/TestID' import { ReactSubmitEvent } from '@/types' export interface AddCategoryFormProps { dataTestID: string submitHandler: (event: ReactSubmitEvent) => void changeHandler: (editingCategoryId: string, value: string) => void resetHandler: () => void editingCategoryId: string tempCategoryName: string } export const AddCategoryForm: React.FC = ({ dataTestID, submitHandler, changeHandler, resetHandler, editingCategoryId, tempCategoryName, }) => { return (
{ changeHandler(editingCategoryId, event.target.value) }} onBlur={(event) => { if (!tempCategoryName || tempCategoryName.trim() === '') { resetHandler() } else { submitHandler(event) } }} />
) } ================================================ FILE: src/client/components/AppSidebar/CollapseCategoryButton.tsx ================================================ import React from 'react' import { ChevronDown, ChevronRight, Layers } from 'react-feather' export interface CollapseCategoryListButton { dataTestID: string handler: () => void label: string isCategoryListOpen: boolean showIcon: boolean } export const CollapseCategoryListButton: React.FC = ({ dataTestID, handler, label, isCategoryListOpen, showIcon, }) => { return ( ) } ================================================ FILE: src/client/components/AppSidebar/FolderOption.tsx ================================================ import React, { useState } from 'react' import { Book, Star, Trash2 } from 'react-feather' import { Folder } from '@/utils/enums' import { iconColor } from '@/utils/constants' import { ReactDragEvent } from '@/types' export interface FolderOptionProps { text: string active: boolean dataTestID: string folder: Folder swapFolder: (folder: Folder) => void addNoteType: (noteId: string) => void } export const FolderOption: React.FC = ({ text, active, dataTestID, folder, swapFolder, addNoteType, }) => { const [mainSectionDragState, setMainSectionDragState] = useState({ [Folder.ALL]: false, [Folder.FAVORITES]: false, [Folder.SCRATCHPAD]: false, [Folder.TRASH]: false, [Folder.CATEGORY]: false, }) const dragEnterHandler = () => { setMainSectionDragState({ ...mainSectionDragState, [folder]: true }) } const dragLeaveHandler = () => { setMainSectionDragState({ ...mainSectionDragState, [folder]: false }) } const noteHandler = (event: ReactDragEvent) => { event.preventDefault() addNoteType(event.dataTransfer.getData('text')) dragLeaveHandler() } const determineClass = () => { if (active) { return 'app-sidebar-link active' } else if (mainSectionDragState[folder]) { return 'app-sidebar-link dragged-over' } else { return 'app-sidebar-link' } } const renderIcon = () => { if (folder === 'FAVORITES') { return } else if (folder === 'ALL') { return } else { return } } return ( ) } ================================================ FILE: src/client/components/AppSidebar/ScratchpadOption.tsx ================================================ import React from 'react' import { Edit } from 'react-feather' import { TestID } from '@resources/TestID' import { LabelText } from '@resources/LabelText' import { Folder } from '@/utils/enums' import { iconColor } from '@/utils/constants' export interface ScratchpadOptionProps { active: boolean swapFolder: (folder: Folder) => {} } export const ScratchpadOption: React.FC = ({ active, swapFolder }) => { return ( ) } ================================================ FILE: src/client/components/Editor/EmptyEditor.tsx ================================================ import React from 'react' export const EmptyEditor: React.FC = () => { return (

Create a note

CTRL + ALT + N

) } ================================================ FILE: src/client/components/Editor/NoteLink.tsx ================================================ import React from 'react' import { NoteItem } from '@/types' import { Errors } from '@/utils/enums' import { TestID } from '@resources/TestID' import { getNoteTitle, getActiveNoteFromShortUuid } from '../../utils/helpers' export interface NoteLinkProps { uuid: string notes: NoteItem[] handleNoteLinkClick: (e: React.SyntheticEvent, note: NoteItem) => void } const NoteLink: React.FC = ({ notes, uuid, handleNoteLinkClick }) => { const note = getActiveNoteFromShortUuid(notes, uuid) const title = note !== undefined ? getNoteTitle(note.text) : null if (note && title) return ( handleNoteLinkClick(e, note)}> {title} ) return ( {Errors.INVALID_LINKED_NOTE_ID} ) } export default NoteLink ================================================ FILE: src/client/components/Editor/PreviewEditor.tsx ================================================ import React from 'react' import ReactMarkdown from 'react-markdown' import { useDispatch } from 'react-redux' import { Folder } from '@/utils/enums' import { updateActiveNote, updateSelectedNotes, pruneNotes, swapFolder } from '@/slices/note' import { NoteItem } from '@/types' import { uuidPlugin } from '../../utils/reactMarkdownPlugins' import NoteLink from './NoteLink' export interface PreviewEditorProps { noteText: string directionText: string notes: NoteItem[] } export const PreviewEditor: React.FC = ({ noteText, directionText, notes }) => { // =========================================================================== // Dispatch // =========================================================================== const dispatch = useDispatch() const _updateSelectedNotes = (noteId: string, multiSelect: boolean) => dispatch(updateSelectedNotes({ noteId, multiSelect })) const _updateActiveNote = (noteId: string, multiSelect: boolean) => dispatch(updateActiveNote({ noteId, multiSelect })) const _pruneNotes = () => dispatch(pruneNotes()) const _swapFolder = (folder: Folder) => dispatch(swapFolder({ folder })) // =========================================================================== // Handlers // =========================================================================== const handleNoteLinkClick = (e: React.SyntheticEvent, note: NoteItem) => { e.preventDefault() if (note) { _updateActiveNote(note.id, false) _updateSelectedNotes(note.id, false) _pruneNotes() if (note?.favorite) return _swapFolder(Folder.FAVORITES) if (note?.scratchpad) return _swapFolder(Folder.SCRATCHPAD) if (note?.trash) return _swapFolder(Folder.TRASH) return _swapFolder(Folder.ALL) } } const returnNoteLink = (value: string) => { return } return ( returnNoteLink(value), }} linkTarget="_blank" className={`previewer previewer_direction-${directionText}`} source={noteText} /> ) } ================================================ FILE: src/client/components/LandingPage.tsx ================================================ import React from 'react' import { isMobile } from 'react-device-detect' import lightScreen from '@resources/assets/screenshot-light.png' import darkScreen from '@resources/assets/screenshot-dark.png' import squareLogo from '@resources/assets/logo-square-white.svg' import logo from '@resources/assets/logo-square-color.svg' import githubLogo from '@resources/assets/github-logo.png' const clientId = process.env.CLIENT_ID const isDemo = process.env.DEMO const loginButton = (text: string) => ( {text} ) export const LandingPage: React.FC = () => { return (
TakeNote

The Note Taking App
for Developers

A web-based notes app for developers.

{isMobile ? (

TakeNote is not currently supported for tablet and mobile devices.

) : isDemo ? (

TakeNote is only available as a demo. Your notes will be saved to local storage and not persisted in any database or cloud.

View Demo
) : (

TakeNote does not have a database or users. It simply links with your GitHub account for authentication, and stores the data in a private{' '} takenotes-data repo.

{loginButton('Sign Up with GitHub')}
)}
TakeNote App

Features

  • Plain text notes - take notes in an IDE-like environment that makes no assumptions
  • Markdown preview - view rendered HTML
  • Linked notes - use {`{{uuid}}`} syntax to link to notes within other notes
  • Syntax highlighting - light and dark mode available (based on the beautiful New Moon theme)
  • Keyboard shortcuts - use the keyboard for all common tasks - creating notes and categories, toggling settings, and other options
  • Drag and drop - drag a note or multiple notes to categories, favorites, or trash
  • Multi-cursor editing - supports multiple cursors and other{' '} Codemirror options
  • Search notes - easily search all notes, or notes within a category
  • Prettify notes - use Prettier on the fly for your Markdown
  • No WYSIWYG - made for developers, by developers
  • No database - notes are only stored in the browser's local storage and are available for download and export to you alone
  • No tracking or analytics - 'nuff said
  • GitHub integration - self-hosted option is available for auto-syncing to a GitHub repository (not available in the demo)
TakeNote App
) } ================================================ FILE: src/client/components/LastSyncedNotification.tsx ================================================ import React from 'react' import dayjs from 'dayjs' import { TestID } from '@resources/TestID' export interface LastSyncedNotificationProps { datetime: string pending: boolean syncing: boolean } export const LastSyncedNotification: React.FC = ({ datetime, pending, syncing, }) => { const renderLastSynced = () => { if (syncing) { return Syncing... } if (pending) { return Unsaved changes } if (datetime) { return ( {dayjs(datetime).format('LT on L')} ) } } return
{renderLastSynced()}
} ================================================ FILE: src/client/components/NoteList/ContextMenuOption.tsx ================================================ import React, { KeyboardEventHandler, MouseEventHandler, useContext } from 'react' import { Icon } from 'react-feather' import { MenuUtilitiesContext } from '@/containers/ContextMenu' export interface ContextMenuOptionProps { dataTestID: string handler: MouseEventHandler & KeyboardEventHandler icon: Icon text: string optionType?: string } export const ContextMenuOption: React.FC = ({ dataTestID, handler, optionType, icon: IconCmp, text, ...rest }) => { // =========================================================================== // Context // =========================================================================== const { setOptionsId } = useContext(MenuUtilitiesContext) // =========================================================================== // Handlers // =========================================================================== const optionHandler: MouseEventHandler & KeyboardEventHandler = ( event: React.MouseEvent & React.KeyboardEvent ) => { handler(event) setOptionsId('') } return (
{text}
) } ================================================ FILE: src/client/components/NoteList/NoteListButton.tsx ================================================ import React, { MouseEventHandler } from 'react' export interface NoteListButtonProps { dataTestID: string disabled?: boolean handler: MouseEventHandler label: string } export const NoteListButton: React.FC = ({ dataTestID, disabled = false, handler, label, }) => { return ( ) } ================================================ FILE: src/client/components/NoteList/SearchBar.tsx ================================================ import React from 'react' import { TestID } from '@resources/TestID' export interface SearchBarProps { searchRef: React.MutableRefObject searchNotes: (searchValue: string) => void } export const SearchBar: React.FC = ({ searchRef, searchNotes }) => { return ( { event.preventDefault() searchNotes(event.target.value) }} placeholder="Search for notes" onDragOver={(e) => { e.preventDefault() }} /> ) } ================================================ FILE: src/client/components/NoteList/SelectCategory.tsx ================================================ import React from 'react' import { TestID } from '@resources/TestID' import { NoteItem, CategoryItem } from '@/types' import { isDraftNote } from '@/utils/helpers' export interface SelectCategoryProps { onChange: (selectedOption: any) => void categories: CategoryItem[] note: NoteItem activeCategoryId: string } export const SelectCategory: React.FC = ({ onChange, categories, note, activeCategoryId, }) => { const filteredCategories = categories .filter(({ id }) => id !== activeCategoryId) .filter((category) => category.id !== note.category) return ( <> {!note.trash && !isDraftNote(note) && filteredCategories.length > 0 && ( )} ) } ================================================ FILE: src/client/components/Select.tsx ================================================ import React from 'react' export interface SelectOption { label: string value: string } export interface SelectProps { options: SelectOption[] onChange: (selectedOption: SelectOption) => void selectedValue: string testId: string } export const Select: React.FC = ({ options, selectedValue, onChange, testId }) => { const getSelectedOption = (options: SelectOption[], value: string): SelectOption => { return options.filter((option: SelectOption) => { return value === option.value })[0] } return ( ) } ================================================ FILE: src/client/components/SettingsModal/IconButton.tsx ================================================ import React, { MouseEventHandler } from 'react' import { Icon } from 'react-feather' import { iconColor } from '@/utils/constants' export interface IconButtonProps { dataTestID?: string disabled?: boolean handler: MouseEventHandler icon: Icon text: string } export const IconButton: React.FC = ({ dataTestID, disabled = false, handler, icon: IconCmp, text, }) => { return ( ) } ================================================ FILE: src/client/components/SettingsModal/IconButtonUploader.tsx ================================================ import React, { ChangeEvent, useRef } from 'react' import { Icon } from 'react-feather' import { iconColor } from '@/utils/constants' export interface IconButtonUploaderProps { dataTestID?: string disabled?: boolean handler: (file: File) => void icon: Icon text: string accept: string } export const IconButtonUploader: React.FC = ({ dataTestID, disabled = false, handler, icon: IconCmp, text, accept, }) => { const inputRef = useRef(null) const handleClick = () => { if (inputRef.current) { inputRef.current.click() } } const handleFileInput = (e: ChangeEvent) => { if (e.target.files) { handler(e.target.files[0]) } } return (
) } ================================================ FILE: src/client/components/SettingsModal/Option.tsx ================================================ import React from 'react' import { Switch } from '@/components/Switch' export interface OptionProps { title: string description: string toggle: () => void checked: boolean testId: string } export const Option: React.FC = ({ title, description, toggle, checked, testId }) => { return (

{title}

{description}

) } ================================================ FILE: src/client/components/SettingsModal/SelectOptions.tsx ================================================ import React from 'react' import { Select } from '../Select' export interface OptionProps { title: string description: string onChange: (selectedOption: any) => void selectedValue: string options: Array<{ value: string; label: string }> testId: string } export const SelectOptions: React.FC = ({ title, description, onChange, selectedValue, options, testId, }) => { return (

{title}

{description}

) } ================================================ FILE: src/client/components/Tabs/Tab.tsx ================================================ import React from 'react' import { Icon } from 'react-feather' export interface TabProps { label: string activeTab: string onClick: (label: string) => void icon: Icon } export const Tab: React.FC = ({ activeTab, label, icon: IconCmp, onClick }) => { const className = activeTab === label ? 'tab active' : 'tab' return (
onClick(label)}>
) } ================================================ FILE: src/client/components/Tabs/TabPanel.tsx ================================================ import React from 'react' import { Icon } from 'react-feather' export interface TabPanelProps { label: string icon: Icon children: JSX.Element[] | JSX.Element } export const TabPanel: React.FC = ({ children }) => { return
{children}
} ================================================ FILE: src/client/components/Tabs/Tabs.tsx ================================================ import React, { Fragment, useState } from 'react' import { Tab } from './Tab' export interface TabsProps { children: JSX.Element[] } export const Tabs: React.FC = ({ children }) => { const [activeTab, setActiveTab] = useState('Preferences') return (
{children.map((child) => { if (child.props.label !== activeTab) return return (

{child.props.label}

{child.props.children}
) })}
) } ================================================ FILE: src/client/containers/App.tsx ================================================ import React, { useEffect } from 'react' import { useDispatch, useSelector } from 'react-redux' import { Route, Switch, Redirect } from 'react-router-dom' import { Helmet, HelmetProvider } from 'react-helmet-async' import { LandingPage } from '@/components/LandingPage' import { TakeNoteApp } from '@/containers/TakeNoteApp' import { PublicRoute } from '@/router/PublicRoute' import { PrivateRoute } from '@/router/PrivateRoute' import { getAuth } from '@/selectors' import { login } from '@/slices/auth' const isDemo = process.env.DEMO export const App: React.FC = () => { // =========================================================================== // Selectors // =========================================================================== const { loading } = useSelector(getAuth) // =========================================================================== // Dispatch // =========================================================================== const dispatch = useDispatch() const _login = () => dispatch(login()) // =========================================================================== // Hooks // =========================================================================== useEffect(() => { _login() }, []) if (loading) { return (
) } return ( TakeNote {isDemo ? ( <> ) : ( <> )} ) } ================================================ FILE: src/client/containers/AppSidebar.tsx ================================================ import React from 'react' import { Plus } from 'react-feather' import { useDispatch, useSelector } from 'react-redux' import { LabelText } from '@resources/LabelText' import { TestID } from '@resources/TestID' import { ActionButton } from '@/components/AppSidebar/ActionButton' import { FolderOption } from '@/components/AppSidebar/FolderOption' import { ScratchpadOption } from '@/components/AppSidebar/ScratchpadOption' import { Folder, NotesSortKey } from '@/utils/enums' import { CategoryList } from '@/containers/CategoryList' import { addNote, swapFolder, updateActiveNote, assignFavoriteToNotes, assignTrashToNotes, updateSelectedNotes, unassignTrashFromNotes, } from '@/slices/note' import { togglePreviewMarkdown } from '@/slices/settings' import { getSettings, getNotes } from '@/selectors' import { NoteItem } from '@/types' import { newNoteHandlerHelper, getActiveNote } from '@/utils/helpers' export const AppSidebar: React.FC = () => { // =========================================================================== // Selectors // =========================================================================== const { activeCategoryId, activeFolder, activeNoteId, notes } = useSelector(getNotes) const { previewMarkdown, notesSortKey } = useSelector(getSettings) const activeNote = getActiveNote(notes, activeNoteId) // =========================================================================== // Dispatch // =========================================================================== const dispatch = useDispatch() const _addNote = (note: NoteItem) => dispatch(addNote(note)) const _updateActiveNote = (noteId: string, multiSelect: boolean) => dispatch(updateActiveNote({ noteId, multiSelect })) const _updateSelectedNotes = (noteId: string, multiSelect: boolean) => dispatch(updateSelectedNotes({ noteId, multiSelect })) const _swapFolder = (sortOrderKey: NotesSortKey) => (folder: Folder) => dispatch(swapFolder({ folder, sortOrderKey })) const _togglePreviewMarkdown = () => dispatch(togglePreviewMarkdown()) const _assignTrashToNotes = (noteId: string) => dispatch(assignTrashToNotes(noteId)) const _unassignTrashFromNotes = (noteId: string) => dispatch(unassignTrashFromNotes(noteId)) const _assignFavoriteToNotes = (noteId: string) => dispatch(assignFavoriteToNotes(noteId)) // =========================================================================== // Handlers // =========================================================================== const newNoteHandler = () => newNoteHandlerHelper( activeFolder, previewMarkdown, activeNote, activeCategoryId, swapFolderHandler, _togglePreviewMarkdown, _addNote, _updateActiveNote, _updateSelectedNotes ) const swapFolderHandler = _swapFolder(notesSortKey) return ( ) } ================================================ FILE: src/client/containers/CategoryList.tsx ================================================ import React, { useRef, useState, useEffect } from 'react' import { useSelector, useDispatch } from 'react-redux' import { v4 as uuid } from 'uuid' import { Droppable } from 'react-beautiful-dnd' import { LabelText } from '@resources/LabelText' import { TestID } from '@resources/TestID' import { CategoryOption } from '@/containers/CategoryOption' import { getCategories } from '@/selectors' import { shouldOpenContextMenu } from '@/utils/helpers' import { ReactMouseEvent, ReactSubmitEvent, CategoryItem } from '@/types' import { useTempState } from '@/contexts/TempStateContext' import { setCategoryEdit, updateCategory, addCategory } from '@/slices/category' import { AddCategoryForm } from '@/components/AppSidebar/AddCategoryForm' import { AddCategoryButton } from '@/components/AppSidebar/AddCategoryButton' import { CollapseCategoryListButton } from '@/components/AppSidebar/CollapseCategoryButton' export const CategoryList: React.FC = () => { // =========================================================================== // Selectors // =========================================================================== const { categories, editingCategory: { id: editingCategoryId, tempName: tempCategoryName }, } = useSelector(getCategories) // =========================================================================== // Dispatch // =========================================================================== const dispatch = useDispatch() const _setCategoryEdit = (categoryId: string, tempName: string) => dispatch(setCategoryEdit({ id: categoryId, tempName })) const _updateCategory = (category: CategoryItem) => dispatch(updateCategory(category)) const _addCategory = (category: CategoryItem) => dispatch(addCategory(category)) // =========================================================================== // Refs // =========================================================================== const contextMenuRef = useRef(null) // =========================================================================== // State // =========================================================================== const [optionsId, setOptionsId] = useState('') const [optionsPosition, setOptionsPosition] = useState({ x: 0, y: 0 }) const [isCategoryListOpen, setCategoryListOpen] = useState(true) // =========================================================================== // Context // =========================================================================== const { addingTempCategory, setAddingTempCategory } = useTempState() // =========================================================================== // Handlers // =========================================================================== const onAddCategory = (adding: boolean) => { setCategoryListOpen(true) setAddingTempCategory(adding) } const handleCategoryMenuClick = ( event: React.MouseEvent | ReactMouseEvent, categoryId: string = '' ) => { const clicked = event.target // Make sure we aren't getting any null values .. any element clicked should be a sub-class of element if (!clicked) return if (shouldOpenContextMenu(clicked as Element)) { if ('clientX' in event && 'clientY' in event) { setOptionsPosition({ x: event.clientX, y: event.clientY }) } } event.stopPropagation() if (!contextMenuRef?.current?.contains(clicked as HTMLDivElement)) { setOptionsId(!optionsId || optionsId !== categoryId ? categoryId : '') } } const handleCategoryRightClick = ( event: React.MouseEvent | ReactMouseEvent, categoryId: string = '' ) => { event.preventDefault() handleCategoryMenuClick(event, categoryId) } const resetTempCategory = () => { setAddingTempCategory(false) _setCategoryEdit('', '') } const onSubmitUpdateCategory = (event: ReactSubmitEvent): void => { event.preventDefault() const category = { id: editingCategoryId, name: tempCategoryName.trim(), draggedOver: false } if (categories.find((cat) => cat.name === category.name) || category.name === '') { resetTempCategory() } else { _updateCategory(category) resetTempCategory() } } const onSubmitNewCategory = (event: ReactSubmitEvent): void => { event.preventDefault() const category = { id: uuid(), name: tempCategoryName.trim(), draggedOver: false } if (categories.find((cat) => cat.name === category.name) || category.name === '') { resetTempCategory() } else { _addCategory(category) resetTempCategory() } } // =========================================================================== // Hooks // =========================================================================== useEffect(() => { document.addEventListener('mousedown', handleCategoryMenuClick) return () => { document.removeEventListener('mousedown', handleCategoryMenuClick) } }) return ( <>
setCategoryListOpen(!isCategoryListOpen)} label={LabelText.COLLAPSE_CATEGORY} isCategoryListOpen={isCategoryListOpen} showIcon={categories.length > 0} />
{isCategoryListOpen && ( <> {(droppableProvided) => (
{categories.map((category, index) => ( ))} {droppableProvided.placeholder}
)}
{addingTempCategory && ( )} )} ) } ================================================ FILE: src/client/containers/CategoryOption.tsx ================================================ import React from 'react' import { useSelector, useDispatch } from 'react-redux' import { Draggable } from 'react-beautiful-dnd' import { Folder as FolderIcon, MoreHorizontal } from 'react-feather' import { TestID } from '@resources/TestID' import { CategoryItem, ReactDragEvent, ReactMouseEvent, ReactSubmitEvent } from '@/types' import { determineCategoryClass } from '@/utils/helpers' import { getNotes, getCategories, getSettings } from '@/selectors' import { updateActiveCategoryId, updateActiveNote, updateSelectedNotes, addCategoryToNote, } from '@/slices/note' import { setCategoryEdit, categoryDragLeave, categoryDragEnter } from '@/slices/category' import { iconColor } from '@/utils/constants' import { ContextMenuEnum } from '@/utils/enums' import { getNotesSorter } from '@/utils/notesSortStrategies' import { ContextMenu } from '@/containers/ContextMenu' interface CategoryOptionProps { category: CategoryItem index: number contextMenuRef: React.RefObject handleCategoryMenuClick: ( event: React.MouseEvent | ReactMouseEvent, categoryId?: string ) => void handleCategoryRightClick: ( event: React.MouseEvent | ReactMouseEvent, categoryId?: string ) => void onSubmitUpdateCategory: (event: ReactSubmitEvent) => void optionsPosition: { x: number; y: number } optionsId: string setOptionsId: React.Dispatch> } export const CategoryOption: React.FC = ({ category, index, contextMenuRef, handleCategoryMenuClick, handleCategoryRightClick, onSubmitUpdateCategory, optionsPosition, optionsId, setOptionsId, }) => { // =========================================================================== // Selectors // =========================================================================== const { activeCategoryId, notes } = useSelector(getNotes) const { editingCategory: { id: editingCategoryId, tempName: tempCategoryName }, } = useSelector(getCategories) const { notesSortKey } = useSelector(getSettings) // =========================================================================== // Dispatch // =========================================================================== const dispatch = useDispatch() const _updateActiveCategoryId = (categoryId: string) => dispatch(updateActiveCategoryId(categoryId)) const _updateActiveNote = (noteId: string, multiSelect: boolean) => dispatch(updateActiveNote({ noteId, multiSelect })) const _updateSelectedNotes = (noteId: string, multiSelect: boolean) => dispatch(updateSelectedNotes({ noteId, multiSelect })) const _setCategoryEdit = (categoryId: string, tempName: string) => dispatch(setCategoryEdit({ id: categoryId, tempName })) const _addCategoryToNote = (categoryId: string, noteId: string) => dispatch(addCategoryToNote({ categoryId, noteId })) const _categoryDragEnter = (category: CategoryItem) => dispatch(categoryDragEnter(category)) const _categoryDragLeave = (category: CategoryItem) => dispatch(categoryDragLeave(category)) return ( {(draggableProvided, snapshot) => (
{ const notesForNewCategory = notes .filter((note) => !note.trash && note.category === category.id) .sort(getNotesSorter(notesSortKey)) const defaultActiveNoteId = notesForNewCategory.length > 0 ? notesForNewCategory[0].id : '' if (category.id !== activeCategoryId) { _updateActiveCategoryId(category.id) _updateActiveNote(defaultActiveNoteId, false) _updateSelectedNotes(defaultActiveNoteId, false) } }} onDoubleClick={() => { _setCategoryEdit(category.id, category.name) }} onBlur={() => { _setCategoryEdit('', '') }} onDrop={(event) => { event.preventDefault() _addCategoryToNote(category.id, event.dataTransfer.getData('text')) _categoryDragLeave(category) }} onDragOver={(event: ReactDragEvent) => event.preventDefault()} onDragEnter={() => _categoryDragEnter(category)} onDragLeave={() => _categoryDragLeave(category)} onContextMenu={(event) => handleCategoryRightClick(event, category.id)} >
{ event.preventDefault() _setCategoryEdit('', '') onSubmitUpdateCategory(event) if (optionsId) setOptionsId('') }} > {editingCategoryId === category.id ? ( { _setCategoryEdit(editingCategoryId, event.target.value) }} onBlur={(event) => onSubmitUpdateCategory(event)} /> ) : ( category.name )}
handleCategoryMenuClick(event, category.id)} >
{optionsId === category.id && ( )}
)}
) } ================================================ FILE: src/client/containers/ContextMenu.tsx ================================================ import ReactDOM from 'react-dom' import React, { useEffect, useState, createContext } from 'react' import { useDispatch, useSelector } from 'react-redux' import { SelectCategory } from '@/components/NoteList/SelectCategory' import { ContextMenuOptions } from '@/containers/ContextMenuOptions' import { addCategoryToNote, updateActiveCategoryId, updateActiveNote } from '@/slices/note' import { NoteItem, CategoryItem } from '@/types' import { getNotes, getCategories, getSettings } from '@/selectors' import { ContextMenuEnum } from '@/utils/enums' import { isDraftNote } from '@/utils/helpers' export const MenuUtilitiesContext = createContext({ setOptionsId: (id: string) => {}, }) interface Position { x: number y: number } export interface ContextMenuProps { item: NoteItem | CategoryItem optionsPosition: Position contextMenuRef: React.RefObject | null setOptionsId: (id: string) => void type: ContextMenuEnum } export const ContextMenu: React.FC = ({ item, optionsPosition, contextMenuRef, setOptionsId, type, }) => { // =========================================================================== // Selectors // =========================================================================== const { darkTheme } = useSelector(getSettings) // =========================================================================== // State // =========================================================================== const [elementDimensions, setElementDimensions] = useState<{ offsetHeight: number | null offsetWidth: number | null }>({ offsetHeight: null, offsetWidth: null }) // =========================================================================== // Hooks // =========================================================================== useEffect(() => { if (contextMenuRef?.current) { const { offsetHeight, offsetWidth } = contextMenuRef.current setElementDimensions({ offsetHeight, offsetWidth }) } }, [contextMenuRef]) // =========================================================================== // Other // =========================================================================== const contextValues = { setOptionsId, } const getOptionsYPosition = (): Number => { if (elementDimensions.offsetHeight || elementDimensions.offsetWidth) { // get the max window frame const MaxY = window.innerHeight const optionsSize = elementDimensions.offsetHeight as number // if window position - noteOptions position isn't bigger than options, flip it. return MaxY - optionsPosition.y > optionsSize ? optionsPosition.y : optionsPosition.y - optionsSize } return 0 } return ReactDOM.createPortal(
{ event.stopPropagation() }} > {type === ContextMenuEnum.CATEGORY ? ( ) : ( )}
, document.getElementById('context-menu') as HTMLElement ) } interface CategoryMenuProps { category: CategoryItem } const CategoryMenu: React.FC = ({ category }) => { return } interface NotesMenuProps { note: NoteItem setOptionsId: (id: string) => void } const NotesMenu: React.FC = ({ note, setOptionsId }) => { // =========================================================================== // Selectors // =========================================================================== const { categories } = useSelector(getCategories) const { activeCategoryId } = useSelector(getNotes) // =========================================================================== // Dispatch // =========================================================================== const dispatch = useDispatch() const _addCategoryToNote = (categoryId: string, noteId: string) => dispatch(addCategoryToNote({ categoryId, noteId })) const _updateActiveNote = (noteId: string, multiSelect: boolean) => dispatch(updateActiveNote({ noteId, multiSelect })) const _updateActiveCategoryId = (categoryId: string) => dispatch(updateActiveCategoryId(categoryId)) return !isDraftNote(note) ? ( <> {!note.scratchpad && ( { _addCategoryToNote(event.target.value, note.id) if (event.target.value !== activeCategoryId) { _updateActiveCategoryId(event.target.value) _updateActiveNote(note.id, false) } setOptionsId('') }} categories={categories} activeCategoryId={activeCategoryId} note={note} /> )} ) : null } ================================================ FILE: src/client/containers/ContextMenuOptions.tsx ================================================ import React, { useContext } from 'react' import { ArrowUp, Download, Star, Trash, X, Edit2, Clipboard } from 'react-feather' import { useDispatch, useSelector } from 'react-redux' import { LabelText } from '@resources/LabelText' import { TestID } from '@resources/TestID' import { ContextMenuOption } from '@/components/NoteList/ContextMenuOption' import { downloadNotes, isDraftNote, getShortUuid, copyToClipboard } from '@/utils/helpers' import { deleteNotes, toggleFavoriteNotes, toggleTrashNotes, addCategoryToNote, updateActiveNote, swapFolder, removeCategoryFromNotes, } from '@/slices/note' import { getCategories, getNotes } from '@/selectors' import { Folder, ContextMenuEnum } from '@/utils/enums' import { CategoryItem, NoteItem } from '@/types' import category, { setCategoryEdit, deleteCategory } from '@/slices/category' import { MenuUtilitiesContext } from '@/containers/ContextMenu' export interface ContextMenuOptionsProps { clickedItem: NoteItem | CategoryItem type: ContextMenuEnum } export const ContextMenuOptions: React.FC = ({ clickedItem, type }) => { if (type === 'CATEGORY') { return } else { return } } interface CategoryOptionsProps { clickedCategory: CategoryItem } const CategoryOptions: React.FC = ({ clickedCategory }) => { // =========================================================================== // Dispatch // =========================================================================== const dispatch = useDispatch() const _deleteCategory = (categoryId: string) => dispatch(deleteCategory(categoryId)) const _removeCategoryFromNotes = (categoryId: string) => dispatch(removeCategoryFromNotes(categoryId)) const _swapFolder = (folder: Folder) => dispatch(swapFolder({ folder })) const _setCategoryEdit = (categoryId: string, tempName: string) => dispatch(setCategoryEdit({ id: categoryId, tempName })) // =========================================================================== // Context // =========================================================================== const { setOptionsId } = useContext(MenuUtilitiesContext) // =========================================================================== // Handlers // =========================================================================== const startRenameHandler = () => { _setCategoryEdit(clickedCategory.id, clickedCategory.name) setOptionsId('') } const removeCategoryHandler = () => { _deleteCategory(clickedCategory.id) _removeCategoryFromNotes(clickedCategory.id) _swapFolder(Folder.ALL) } return ( ) } interface NotesOptionsProps { clickedNote: NoteItem } const NotesOptions: React.FC = ({ clickedNote }) => { // =========================================================================== // Selectors // =========================================================================== const { selectedNotesIds, notes } = useSelector(getNotes) const { categories } = useSelector(getCategories) const selectedNotes = notes.filter((note) => selectedNotesIds.includes(note.id)) const isSelectedNotesDiffFavor = Boolean( selectedNotes.find((note) => note.favorite) && selectedNotes.find((note) => !note.favorite) ) // =========================================================================== // Dispatch // =========================================================================== const dispatch = useDispatch() const _deleteNotes = (noteIds: string[]) => dispatch(deleteNotes(noteIds)) const _toggleTrashNotes = (noteId: string) => dispatch(toggleTrashNotes(noteId)) const _toggleFavoriteNotes = (noteId: string) => dispatch(toggleFavoriteNotes(noteId)) const _addCategoryToNote = (categoryId: string, noteId: string) => dispatch(addCategoryToNote({ categoryId, noteId })) const _updateActiveNote = (noteId: string, multiSelect: boolean) => dispatch(updateActiveNote({ noteId, multiSelect })) // =========================================================================== // Handlers // =========================================================================== const deleteNotesHandler = () => _deleteNotes(selectedNotesIds) const downloadNotesHandler = () => downloadNotes( selectedNotesIds.includes(clickedNote.id) ? selectedNotes : [clickedNote], categories ) const favoriteNoteHandler = () => _toggleFavoriteNotes(clickedNote.id) const trashNoteHandler = () => _toggleTrashNotes(clickedNote.id) const removeCategoryFromNoteHandler = () => { _addCategoryToNote('', clickedNote.id) _updateActiveNote(clickedNote.id, false) } const copyLinkedNoteMarkdownHandler = (e: React.SyntheticEvent, note: NoteItem) => { e.preventDefault() const shortNoteUuid = getShortUuid(note.id) copyToClipboard(`{{${shortNoteUuid}}}`) } return !isDraftNote(clickedNote) ? ( ) : null } ================================================ FILE: src/client/containers/KeyboardShortcuts.tsx ================================================ import React from 'react' import { useDispatch, useSelector } from 'react-redux' import prettier from 'prettier/standalone' import parserMarkdown from 'prettier/parser-markdown' import { useTempState } from '@/contexts/TempStateContext' import { Folder, Shortcuts } from '@/utils/enums' import { downloadNotes, getActiveNote, newNoteHandlerHelper } from '@/utils/helpers' import { useKey } from '@/utils/hooks' import { addNote, swapFolder, toggleTrashNotes, updateActiveNote, updateSelectedNotes, updateNote, } from '@/slices/note' import { sync } from '@/slices/sync' import { getCategories, getNotes, getSettings } from '@/selectors' import { CategoryItem, NoteItem } from '@/types' import { toggleDarkTheme, togglePreviewMarkdown, updateCodeMirrorOption } from '@/slices/settings' export const KeyboardShortcuts: React.FC = () => { // =========================================================================== // Selectors // =========================================================================== const { categories } = useSelector(getCategories) const { activeCategoryId, activeFolder, activeNoteId, notes, selectedNotesIds } = useSelector( getNotes ) const { darkTheme, previewMarkdown } = useSelector(getSettings) const activeNote = getActiveNote(notes, activeNoteId) // =========================================================================== // Dispatch // =========================================================================== const dispatch = useDispatch() const _addNote = (note: NoteItem) => dispatch(addNote(note)) const _updateActiveNote = (noteId: string, multiSelect: boolean) => dispatch(updateActiveNote({ noteId, multiSelect })) const _updateSelectedNotes = (noteId: string, multiSelect: boolean) => dispatch(updateSelectedNotes({ noteId, multiSelect })) const _swapFolder = (folder: Folder) => dispatch(swapFolder({ folder })) const _toggleTrashNotes = (noteId: string) => dispatch(toggleTrashNotes(noteId)) const _sync = (notes: NoteItem[], categories: CategoryItem[]) => dispatch(sync({ notes, categories })) const _togglePreviewMarkdown = () => dispatch(togglePreviewMarkdown()) const _toggleDarkTheme = () => dispatch(toggleDarkTheme()) const _updateCodeMirrorOption = (key: string, value: string) => dispatch(updateCodeMirrorOption({ key, value })) // =========================================================================== // State // =========================================================================== const { addingTempCategory, setAddingTempCategory } = useTempState() // =========================================================================== // Handlers // =========================================================================== const newNoteHandler = () => newNoteHandlerHelper( activeFolder, previewMarkdown, activeNote, activeCategoryId, _swapFolder, _togglePreviewMarkdown, _addNote, _updateActiveNote, _updateSelectedNotes ) const newTempCategoryHandler = () => !addingTempCategory && setAddingTempCategory(true) const trashNoteHandler = () => _toggleTrashNotes(activeNote!.id) const syncNotesHandler = () => _sync(notes, categories) const downloadNotesHandler = () => { if (!activeNote || selectedNotesIds.length === 0) return downloadNotes( selectedNotesIds.includes(activeNote.id) ? notes.filter((note) => selectedNotesIds.includes(note.id)) : [activeNote], categories ) } const togglePreviewMarkdownHandler = () => _togglePreviewMarkdown() const toggleDarkThemeHandler = () => { _toggleDarkTheme() _updateCodeMirrorOption('theme', darkTheme ? 'base16-light' : 'new-moon') } const prettifyNoteHandler = () => { // format current note with prettier if (activeNote && activeNote.text) { const formattedText = prettier.format(activeNote.text, { parser: 'markdown', plugins: [parserMarkdown], }) const updatedNote = { ...activeNote, text: formattedText, } dispatch(updateNote(updatedNote)) } } // =========================================================================== // Hooks // =========================================================================== useKey(Shortcuts.NEW_NOTE, () => newNoteHandler()) useKey(Shortcuts.NEW_CATEGORY, () => newTempCategoryHandler()) useKey(Shortcuts.DELETE_NOTE, () => trashNoteHandler()) useKey(Shortcuts.SYNC_NOTES, () => syncNotesHandler()) useKey(Shortcuts.DOWNLOAD_NOTES, () => downloadNotesHandler()) useKey(Shortcuts.PREVIEW, () => togglePreviewMarkdownHandler()) useKey(Shortcuts.TOGGLE_THEME, () => toggleDarkThemeHandler()) useKey(Shortcuts.PRETTIFY, () => prettifyNoteHandler()) return null } ================================================ FILE: src/client/containers/NoteEditor.tsx ================================================ import dayjs from 'dayjs' import React from 'react' import { Controlled as CodeMirror } from 'react-codemirror2' import { useDispatch, useSelector } from 'react-redux' import { Editor } from 'codemirror' import { getActiveNote } from '@/utils/helpers' import { updateNote } from '@/slices/note' import { NoteItem } from '@/types' import { NoteMenuBar } from '@/containers/NoteMenuBar' import { EmptyEditor } from '@/components/Editor/EmptyEditor' import { PreviewEditor } from '@/components/Editor/PreviewEditor' import { getNotes, getSettings, getSync } from '@/selectors' import { setPendingSync } from '@/slices/sync' import 'codemirror/lib/codemirror.css' import 'codemirror/theme/base16-light.css' import 'codemirror/mode/gfm/gfm' import 'codemirror/addon/selection/active-line' import 'codemirror/addon/scroll/scrollpastend' export const NoteEditor: React.FC = () => { // =========================================================================== // Selectors // =========================================================================== const { pendingSync } = useSelector(getSync) const { activeNoteId, loading, notes } = useSelector(getNotes) const { codeMirrorOptions, previewMarkdown } = useSelector(getSettings) const activeNote = getActiveNote(notes, activeNoteId) // =========================================================================== // Dispatch // =========================================================================== const dispatch = useDispatch() const _updateNote = (note: NoteItem) => { !pendingSync && dispatch(setPendingSync()) dispatch(updateNote(note)) } const setEditorOverlay = (editor: Editor) => { const query = /\{\{[^}]*}}/g editor.addOverlay({ token: function (stream: any) { query.lastIndex = stream.pos var match = query.exec(stream.string) if (match && match.index == stream.pos) { stream.pos += match[0].length || 1 return 'notelink' } else if (match) { stream.pos = match.index } else { stream.skipToEnd() } }, }) } const renderEditor = () => { if (loading) { return
Loading...
} else if (!activeNote) { return } else if (previewMarkdown) { return ( ) } return ( { setTimeout(() => { editor.focus() }, 0) editor.setCursor(0) setEditorOverlay(editor) }} onBeforeChange={(editor, data, value) => { _updateNote({ id: activeNote.id, text: value, created: activeNote.created, lastUpdated: dayjs().format(), }) }} onChange={(editor, data, value) => { if (!value) { editor.focus() } }} onPaste={(editor, event: any) => { // Get around pasting issue // https://github.com/scniro/react-codemirror2/issues/77 if (!event.clipboardData || !event.clipboardData.items || !event.clipboardData.items[0]) return event.clipboardData.items[0].getAsString((pasted: any) => { if (editor.getSelection() !== pasted) return const { anchor, head } = editor.listSelections()[0] editor.setCursor({ line: Math.max(anchor.line, head.line), ch: Math.max(anchor.ch, head.ch), }) }) }} /> ) } return (
{renderEditor()}
) } ================================================ FILE: src/client/containers/NoteList.tsx ================================================ import React, { useEffect, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { MoreHorizontal, Book, Star, Folder as FolderIcon } from 'react-feather' import { TestID } from '@resources/TestID' import { Folder, Shortcuts, ContextMenuEnum } from '@/utils/enums' import { NoteListButton } from '@/components/NoteList/NoteListButton' import { SearchBar } from '@/components/NoteList/SearchBar' import { ContextMenu } from '@/containers/ContextMenu' import { getNoteTitle, shouldOpenContextMenu, debounceEvent, isDraftNote } from '@/utils/helpers' import { useKey } from '@/utils/hooks' import { permanentlyEmptyTrash, pruneNotes, updateActiveNote, searchNotes, updateSelectedNotes, } from '@/slices/note' import { NoteItem, ReactDragEvent, ReactMouseEvent } from '@/types' import { getNotes, getSettings, getCategories } from '@/selectors' import { getNotesSorter } from '@/utils/notesSortStrategies' export const NoteList: React.FC = () => { // =========================================================================== // Selectors // =========================================================================== const { notesSortKey } = useSelector(getSettings) const { activeCategoryId, activeFolder, selectedNotesIds, notes, searchValue } = useSelector(getNotes) const { categories } = useSelector(getCategories) // =========================================================================== // Dispatch // =========================================================================== const dispatch = useDispatch() const _updateSelectedNotes = (noteId: string, multiSelect: boolean) => dispatch(updateSelectedNotes({ noteId, multiSelect })) const _permanentlyEmptyTrash = () => dispatch(permanentlyEmptyTrash()) const _pruneNotes = () => dispatch(pruneNotes()) const _updateActiveNote = (noteId: string, multiSelect: boolean) => dispatch(updateActiveNote({ noteId, multiSelect })) const _searchNotes = debounceEvent( (searchValue: string) => dispatch(searchNotes(searchValue)), 100 ) // =========================================================================== // Refs // =========================================================================== const contextMenuRef = useRef(null) const searchRef = React.useRef() as React.MutableRefObject // =========================================================================== // State // =========================================================================== const [optionsId, setOptionsId] = useState('') const [optionsPosition, setOptionsPosition] = useState({ x: 0, y: 0 }) const re = new RegExp(searchValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') const isMatch = (result: NoteItem) => re.test(result.text) const filter: Record boolean> = { [Folder.CATEGORY]: (note) => !note.trash && note.category === activeCategoryId, [Folder.SCRATCHPAD]: (note) => !!note.scratchpad, [Folder.FAVORITES]: (note) => !note.trash && !!note.favorite, [Folder.TRASH]: (note) => !!note.trash, [Folder.ALL]: (note) => !note.trash && !note.scratchpad, } const filteredNotes: NoteItem[] = notes .filter(filter[activeFolder]) .filter(isMatch) .sort(getNotesSorter(notesSortKey)) // =========================================================================== // Handlers // =========================================================================== const focusSearchHandler = () => searchRef.current.focus() const handleDragStart = (event: ReactDragEvent, noteId: string = '') => { event.stopPropagation() event.dataTransfer.setData('text/plain', noteId) } const handleNoteOptionsClick = (event: ReactMouseEvent, noteId: string = '') => { const clicked = event.target // Make sure we aren't getting any null values. Any element clicked should be a sub-class of element if (!clicked) return // Ensure the clicked target is supposed to open the context menu if (shouldOpenContextMenu(clicked as Element)) { // note: don't check for MouseEvent because Cypress MouseEvent !== Window.MouseEvent if ('pageX' in event && 'pageY' in event) { setOptionsPosition({ x: event.pageX, y: event.pageY }) } } event.stopPropagation() if (!contextMenuRef.current || !contextMenuRef.current.contains(clicked as HTMLDivElement)) { setOptionsId(!optionsId || optionsId !== noteId ? noteId : '') } } const handleNoteRightClick = ( event: React.MouseEvent, noteId: string = '' ) => { event.preventDefault() const clicked = event.target const RIGHT_CLICK = 2 // Make sure we aren't getting any null values .. any element clicked should be a sub-class of element if (!clicked) return // FIXME: This feels hacky if (event.ctrlKey) return // Make sure we are not right clicking on the menu if (optionsId && event.button == RIGHT_CLICK) return if ('clientX' in event && 'clientY' in event) { setOptionsPosition({ x: event.clientX, y: event.clientY }) } event.stopPropagation() if (!contextMenuRef.current || contextMenuRef.current.contains(clicked as HTMLDivElement)) { setOptionsId(!optionsId || optionsId !== noteId ? noteId : '') } } const showEmptyTrash = activeFolder === Folder.TRASH && filteredNotes.length > 0 // =========================================================================== // Hooks // =========================================================================== useEffect(() => { document.addEventListener('mousedown', handleNoteOptionsClick) return () => { document.removeEventListener('mousedown', handleNoteOptionsClick) } }) useKey(Shortcuts.SEARCH, () => focusSearchHandler()) return ( ) } ================================================ FILE: src/client/containers/NoteMenuBar.tsx ================================================ import React, { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { Eye, Edit, Star, Trash2, Download, RefreshCw, Loader, Settings, Sun, Moon, Clipboard as ClipboardCmp, } from 'react-feather' import { TestID } from '@resources/TestID' import { LastSyncedNotification } from '@/components/LastSyncedNotification' import { NoteItem, CategoryItem } from '@/types' import { toggleSettingsModal, togglePreviewMarkdown, toggleDarkTheme, updateCodeMirrorOption, } from '@/slices/settings' import { toggleFavoriteNotes, toggleTrashNotes } from '@/slices/note' import { getCategories, getNotes, getSync, getSettings } from '@/selectors' import { downloadNotes, isDraftNote, getShortUuid, copyToClipboard } from '@/utils/helpers' import { sync } from '@/slices/sync' export const NoteMenuBar = () => { // =========================================================================== // Selectors // =========================================================================== const { notes, activeNoteId } = useSelector(getNotes) const { categories } = useSelector(getCategories) const { syncing, lastSynced, pendingSync } = useSelector(getSync) const { darkTheme } = useSelector(getSettings) // =========================================================================== // Other // =========================================================================== const copyNoteIcon =