├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── .travis.yml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── TODO.md ├── craco.config.js ├── electron ├── actions.js ├── constants.js ├── main.js ├── menu.js ├── utils.js └── window.js ├── nodemon.json ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo.icns ├── logo.png └── manifest.json ├── src ├── App.module.scss ├── App.tsx ├── Globals.d.ts ├── components │ ├── Loader │ │ ├── Loader.module.scss │ │ ├── Loader.test.tsx │ │ └── Loader.tsx │ ├── Modal │ │ ├── AccountModal │ │ │ ├── AccountModal.tsx │ │ │ ├── AuthTokenPanel.tsx │ │ │ └── GistSelectorPanel.tsx │ │ ├── ErrorModal │ │ │ └── ErrorModal.tsx │ │ ├── ModalOverlay │ │ │ ├── ModalOverlay.tsx │ │ │ └── constants.ts │ │ └── Panel.module.scss │ ├── SnippetEditor │ │ ├── Editor │ │ │ ├── Editor.module.scss │ │ │ ├── Editor.test.tsx │ │ │ ├── Editor.tsx │ │ │ ├── __snapshots__ │ │ │ │ └── Editor.test.tsx.snap │ │ │ └── ace-themes │ │ │ │ ├── darkTheme.js │ │ │ │ ├── darkTheme.scss │ │ │ │ ├── index.ts │ │ │ │ ├── lightTheme.js │ │ │ │ └── lightTheme.scss │ │ ├── EditorHeader │ │ │ ├── EditorHeader.module.scss │ │ │ ├── EditorHeader.test.tsx │ │ │ ├── EditorHeader.tsx │ │ │ └── __snapshots__ │ │ │ │ └── EditorHeader.test.tsx.snap │ │ ├── SnippetEditor.module.scss │ │ ├── SnippetEditor.tsx │ │ └── StatusBar │ │ │ ├── StatusBar.module.scss │ │ │ ├── StatusBar.test.tsx │ │ │ ├── StatusBar.tsx │ │ │ ├── TagBar │ │ │ ├── TagBar.module.scss │ │ │ ├── TagBar.test.tsx │ │ │ ├── TagBar.tsx │ │ │ └── __snapshots__ │ │ │ │ └── TagBar.test.tsx.snap │ │ │ └── __snapshots__ │ │ │ └── StatusBar.test.tsx.snap │ ├── SnippetList │ │ ├── Resizer.tsx │ │ ├── ScrollableWrapper │ │ │ ├── ScrollableWrapper.module.scss │ │ │ └── ScrollableWrapper.tsx │ │ ├── SnippetList.module.scss │ │ ├── SnippetList.tsx │ │ ├── SnippetListElement │ │ │ ├── SnippetListElement.module.scss │ │ │ ├── SnippetListElement.test.tsx │ │ │ ├── SnippetListElement.tsx │ │ │ └── __snapshots__ │ │ │ │ └── SnippetListElement.test.tsx.snap │ │ ├── SnippetListHeader │ │ │ ├── SnippetListHeader.module.scss │ │ │ ├── SnippetListHeader.test.tsx │ │ │ ├── SnippetListHeader.tsx │ │ │ └── __snapshots__ │ │ │ │ └── SnippetListHeader.test.tsx.snap │ │ └── contextMenu.ts │ └── Theme │ │ ├── Theme.tsx │ │ └── themes.ts ├── db │ ├── __mocks__ │ │ └── snippets.ts │ ├── app.ts │ ├── constants.ts │ ├── db.ts │ └── snippets.ts ├── index.scss ├── index.tsx ├── models │ ├── Snippet.ts │ ├── languages.ts │ └── tags.ts ├── react-app-env.d.ts ├── serviceWorker.ts ├── store │ ├── auth │ │ ├── actions.test.ts │ │ ├── actions.ts │ │ ├── reducers.ts │ │ └── types.ts │ ├── editor │ │ ├── actions.ts │ │ ├── reducers.ts │ │ └── types.ts │ ├── index.ts │ ├── modal │ │ ├── actions.ts │ │ ├── reducers.ts │ │ └── types.ts │ ├── snippets │ │ ├── actions.test.ts │ │ ├── actions.ts │ │ ├── reducers.ts │ │ └── types.ts │ ├── types.ts │ └── ui │ │ ├── actions.ts │ │ ├── reducers.ts │ │ └── types.ts ├── svg-icon-overrides.scss └── utils │ ├── __mocks__ │ └── gistActions.ts │ ├── appCommand.ts │ ├── gistActions.ts │ ├── test │ ├── mockSnippets.ts │ └── mockStore.ts │ ├── useWindowDimensions.tsx │ └── utils.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | build/* 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", 3 | extends: [ 4 | "plugin:react/recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:prettier/recommended" 7 | ], 8 | plugins: ["prettier", "@typescript-eslint"], 9 | parserOptions: { 10 | ecmaVersion: 2020, 11 | sourceType: "module", 12 | ecmaFeatures: { 13 | jsx: true 14 | } 15 | }, 16 | settings: { 17 | react: { 18 | version: "detect" 19 | } 20 | }, 21 | rules: { 22 | "react/react-in-jsx-scope": "off", 23 | "@typescript-eslint/no-use-before-define": "off" 24 | }, 25 | overrides: [ 26 | { 27 | "files": ["**/*.tsx"], 28 | "rules": { 29 | "react/prop-types": "off" 30 | } 31 | }, 32 | { 33 | "files": ["electron/**/*"], 34 | "rules": { 35 | "@typescript-eslint/no-var-requires": "off", 36 | "@typescript-eslint/explicit-function-return-type": "off", 37 | } 38 | } 39 | ] 40 | }; 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | /dist 12 | 13 | # misc 14 | .DS_Store 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14.18 -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: "all", 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2 7 | }; 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | cache: 4 | directories: 5 | - node_modules 6 | 7 | addons: 8 | apt: 9 | packages: 10 | - xvfb 11 | 12 | install: 13 | - npm install 14 | - export DISPLAY=':99.0' 15 | - Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.alwaysShowStatus": true, 3 | 4 | "typescript.preferences.quoteStyle": "single", 5 | "javascript.preferences.quoteStyle": "single", 6 | 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll.eslint": true 9 | } 10 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Łukasz Szypliński 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kangaroo [![Build Status](https://api.travis-ci.com/WilsonDev/kangaroo.svg?branch=master)](https://travis-ci.com/WilsonDev/kangaroo) [![MIT Licensed](https://img.shields.io/badge/License-MIT-blue.svg?style=flat)](https://opensource.org/licenses/MIT) 2 | 3 |
4 |
5 | logo 6 |
7 |
8 |
9 | 10 | Simple **Snippet** Manager 11 | - Ability to store and sync notes using GitHub gists 12 | - Light and dark theme 13 | 14 |
15 | 16 | ## Screenshots 17 | 18 | ![kangaroo light](https://user-images.githubusercontent.com/5923943/152207756-a772b4ed-65ca-4df9-b890-613afe812e61.png) 19 | ![kangaroo dark](https://user-images.githubusercontent.com/5923943/152207768-e3419fcc-5bcc-4f66-98e6-88a34c2e6f4a.png) 20 | 21 | ## Developement 22 | 23 | ### Install dependencies 24 | ```bash 25 | $ git clone https://github.com/WilsonDev/kangaroo.git 26 | $ cd kangaroo && npm install 27 | ``` 28 | 29 | ### Run 30 | ```bash 31 | npm start 32 | ``` 33 | 34 | ## Licensing 35 | 36 | This code is licensed under the [MIT license](LICENSE). Check out the LICENSE file for more information. 37 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | * GitHub gists integration: 2 | * Error handling 3 | * Remove CLOUD source when unlinking GH account -------------------------------------------------------------------------------- /craco.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | webpack: { 3 | configure: { 4 | target: 'electron-renderer', 5 | }, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /electron/actions.js: -------------------------------------------------------------------------------- 1 | const { Menu, MenuItem, app } = require('electron'); 2 | const { GH_AUTH_TOKEN, BACKUP_GIST_ID, LAST_SYNCHRONIZED_GIST_DATE, THEME } = require('./constants'); 3 | 4 | const initActions = (ipcMain, window, store) => { 5 | ipcMain.handle('LOAD_GH_DATA', () => { 6 | const token = store.get(GH_AUTH_TOKEN); 7 | const backupGistId = store.get(BACKUP_GIST_ID); 8 | const gistDate = store.get(LAST_SYNCHRONIZED_GIST_DATE); 9 | 10 | return { 11 | token, 12 | backupGistId, 13 | gistDate, 14 | }; 15 | }); 16 | 17 | ipcMain.handle('SET_GH_DATA', (_event, data) => { 18 | const { token, backupGistId, gistDate } = data; 19 | 20 | if (token) { 21 | store.set(GH_AUTH_TOKEN, token); 22 | } 23 | 24 | if (backupGistId) { 25 | store.set(BACKUP_GIST_ID, backupGistId); 26 | } 27 | 28 | if (gistDate) { 29 | store.set(LAST_SYNCHRONIZED_GIST_DATE, gistDate); 30 | } 31 | }); 32 | 33 | ipcMain.handle('DELETE_GH_DATA', () => { 34 | store.delete(GH_AUTH_TOKEN); 35 | store.delete(BACKUP_GIST_ID); 36 | store.delete(LAST_SYNCHRONIZED_GIST_DATE); 37 | }); 38 | 39 | ipcMain.handle('SWITCH_THEME', (_event, theme) => { 40 | store.set(THEME, theme); 41 | return theme; 42 | }); 43 | 44 | ipcMain.handle('GET_THEME', () => { 45 | return store.get(THEME); 46 | }); 47 | 48 | ipcMain.handle('GET_USER_DATA_PATH', () => { 49 | return app.getPath('userData'); 50 | }); 51 | 52 | const contextMenu = new Menu(); 53 | const deleteMenuItem = new MenuItem({ 54 | label: 'Delete', 55 | click: () => { 56 | window.webContents.send('DELETE_SNIPPET'); 57 | } 58 | }); 59 | contextMenu.append(deleteMenuItem); 60 | 61 | ipcMain.handle('OPEN_CONTEXT_MENU', (_event) => { 62 | contextMenu.popup(); 63 | }); 64 | }; 65 | 66 | module.exports = { initActions }; 67 | -------------------------------------------------------------------------------- /electron/constants.js: -------------------------------------------------------------------------------- 1 | const GH_AUTH_TOKEN = 'gh_auth_token'; 2 | const BACKUP_GIST_ID = 'backup_gist_id'; 3 | const LAST_SYNCHRONIZED_GIST_DATE = 'last_synchronized_gist_date'; 4 | const THEME = 'theme'; 5 | 6 | const BACKGROUND_COLOR = { 7 | LIGHT: '#ffffff', 8 | DARK: '#30404d', 9 | }; 10 | 11 | module.exports = { 12 | GH_AUTH_TOKEN, 13 | BACKUP_GIST_ID, 14 | LAST_SYNCHRONIZED_GIST_DATE, 15 | THEME, 16 | BACKGROUND_COLOR, 17 | }; 18 | -------------------------------------------------------------------------------- /electron/main.js: -------------------------------------------------------------------------------- 1 | const { app, BrowserWindow, ipcMain } = require('electron'); 2 | 3 | const path = require('path'); 4 | const isDev = require('electron-is-dev'); 5 | const Store = require('electron-store'); 6 | 7 | const { getMainWindow, setMainWindow } = require('./window'); 8 | const { generateMenu } = require('./menu'); 9 | const { initActions } = require('./actions'); 10 | const { THEME, BACKGROUND_COLOR } = require('./constants'); 11 | 12 | const createWindow = () => { 13 | const store = new Store(); 14 | const theme = store.get(THEME); 15 | 16 | const mainWindow = new BrowserWindow({ 17 | title: 'Kangaroo', 18 | backgroundColor: theme && theme === 'light' ? BACKGROUND_COLOR.LIGHT : BACKGROUND_COLOR.DARK, 19 | show: false, 20 | titleBarStyle: 'hiddenInset', 21 | webPreferences: { 22 | nodeIntegration: true, 23 | contextIsolation: false, 24 | }, 25 | height: 600, 26 | width: 880, 27 | minWidth: 450, 28 | }); 29 | 30 | mainWindow.loadURL(isDev ? 'http://localhost:3000' : `file://${path.join(__dirname, '../index.html')}`); 31 | 32 | if (isDev) { 33 | const { default: installExtension, REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } = require('electron-devtools-installer'); 34 | 35 | [REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS].forEach((extension) => { 36 | installExtension(extension) 37 | .then((name) => console.log(`Added Extension: ${name}`)) 38 | .catch((err) => console.log('An error occurred: ', err)); 39 | }); 40 | 41 | mainWindow.webContents.on('did-frame-finish-load', () => { 42 | mainWindow.webContents.openDevTools(); 43 | mainWindow.webContents.on('devtools-opened', () => { 44 | mainWindow.focus(); 45 | }); 46 | }); 47 | } 48 | 49 | generateMenu(mainWindow); 50 | 51 | mainWindow.once('ready-to-show', () => { 52 | initActions(ipcMain, mainWindow, store); 53 | mainWindow.show(); 54 | }); 55 | 56 | mainWindow.on('closed', () => { 57 | setMainWindow(null); 58 | }); 59 | 60 | setMainWindow(mainWindow); 61 | }; 62 | 63 | app.allowRendererProcessReuse = true; 64 | 65 | app.setAboutPanelOptions({ 66 | applicationName: 'Kangaroo', 67 | version: 'App Store', 68 | applicationVersion: process.env.npm_package_version, 69 | }); 70 | 71 | app.on('window-all-closed', () => { 72 | if (process.platform !== 'darwin') { 73 | app.quit(); 74 | } 75 | }); 76 | 77 | app.on('activate', () => { 78 | if (getMainWindow() === null) { 79 | createWindow(); 80 | } 81 | }); 82 | 83 | app.whenReady().then(() => { 84 | createWindow(); 85 | }); 86 | -------------------------------------------------------------------------------- /electron/menu.js: -------------------------------------------------------------------------------- 1 | const { Menu } = require('electron'); 2 | const { sendCommand } = require('./utils'); 3 | 4 | const generateMenu = (window) => { 5 | const template = [ 6 | { 7 | label: 'App', 8 | submenu: [ 9 | { role: 'about' }, 10 | { 11 | label: 'Switch theme', 12 | click: () => sendCommand(window, { action: 'SWITCH_THEME' }), 13 | }, 14 | { role: 'quit' }, 15 | ], 16 | }, 17 | { 18 | label: 'File', 19 | submenu: [ 20 | { 21 | label: 'New', 22 | accelerator: 'CommandOrControl+N', 23 | click: () => sendCommand(window, { action: 'ADD_SNIPPET' }), 24 | }, 25 | { role: 'undo' }, 26 | { role: 'redo' }, 27 | { type: 'separator' }, 28 | { role: 'cut' }, 29 | { role: 'copy' }, 30 | { role: 'paste' }, 31 | { role: 'delete' }, 32 | { role: 'selectall' }, 33 | ], 34 | }, 35 | { 36 | role: 'window', 37 | submenu: [{ role: 'minimize' }, { role: 'close' }], 38 | }, 39 | ]; 40 | 41 | Menu.setApplicationMenu(Menu.buildFromTemplate(template)); 42 | }; 43 | 44 | module.exports = { generateMenu }; 45 | -------------------------------------------------------------------------------- /electron/utils.js: -------------------------------------------------------------------------------- 1 | const sendCommand = (window, command) => { 2 | window.webContents.send('APP_COMMAND', command); 3 | }; 4 | 5 | module.exports = { sendCommand }; 6 | -------------------------------------------------------------------------------- /electron/window.js: -------------------------------------------------------------------------------- 1 | let _mainWindow; 2 | 3 | exports.getMainWindow = () => { 4 | return _mainWindow; 5 | }; 6 | 7 | exports.setMainWindow = (mainWindow) => { 8 | _mainWindow = mainWindow; 9 | }; 10 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "restartable": "rs", 3 | "ignore": [".git", "node_modules/**/node_modules"], 4 | "verbose": false, 5 | "execMap": { 6 | "ts": "node --require ts-node/register" 7 | }, 8 | "watch": ["src/", "electron/"], 9 | "env": { 10 | "NODE_ENV": "development" 11 | }, 12 | "ext": "js,json,ts,tsx" 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kangaroo", 3 | "version": "0.9.5", 4 | "private": true, 5 | "main": "electron/main.js", 6 | "description": "Snippet manager", 7 | "author": { 8 | "name": "PROGGRAMIK Łukasz Szypliński", 9 | "email": "proggramik@gmail.com", 10 | "url": "https://proggramik.com" 11 | }, 12 | "scripts": { 13 | "start": "concurrently \"cross-env BROWSER=none REACT_APP_MODE=electron craco start\" \"NODE_ENV=dev nodemon --exec ' wait-on http://localhost:3000 && electron .'\"", 14 | "start:web": "craco start", 15 | "build": "npm run build:web && npm run build:electron", 16 | "build:web": "craco build", 17 | "build:electron": "mkdir build/src && cp -r electron/. build/electron", 18 | "package:mac": "PUBLIC_URL=./ npm run build && electron-builder --mac", 19 | "lint": "tsc --noEmit && eslint 'src/*/**/*.{js,ts,tsx}' --quiet --fix", 20 | "test": "craco test", 21 | "analyze": "source-map-explorer 'build/static/js/*.js'" 22 | }, 23 | "dependencies": { 24 | "@blueprintjs/core": "3.52.0", 25 | "@blueprintjs/popover2": "^0.12.9", 26 | "@octokit/rest": "18.0.6", 27 | "@octokit/types": "5.5.0", 28 | "@wojtekmaj/enzyme-adapter-react-17": "^0.6.6", 29 | "ace-builds": "1.4.13", 30 | "classnames": "2.3.1", 31 | "electron-is-dev": "2.0.0", 32 | "electron-store": "8.0.1", 33 | "nedb": "1.8.0", 34 | "node-sass": "5.0.0", 35 | "path": "0.12.7", 36 | "react": "17.0.2", 37 | "react-ace": "9.5.0", 38 | "react-dom": "17.0.2", 39 | "react-redux": "7.2.6", 40 | "react-transition-group": "4.4.2", 41 | "redux": "4.1.2", 42 | "redux-thunk": "2.4.1", 43 | "source-map-explorer": "2.5.2", 44 | "uuid": "8.3.2" 45 | }, 46 | "devDependencies": { 47 | "@craco/craco": "6.4.3", 48 | "@jedmao/redux-mock-store": "3.0.5", 49 | "@types/classnames": "2.3.1", 50 | "@types/enzyme": "3.10.11", 51 | "@types/jest": "26.0.20", 52 | "@types/nedb": "1.8.11", 53 | "@types/node": "14.18.3", 54 | "@types/react": "17.0.38", 55 | "@types/react-dom": "17.0.11", 56 | "@types/react-redux": "7.1.22", 57 | "@types/react-test-renderer": "17.0.1", 58 | "@types/react-transition-group": "4.4.4", 59 | "@types/uuid": "8.3.4", 60 | "@typescript-eslint/eslint-plugin": "5.10.2", 61 | "@typescript-eslint/parser": "5.10.2", 62 | "concurrently": "7.0.0", 63 | "cross-env": "7.0.3", 64 | "electron": "17.0.0", 65 | "electron-builder": "22.14.5", 66 | "electron-devtools-installer": "3.2.0", 67 | "enzyme": "3.11.0", 68 | "eslint": "7.32.0", 69 | "eslint-config-prettier": "8.3.0", 70 | "eslint-plugin-prettier": "4.0.0", 71 | "eslint-plugin-react": "7.28.0", 72 | "nodemon": "2.0.15", 73 | "prettier": "2.5.1", 74 | "react-scripts": "5.0.0", 75 | "react-test-renderer": "17.0.2", 76 | "redux-devtools-extension": "2.13.9", 77 | "ts-node": "10.4.0", 78 | "typescript": "4.5.5", 79 | "version-bump-prompt": "6.1.0", 80 | "wait-on": "6.0.0" 81 | }, 82 | "eslintConfig": { 83 | "extends": "react-app" 84 | }, 85 | "browserslist": { 86 | "production": [ 87 | ">0.2%", 88 | "not dead", 89 | "not op_mini all" 90 | ], 91 | "development": [ 92 | "last 1 chrome version", 93 | "last 1 firefox version", 94 | "last 1 safari version" 95 | ] 96 | }, 97 | "build": { 98 | "appId": "com.proggramik.kangaroo", 99 | "compression": "normal", 100 | "productName": "Kangaroo", 101 | "copyright": "Copyright © 2021 ${author}", 102 | "extraMetadata": { 103 | "main": "build/electron/main.js" 104 | }, 105 | "directories": { 106 | "buildResources": "build", 107 | "output": "dist" 108 | }, 109 | "mac": { 110 | "icon": "logo.icns", 111 | "type": "distribution", 112 | "target": [ 113 | "pkg", 114 | "dmg" 115 | ], 116 | "extendInfo": { 117 | "NSRequiresAquaSystemAppearance": false 118 | }, 119 | "category": "public.app-category.developer-tools" 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukszy-dev/kangaroo/485e70f272c0e1232a28b9b2ea91d983901c3d55/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | Kangaroo 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /public/logo.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukszy-dev/kangaroo/485e70f272c0e1232a28b9b2ea91d983901c3d55/public/logo.icns -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukszy-dev/kangaroo/485e70f272c0e1232a28b9b2ea91d983901c3d55/public/logo.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Kangaroo", 3 | "name": "Kangaroo", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/App.module.scss: -------------------------------------------------------------------------------- 1 | .content { 2 | display: flex; 3 | flex-flow: row; 4 | } 5 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { ipcRenderer, IpcRendererEvent } from 'electron'; 4 | 5 | import { appDb } from 'db/app'; 6 | import { snippetsDb } from 'db/snippets'; 7 | import Loader from 'components/Loader/Loader'; 8 | import Theme from 'components/Theme/Theme'; 9 | import SnippetEditor from 'components/SnippetEditor/SnippetEditor'; 10 | import SnippetList from 'components/SnippetList/SnippetList'; 11 | import ModalOverlay from 'components/Modal/ModalOverlay/ModalOverlay'; 12 | 13 | import { RootState, AppDispatch } from 'store/types'; 14 | import { initSnippets } from 'store/snippets/actions'; 15 | import { loadAuthData } from 'store/auth/actions'; 16 | import { appInit, loadTheme } from 'store/ui/actions'; 17 | 18 | import appCommand, { APP_COMMAND, AppCommandMessage } from 'utils/appCommand'; 19 | 20 | import styles from './App.module.scss'; 21 | 22 | const App: React.FC = () => { 23 | const dispatch = useDispatch(); 24 | const { init, theme } = useSelector((state: RootState) => state.ui); 25 | 26 | useEffect(() => { 27 | dispatch(loadTheme()); 28 | dispatch(loadAuthData()); 29 | 30 | ipcRenderer.invoke('GET_USER_DATA_PATH').then((path) => { 31 | appDb.loadDatabase(path); 32 | snippetsDb.loadDatabase(path); 33 | 34 | dispatch(initSnippets()).then(() => dispatch(appInit(false))); 35 | }); 36 | 37 | ipcRenderer.on(APP_COMMAND, (_: IpcRendererEvent, message: AppCommandMessage) => appCommand(dispatch, message)); 38 | 39 | return (): void => { 40 | ipcRenderer.removeAllListeners(APP_COMMAND); 41 | }; 42 | }, [dispatch]); 43 | 44 | return ( 45 | 46 | {init ? ( 47 | 48 | ) : ( 49 | <> 50 | 51 | 52 | 53 | 54 | )} 55 | 56 | ); 57 | }; 58 | 59 | export default App; 60 | -------------------------------------------------------------------------------- /src/Globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.module.scss'; -------------------------------------------------------------------------------- /src/components/Loader/Loader.module.scss: -------------------------------------------------------------------------------- 1 | @import "@blueprintjs/core/lib/scss/variables"; 2 | 3 | .container { 4 | position: absolute; 5 | display: flex; 6 | height: 100%; 7 | width: 100%; 8 | z-index: $pt-z-index-overlay; 9 | } 10 | 11 | .spinner { 12 | flex: 1; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.test.tsx: -------------------------------------------------------------------------------- 1 | import { configure, shallow, ShallowWrapper } from 'enzyme'; 2 | import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; 3 | 4 | import Loader from './Loader'; 5 | import { Spinner } from '@blueprintjs/core'; 6 | 7 | configure({ adapter: new Adapter() }); 8 | 9 | describe('', () => { 10 | let wrapper: ShallowWrapper; 11 | 12 | beforeEach(() => { 13 | wrapper = shallow(); 14 | }); 15 | 16 | it('renders without crashing', () => { 17 | wrapper.contains(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner, SpinnerSize } from '@blueprintjs/core'; 2 | 3 | import styles from './Loader.module.scss'; 4 | 5 | const Loader: React.FC = () => ( 6 |
7 | 8 |
9 | ); 10 | 11 | export default Loader; 12 | -------------------------------------------------------------------------------- /src/components/Modal/AccountModal/AccountModal.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import { GistsListResponseData } from '@octokit/types'; 4 | 5 | import { RootState } from 'store/types'; 6 | import { showModal } from 'store/modal/actions'; 7 | import { ERROR_MODAL } from 'components/Modal/ModalOverlay/constants'; 8 | 9 | import AuthTokenPanel from './AuthTokenPanel'; 10 | import GistSelectorPanel from './GistSelectorPanel'; 11 | 12 | const STEPS = { 13 | AUTH_TOKEN: 0, 14 | GIST_SELECTOR: 1, 15 | }; 16 | 17 | type AccountModalProps = { 18 | onHideModal: () => void; 19 | onSetAuthToken: (token: string) => Promise; 20 | onSynchronizeGist: (backupLocalSnippets: boolean, token: string, id: string) => Promise; 21 | onCreateBackupGist: (description: string, token: string) => Promise; 22 | onDeleteAuthData: () => void; 23 | }; 24 | 25 | type PanelType = { 26 | component: React.ElementType; 27 | props: unknown; 28 | }; 29 | 30 | const AccountModal: React.FC = ({ 31 | onHideModal, 32 | onSetAuthToken, 33 | onSynchronizeGist, 34 | onCreateBackupGist, 35 | onDeleteAuthData, 36 | }) => { 37 | const dispatch = useDispatch(); 38 | 39 | const { token, backupGistId, gists, lastSychronizedGistDate } = useSelector((state: RootState) => state.auth); 40 | const { loading } = useSelector((state: RootState) => state.ui); 41 | 42 | const [authToken, setAuthToken] = useState(token); 43 | const [gistId, setGistId] = useState(gists.length > 0 ? gists[0].id : ''); 44 | const [gistDescription, setGistDescription] = useState(''); 45 | const [backupLocalSnippets, setBackupLocalSnippets] = useState(false); 46 | const [step, setStep] = useState(token ? STEPS.GIST_SELECTOR : STEPS.AUTH_TOKEN); 47 | 48 | const handleAuthTokenChange = (event: React.ChangeEvent): void => { 49 | setAuthToken(event.target.value); 50 | }; 51 | 52 | const handleGistDescriptionChange = (event: React.ChangeEvent): void => { 53 | setGistDescription(event.target.value); 54 | }; 55 | 56 | const handleBackupLocalSnippetsChange = (event: React.ChangeEvent): void => { 57 | setBackupLocalSnippets(event.target.checked); 58 | }; 59 | 60 | const handleGistSelect = (event: React.ChangeEvent): void => { 61 | setGistId(event.currentTarget.value); 62 | }; 63 | 64 | const handleAuthToken = (): void => { 65 | onSetAuthToken(authToken).then((gists) => { 66 | setGistId(gists.length > 0 ? gists[0].id : ''); 67 | nextStep(); 68 | }); 69 | }; 70 | 71 | const handleCreateGist = (): void => { 72 | onCreateBackupGist(gistDescription, authToken).then(() => { 73 | handleClose(); 74 | }); 75 | }; 76 | 77 | const handleSynchronizeGist = (): void => { 78 | onSynchronizeGist(backupLocalSnippets, authToken, gistId) 79 | .then(() => { 80 | handleClose(); 81 | }) 82 | .catch((error) => { 83 | handleClose(); 84 | dispatch(showModal(ERROR_MODAL, { error })); 85 | }); 86 | }; 87 | 88 | const handleDeleteAuthData = (): void => { 89 | onDeleteAuthData(); 90 | handleClose(); 91 | }; 92 | 93 | const nextStep = (): void => { 94 | if (step < panels.length - 1) { 95 | setStep(step + 1); 96 | } 97 | }; 98 | 99 | const handleClose = (): void => { 100 | setStep(authToken ? STEPS.GIST_SELECTOR : STEPS.AUTH_TOKEN); 101 | onHideModal(); 102 | }; 103 | 104 | const renderPanel = (): React.ReactElement | null => { 105 | const panelConfig = panels[step]; 106 | 107 | if (!panelConfig) { 108 | return null; 109 | } 110 | 111 | const Panel = panelConfig.component; 112 | const panelProps = panelConfig.props; 113 | 114 | return Panel ? : null; 115 | }; 116 | 117 | const panels: PanelType[] = [ 118 | { 119 | component: AuthTokenPanel, 120 | props: { 121 | authToken: authToken, 122 | onAuthTokenChange: handleAuthTokenChange, 123 | onAccept: handleAuthToken, 124 | loading, 125 | }, 126 | }, 127 | { 128 | component: GistSelectorPanel, 129 | props: { 130 | remoteGists: gists, 131 | gistDescription: gistDescription, 132 | gistId: gistId, 133 | backupGistId: backupGistId, 134 | backupLocalSnippets: backupLocalSnippets, 135 | lastSychronizedGistDate: lastSychronizedGistDate, 136 | onGistSelect: handleGistSelect, 137 | onGistDescriptionChange: handleGistDescriptionChange, 138 | onBackupLocalSnippetsChange: handleBackupLocalSnippetsChange, 139 | onSynchronizeGist: handleSynchronizeGist, 140 | onCreateGist: handleCreateGist, 141 | onDeleteAuthData: handleDeleteAuthData, 142 | loading, 143 | }, 144 | }, 145 | ]; 146 | 147 | return <>{renderPanel()}; 148 | }; 149 | 150 | export default AccountModal; 151 | -------------------------------------------------------------------------------- /src/components/Modal/AccountModal/AuthTokenPanel.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { Button, InputGroup, FormGroup, Classes } from '@blueprintjs/core'; 3 | 4 | import styles from '../Panel.module.scss'; 5 | 6 | const cx = classNames.bind(styles); 7 | 8 | type AuthTokenPanelProps = { 9 | authToken: string; 10 | onAuthTokenChange: (event: React.ChangeEvent) => void; 11 | onAccept: () => void; 12 | loading: boolean; 13 | }; 14 | 15 | const AuthTokenPanel: React.FC = ({ authToken, onAuthTokenChange, onAccept, loading }) => { 16 | return ( 17 |
18 | 19 | 20 | 21 | 22 |
24 | ); 25 | }; 26 | 27 | export default AuthTokenPanel; 28 | -------------------------------------------------------------------------------- /src/components/Modal/AccountModal/GistSelectorPanel.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { GistsListResponseData } from '@octokit/types'; 3 | import { 4 | InputGroup, 5 | FormGroup, 6 | Button, 7 | HTMLSelect, 8 | Divider, 9 | Classes, 10 | Callout, 11 | H5, 12 | Intent, 13 | Checkbox, 14 | AnchorButton, 15 | } from '@blueprintjs/core'; 16 | 17 | import styles from '../Panel.module.scss'; 18 | 19 | const cx = classNames.bind(styles); 20 | 21 | type GistSelectorPanelProps = { 22 | remoteGists: GistsListResponseData; 23 | gistDescription: string; 24 | gistId: string; 25 | backupGistId: string; 26 | backupLocalSnippets: boolean; 27 | lastSychronizedGistDate: string; 28 | onGistSelect: (event: React.ChangeEvent) => void; 29 | onGistDescriptionChange: (event: React.ChangeEvent) => void; 30 | onBackupLocalSnippetsChange: (event: React.ChangeEvent) => void; 31 | onSynchronizeGist: () => void; 32 | onCreateGist: () => void; 33 | onDeleteAuthData: () => void; 34 | loading: boolean; 35 | }; 36 | 37 | const GistSelectorPanel: React.FC = ({ 38 | remoteGists, 39 | gistDescription, 40 | gistId, 41 | backupGistId, 42 | backupLocalSnippets, 43 | lastSychronizedGistDate, 44 | onGistSelect, 45 | onGistDescriptionChange, 46 | onBackupLocalSnippetsChange, 47 | onSynchronizeGist, 48 | onCreateGist, 49 | onDeleteAuthData, 50 | loading, 51 | }) => { 52 | const gistItems = remoteGists.map((gist) => { 53 | const keys = Object.keys(gist.files); 54 | const title = keys[keys.length - 1]; 55 | return { label: title, value: gist.id }; 56 | }); 57 | 58 | const renderGistCreator = (): React.ReactElement | null => { 59 | if (backupGistId) { 60 | return null; 61 | } 62 | 63 | return ( 64 | <> 65 |
Create new Gist
66 | 67 | 68 | 69 | 70 | 71 | 72 | 43 | 44 |
47 |
50 |
59 | 64 | 70 | 74 | 75 | 76 |
77 |
78 |
81 |
90 | 95 | 101 | 105 | 106 | 107 |
108 |
109 |
112 |
121 | 126 | 132 | 136 | 137 | 138 |
139 |
140 |
143 |
152 | 157 | 163 | 167 | 168 | 169 |
170 |
171 |
174 |
183 | 188 | 194 | 198 | 199 | 200 |
201 |
202 |
203 |
206 | 215 |
219 | 224 | 230 | 234 | 235 | 236 |
239 |
240 | 241 |
244 |
247 | 325 | 329 | 335 | 336 | Open dropdown 337 | 338 | 342 | 343 | 344 |
345 |
346 | `; 347 | -------------------------------------------------------------------------------- /src/components/SnippetList/Resizer.tsx: -------------------------------------------------------------------------------- 1 | import { memo, CSSProperties } from 'react'; 2 | 3 | type ResizerProps = { 4 | onMouseDown?: (event: React.MouseEvent) => void; 5 | }; 6 | 7 | const style: CSSProperties = { 8 | width: '4px', 9 | height: '100%', 10 | top: 0, 11 | right: '-2px', 12 | marginLeft: 'auto', 13 | position: 'absolute', 14 | cursor: 'col-resize', 15 | }; 16 | 17 | const Resizer: React.FC = ({ onMouseDown }) => ; 18 | 19 | export default memo(Resizer); 20 | -------------------------------------------------------------------------------- /src/components/SnippetList/ScrollableWrapper/ScrollableWrapper.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | position: relative; 3 | flex: 1; 4 | display: flex; 5 | min-height: 0; 6 | } 7 | 8 | .content { 9 | width: 100%; 10 | overflow-y: scroll; 11 | } 12 | 13 | .shadow { 14 | position: absolute; 15 | height: 4px; 16 | width: 100%; 17 | opacity: 0; 18 | transition: opacity 0.2s ease-out; 19 | 20 | &.active { 21 | opacity: 1; 22 | } 23 | 24 | &.top { 25 | top: 0; 26 | box-shadow: rgba(16, 22, 26, 0.8) 0 6px 4px -6px inset; 27 | } 28 | 29 | &.bottom { 30 | bottom: 0; 31 | box-shadow: rgba(16, 22, 26, 0.8) 0px -6px 4px -6px inset; 32 | } 33 | } -------------------------------------------------------------------------------- /src/components/SnippetList/ScrollableWrapper/ScrollableWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | 3 | import styles from './ScrollableWrapper.module.scss'; 4 | 5 | type ScrollableWrapperProps = { 6 | children: React.ReactElement; 7 | topShadow: boolean; 8 | bottomShadow: boolean; 9 | alwaysOn: boolean; 10 | }; 11 | 12 | const ScrollableWrapper: React.FC = ({ 13 | children, 14 | topShadow = true, 15 | bottomShadow = true, 16 | alwaysOn = false, 17 | }) => { 18 | const [top, setActiveTop] = useState(alwaysOn); 19 | const [bottom, setActiveBottom] = useState(alwaysOn); 20 | 21 | const contentRef = useRef(null); 22 | 23 | useEffect(() => { 24 | if (bottomShadow && contentRef.current) { 25 | const { 26 | current: { scrollHeight, clientHeight }, 27 | } = contentRef; 28 | 29 | if (scrollHeight > clientHeight) { 30 | setActiveBottom(true); 31 | } 32 | } 33 | }, [bottomShadow]); 34 | 35 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 36 | const handleScroll = ({ target }: any): void => { 37 | if (alwaysOn) { 38 | return; 39 | } 40 | 41 | const { scrollHeight, clientHeight, scrollTop } = target; 42 | 43 | if (scrollHeight - scrollTop === clientHeight) { 44 | setActiveBottom(false); 45 | return; 46 | } 47 | 48 | if (scrollTop === 0) { 49 | setActiveTop(false); 50 | return; 51 | } 52 | 53 | if (top && bottom) { 54 | return; 55 | } 56 | 57 | setActiveTop(true); 58 | setActiveBottom(true); 59 | }; 60 | 61 | return ( 62 |
63 |
64 | {topShadow &&
} 65 | 66 | {children} 67 | 68 | {bottomShadow &&
} 69 |
70 |
71 | ); 72 | }; 73 | 74 | export default ScrollableWrapper; 75 | -------------------------------------------------------------------------------- /src/components/SnippetList/SnippetList.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | display: flex; 4 | flex-direction: column; 5 | height: 100vh; 6 | background-color: var(--background-color-secondary); 7 | border-right: var(--list-container-border); 8 | z-index: 10; 9 | } 10 | 11 | .divider { 12 | height: 1px; 13 | background-image: var(--divider); 14 | } 15 | 16 | .elementEnter { 17 | opacity: 0.01; 18 | height: 0 !important; 19 | } 20 | 21 | .elementEnter.elementEnterActive { 22 | opacity: 1; 23 | height: var(--snippet-list-element-height) !important; 24 | transition: opacity 150ms ease, height 200ms ease; 25 | } 26 | 27 | .elementExit { 28 | opacity: 1; 29 | height: var(--snippet-list-element-height); 30 | } 31 | 32 | .elementExit.elementExitActive { 33 | opacity: 0.01; 34 | height: 0; 35 | transition: opacity 150ms ease, height 200ms ease; 36 | } 37 | -------------------------------------------------------------------------------- /src/components/SnippetList/SnippetList.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef, useCallback } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { TransitionGroup, CSSTransition } from 'react-transition-group'; 4 | 5 | import SnippetListHeader from './SnippetListHeader/SnippetListHeader'; 6 | import SnippetListElement from './SnippetListElement/SnippetListElement'; 7 | import ScrollableWrapper from './ScrollableWrapper/ScrollableWrapper'; 8 | import Resizer from './Resizer'; 9 | import { menuOpen } from './contextMenu'; 10 | 11 | import { AppDispatch, RootState } from 'store/types'; 12 | import { resizeLeftPanel } from 'store/ui/actions'; 13 | import { setCurrentSnippet } from 'store/snippets/actions'; 14 | 15 | import styles from './SnippetList.module.scss'; 16 | 17 | const SnippetList: React.FC = () => { 18 | const dispatch = useDispatch(); 19 | 20 | const [query, setQuery] = useState(''); 21 | 22 | const { leftPanelWidth } = useSelector((state: RootState) => state.ui); 23 | const { current, list } = useSelector((state: RootState) => state.snippets); 24 | 25 | const resizerXPosition = useRef(null); 26 | const panelWidth = useRef(null); 27 | 28 | const handleElementContextMenu = useCallback((): void => { 29 | menuOpen(); 30 | }, []); 31 | 32 | const handleOnMouseDown = useCallback((event: React.MouseEvent): void => { 33 | resizerXPosition.current = event.clientX; 34 | panelWidth.current = event.clientX; 35 | }, []); 36 | 37 | const handleSearchChange = useCallback((value: string): void => { 38 | setQuery(value); 39 | }, []); 40 | 41 | const handleChangeSnippet = useCallback( 42 | (id: number): void => { 43 | dispatch(setCurrentSnippet(id)); 44 | }, 45 | [dispatch], 46 | ); 47 | 48 | useEffect(() => { 49 | const mouseUp = (): void => { 50 | resizerXPosition.current = null; 51 | }; 52 | 53 | const mouseMove = (event: MouseEvent): void => { 54 | if (!resizerXPosition.current || !panelWidth.current) { 55 | return; 56 | } 57 | 58 | const newPosition = panelWidth.current + event.clientX - resizerXPosition.current; 59 | // TODO Remove hardcoded values 60 | if (newPosition <= 600) { 61 | dispatch(resizeLeftPanel(Math.max(200, newPosition))); 62 | } 63 | }; 64 | 65 | document.addEventListener('mouseup', mouseUp); 66 | document.addEventListener('mousemove', mouseMove); 67 | 68 | return (): void => { 69 | document.removeEventListener('mouseup', mouseUp); 70 | document.removeEventListener('mousemove', mouseMove); 71 | }; 72 | }, [dispatch]); 73 | 74 | const renderElements = (): React.ReactElement[] => { 75 | const filtered = list.filter((element) => element.title.toLowerCase().includes(query.toLowerCase())); 76 | 77 | return filtered.map((element) => { 78 | return ( 79 | 89 | 97 | 98 | ); 99 | }); 100 | }; 101 | 102 | return ( 103 |
104 | 105 | 106 | {list && ( 107 | 108 | {renderElements()} 109 | 110 | )} 111 | 112 | 113 |
114 | ); 115 | }; 116 | 117 | export default SnippetList; 118 | -------------------------------------------------------------------------------- /src/components/SnippetList/SnippetListElement/SnippetListElement.module.scss: -------------------------------------------------------------------------------- 1 | @import "@blueprintjs/core/lib/scss/variables"; 2 | 3 | .root { 4 | height: var(--snippet-list-element-height); 5 | 6 | &.active { 7 | background-image: var(--active-snippet); 8 | 9 | .tag { 10 | box-shadow: 0 -0.5px 0px 1px var(--active-snippet-tag-border); 11 | } 12 | } 13 | } 14 | 15 | .content { 16 | position: relative; 17 | display: flex; 18 | flex-direction: row; 19 | align-items: center; 20 | justify-content: space-between; 21 | height: 100%; 22 | padding: 5px 15px 5px 15px; 23 | border-image: var(--divider) 1; 24 | border-bottom-style: solid; 25 | border-bottom-width: 1px; 26 | } 27 | 28 | .tag { 29 | position: absolute; 30 | width: 10px; 31 | height: 10px; 32 | right: 5px; 33 | border-radius: 50%; 34 | box-shadow: 0 -0.5px 0px 1px var(--background-color-secondary); 35 | } 36 | 37 | .sourceIcon { 38 | padding: 5px 10px; 39 | } 40 | -------------------------------------------------------------------------------- /src/components/SnippetList/SnippetListElement/SnippetListElement.test.tsx: -------------------------------------------------------------------------------- 1 | import { configure, shallow } from 'enzyme'; 2 | import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; 3 | import renderer from 'react-test-renderer'; 4 | 5 | import SnippetListElement from './SnippetListElement'; 6 | 7 | configure({ adapter: new Adapter() }); 8 | 9 | describe('', () => { 10 | const mockProps = { 11 | snippetId: 1, 12 | snippetTags: '1,2,3', 13 | snippetTitle: 'Test', 14 | currentlySelectedId: 1, 15 | onChangeSnippet: jest.fn(), 16 | onContextMenu: jest.fn(), 17 | }; 18 | 19 | it('renders without crashing', () => { 20 | shallow(); 21 | }); 22 | 23 | it('matches snapshot', () => { 24 | const wrapper = renderer.create(); 25 | expect(wrapper).toMatchSnapshot(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/components/SnippetList/SnippetListElement/SnippetListElement.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | import { getColorTags } from 'models/Snippet'; 5 | 6 | import styles from './SnippetListElement.module.scss'; 7 | 8 | const cx = classNames.bind(styles); 9 | 10 | type SnippetListElementProps = { 11 | snippetId: number; 12 | snippetTags: string; 13 | snippetTitle: string; 14 | currentlySelectedId: number | null; 15 | onChangeSnippet: (id: number) => void; 16 | onContextMenu: (id: number) => void; 17 | }; 18 | 19 | const SnippetListElement: React.FC = ({ 20 | snippetId, 21 | snippetTags, 22 | snippetTitle, 23 | currentlySelectedId, 24 | onChangeSnippet, 25 | onContextMenu, 26 | }) => { 27 | const handleClick = (): void => { 28 | if (currentlySelectedId !== snippetId) { 29 | onChangeSnippet(snippetId); 30 | } 31 | }; 32 | 33 | const handleContextMenu = (): void => { 34 | handleClick(); 35 | onContextMenu(snippetId); 36 | }; 37 | 38 | const renderTags = (): React.ReactElement[] | null => { 39 | if (!snippetTags) { 40 | return null; 41 | } 42 | 43 | const MAX_TAG_COUNT = 3; 44 | const tags = getColorTags(snippetTags); 45 | const recent = tags.slice(Math.max(tags.length - MAX_TAG_COUNT, 0)); 46 | 47 | return recent.map((tag, index) => { 48 | return
; 49 | }); 50 | }; 51 | 52 | const listElementClass = cx({ 53 | [styles.root]: true, 54 | active: currentlySelectedId === snippetId, 55 | 'bp3-text-muted': currentlySelectedId !== snippetId, 56 | }); 57 | 58 | return ( 59 |
60 |
61 | {snippetTitle} 62 | {renderTags()} 63 |
64 |
65 | ); 66 | }; 67 | 68 | export default memo(SnippetListElement); 69 | -------------------------------------------------------------------------------- /src/components/SnippetList/SnippetListElement/__snapshots__/SnippetListElement.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` matches snapshot 1`] = ` 4 |
9 |
12 | 15 | Test 16 | 17 |
26 |
35 |
44 |
45 |
46 | `; 47 | -------------------------------------------------------------------------------- /src/components/SnippetList/SnippetListHeader/SnippetListHeader.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | display: flex; 3 | flex-direction: column; 4 | -webkit-app-region: drag; 5 | } 6 | 7 | .container, 8 | .searchContainer { 9 | display: flex; 10 | justify-content: flex-end; 11 | height: var(--header-height); 12 | margin-right: 10px; 13 | align-items: center; 14 | } 15 | 16 | .searchContainer { 17 | margin-bottom: 8px; 18 | margin-left: 11px; 19 | margin-right: 10px; 20 | } 21 | -------------------------------------------------------------------------------- /src/components/SnippetList/SnippetListHeader/SnippetListHeader.test.tsx: -------------------------------------------------------------------------------- 1 | import { Provider } from 'react-redux'; 2 | import { configure, shallow } from 'enzyme'; 3 | import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; 4 | import { configureMockStore } from '@jedmao/redux-mock-store'; 5 | import renderer from 'react-test-renderer'; 6 | 7 | import SnippetListHeader from './SnippetListHeader'; 8 | 9 | configure({ adapter: new Adapter() }); 10 | 11 | describe('', () => { 12 | const mockStore = configureMockStore(); 13 | const mockProps = { 14 | query: '', 15 | onSearchChange: jest.fn(), 16 | }; 17 | const initialState = { auth: { token: 'TOKEN' } }; 18 | 19 | let store; 20 | 21 | it('renders without crashing', () => { 22 | store = mockStore(initialState); 23 | shallow( 24 | 25 | 26 | , 27 | ); 28 | }); 29 | 30 | it('matches snapshot', () => { 31 | store = mockStore(initialState); 32 | const wrapper = renderer.create( 33 | 34 | 35 | , 36 | ); 37 | expect(wrapper).toMatchSnapshot(); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/components/SnippetList/SnippetListHeader/SnippetListHeader.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useState } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import { Button, ButtonGroup, InputGroup } from '@blueprintjs/core'; 4 | import { GistsListResponseData } from '@octokit/types'; 5 | 6 | import { RootState, AppDispatch } from 'store/types'; 7 | import { showModal } from 'store/modal/actions'; 8 | import { setAuthToken, deleteAuthData } from 'store/auth/actions'; 9 | import { synchronizeGist, createBackupGist, addSnippet } from 'store/snippets/actions'; 10 | import { ACCOUNT_MODAL } from 'components/Modal/ModalOverlay/constants'; 11 | 12 | import styles from './SnippetListHeader.module.scss'; 13 | 14 | type SnippetListHeaderProps = { 15 | query: string; 16 | onSearchChange: (value: string) => void; 17 | }; 18 | 19 | const SnippetListHeader: React.FC = ({ query, onSearchChange }) => { 20 | const dispatch = useDispatch(); 21 | const { token } = useSelector((state: RootState) => state.auth); 22 | const [loading, setLoading] = useState(false); 23 | 24 | const handleAddSnippet = (): void => { 25 | dispatch(addSnippet()); 26 | }; 27 | 28 | const handleDeleteAuthData = (): void => { 29 | dispatch(deleteAuthData()); 30 | }; 31 | 32 | const handleSetAuthToken = (token: string): Promise => { 33 | return dispatch(setAuthToken(token)); 34 | }; 35 | 36 | const handleCreateBackupGist = (description: string, token: string): Promise => { 37 | return dispatch(createBackupGist(description, token)); 38 | }; 39 | 40 | const handleSynchronizeGist = (backupLocalSnippets: boolean, token: string, id: string): Promise => { 41 | return dispatch(synchronizeGist(backupLocalSnippets, token, id)); 42 | }; 43 | 44 | const handleSearchOnChange = ({ target: { value } }: React.ChangeEvent): void => { 45 | onSearchChange(value); 46 | }; 47 | 48 | const handleClearSearch = (): void => { 49 | onSearchChange(''); 50 | }; 51 | 52 | const handleAccountModalOpen = (): void => { 53 | const dispatchShowModalAction = (): void => { 54 | dispatch( 55 | showModal(ACCOUNT_MODAL, { 56 | onSetAuthToken: handleSetAuthToken, 57 | onSynchronizeGist: handleSynchronizeGist, 58 | onCreateBackupGist: handleCreateBackupGist, 59 | onDeleteAuthData: handleDeleteAuthData, 60 | }), 61 | ); 62 | setLoading(false); 63 | }; 64 | 65 | setLoading(true); 66 | 67 | if (!token) { 68 | dispatchShowModalAction(); 69 | return; 70 | } 71 | 72 | handleSetAuthToken(token).then(() => { 73 | dispatchShowModalAction(); 74 | }); 75 | }; 76 | 77 | const renderClearSearchButton = (): React.ReactElement | undefined => { 78 | if (query) { 79 | return
91 | 92 |
93 | 101 |
102 |
103 | ); 104 | }; 105 | 106 | export default memo(SnippetListHeader); 107 | -------------------------------------------------------------------------------- /src/components/SnippetList/SnippetListHeader/__snapshots__/SnippetListHeader.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` matches snapshot 1`] = ` 4 |
7 |
10 |
13 | 40 | 66 |
67 |
68 |
71 |
74 | 79 | 85 | 89 | 90 | 91 | 104 |
105 |
106 |
107 | `; 108 | -------------------------------------------------------------------------------- /src/components/SnippetList/contextMenu.ts: -------------------------------------------------------------------------------- 1 | import store from 'store'; 2 | import { ipcRenderer } from 'electron'; 3 | 4 | import { AppDispatch } from 'store/types'; 5 | import { deleteSnippet } from 'store/snippets/actions'; 6 | 7 | const dispatch: AppDispatch = store.dispatch; 8 | 9 | ipcRenderer.addListener('DELETE_SNIPPET', () => dispatch(deleteSnippet())); 10 | 11 | export const menuOpen = (): void => { 12 | ipcRenderer.invoke('OPEN_CONTEXT_MENU'); 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/Theme/Theme.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, createRef } from 'react'; 2 | import classNames from 'classnames'; 3 | 4 | import { Light, Dark } from './themes'; 5 | 6 | type ThemeProps = { 7 | mode: string; 8 | children: React.ReactElement; 9 | className: string; 10 | }; 11 | 12 | export enum ThemeType { 13 | LIGHT = 'light', 14 | DARK = 'dark', 15 | } 16 | 17 | const Theme: React.FC = ({ mode, children, className }) => { 18 | const variables = mode === ThemeType.DARK ? Dark : Light; 19 | const containerRef = createRef(); 20 | 21 | useEffect(() => { 22 | const updateCSSVariables = (): void => { 23 | if (!variables) { 24 | return; 25 | } 26 | 27 | Object.entries(variables).forEach(([prop, value]) => { 28 | const node = containerRef.current; 29 | if (node) { 30 | node.style.setProperty(prop, value); 31 | } 32 | }); 33 | }; 34 | 35 | updateCSSVariables(); 36 | }, [containerRef, variables]); 37 | 38 | const containerClassNames = classNames( 39 | { 40 | 'bp3-dark': mode === ThemeType.DARK, 41 | 'bp3-focus-disabled': true, 42 | }, 43 | className, 44 | ); 45 | 46 | return ( 47 |
48 | {children} 49 |
50 | ); 51 | }; 52 | 53 | export default Theme; 54 | -------------------------------------------------------------------------------- /src/components/Theme/themes.ts: -------------------------------------------------------------------------------- 1 | import { Colors } from '@blueprintjs/core'; 2 | 3 | const root = { 4 | '--header-height': '37px', 5 | '--editor-header-height': '47px', 6 | '--snippet-list-element-height': '40px', 7 | '--status-bar-height': '30px', 8 | '--color-selected': Colors.BLUE3, 9 | }; 10 | 11 | const light = { 12 | ...root, 13 | '--background-color-primary': Colors.WHITE, 14 | '--background-color-secondary': Colors.LIGHT_GRAY5, 15 | 16 | '--icon-color': Colors.GRAY1, 17 | 18 | '--list-container-border': 'solid 1px rgba(16, 22, 26, .15)', 19 | '--tag-border': 'solid 1px rgba(16, 22, 26, .2)', 20 | '--tag-hover-background': 'rgba(167, 182, 194, 0.3)', 21 | 22 | '--divider': `linear-gradient( 23 | 90deg, 24 | rgba(16, 22, 26, 0) 0, 25 | rgba(16, 22, 26, .15) 40% 26 | )`, 27 | '--active-snippet': `linear-gradient( 28 | 90deg, 29 | rgba(167, 182, 194, 0) 0, 30 | rgba(167, 182, 194, .3) 40% 31 | )`, 32 | '--active-snippet-tag-border': Colors.LIGHT_GRAY2, 33 | }; 34 | 35 | const dark = { 36 | ...root, 37 | '--background-color-primary': Colors.DARK_GRAY3, 38 | '--background-color-secondary': Colors.DARK_GRAY5, 39 | 40 | '--icon-color': Colors.GRAY4, 41 | 42 | '--list-container-border': 'solid 1px rgba(16, 22, 26, .5)', 43 | '--tag-border': 'solid 1px hsla(0, 0%, 100%, .2)', 44 | '--tag-hover-background': 'rgba(138, 155, 168, 0.15)', 45 | 46 | '--divider': `linear-gradient( 47 | 90deg, 48 | rgba(16, 22, 26, 0) 0, 49 | rgba(16, 22, 26, .4) 80%, 50 | rgba(16, 22, 26, .5) 100% 51 | )`, 52 | '--active-snippet': `linear-gradient( 53 | 90deg, 54 | rgba(48, 64, 77, 0) 0, 55 | #30404d 40% 56 | )`, 57 | '--active-snippet-tag-border': Colors.DARK_GRAY4, 58 | }; 59 | 60 | export { root, light as Light, dark as Dark }; 61 | -------------------------------------------------------------------------------- /src/db/__mocks__/snippets.ts: -------------------------------------------------------------------------------- 1 | import { SnippetInterface } from 'models/Snippet'; 2 | 3 | const mockSnippets = [ 4 | { 5 | id: 0, 6 | title: 'test0', 7 | }, 8 | { 9 | id: 1, 10 | title: 'test1', 11 | }, 12 | ]; 13 | 14 | export const snippetsDb = { 15 | add: jest.fn(), 16 | update: jest.fn(), 17 | updateAll: jest.fn(), 18 | remove: jest.fn(), 19 | removeQuery: jest.fn(), 20 | findAll: (callback: (items: SnippetInterface[]) => void): void => { 21 | callback(mockSnippets); 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /src/db/app.ts: -------------------------------------------------------------------------------- 1 | import Datastore from 'nedb'; 2 | 3 | import { dbFactory, dbAdd, dbUpdate, dbRemove, dbFind } from './db'; 4 | 5 | import { DB_APP } from './constants'; 6 | 7 | let db: Datastore; 8 | 9 | const loadDatabase = (path: string): void => { 10 | if (!db) { 11 | db = dbFactory(DB_APP, path); 12 | } 13 | }; 14 | 15 | const add = (objArray: unknown | unknown[]): void => { 16 | dbAdd(db, objArray); 17 | }; 18 | 19 | const update = (obj: { id: number }): void => { 20 | dbUpdate(db, obj); 21 | }; 22 | 23 | const remove = (id: number): void => { 24 | dbRemove(db, id); 25 | }; 26 | 27 | const find = (id: number): void => { 28 | dbFind(db, id); 29 | }; 30 | 31 | export const appDb = { 32 | loadDatabase, 33 | add, 34 | update, 35 | remove, 36 | find, 37 | }; 38 | -------------------------------------------------------------------------------- /src/db/constants.ts: -------------------------------------------------------------------------------- 1 | /* DB */ 2 | const DB_APP = 'app'; 3 | const DB_SNIPPETS = 'snippets'; 4 | 5 | export { DB_APP, DB_SNIPPETS }; 6 | -------------------------------------------------------------------------------- /src/db/db.ts: -------------------------------------------------------------------------------- 1 | import Datastore from 'nedb'; 2 | 3 | const dbFactory = (name: string, path: string): Datastore => { 4 | const filename = `${process.env.NODE_ENV === 'development' ? '.' : path}/data/${name}.db`; 5 | const db = new Datastore({ 6 | filename, 7 | autoload: true, 8 | }); 9 | 10 | db.ensureIndex({ fieldName: 'id', unique: true }); 11 | 12 | return db; 13 | }; 14 | 15 | const dbAdd = (db: Datastore, objArray: unknown | unknown[]): void => { 16 | db.insert(objArray, (err) => { 17 | if (err) throw new Error(err.message); 18 | }); 19 | }; 20 | 21 | const dbUpdate = (db: Datastore, obj: { id: number }): void => { 22 | db.update({ id: obj.id }, { ...obj }, {}, (err) => { 23 | if (err) throw new Error(err.message); 24 | }); 25 | }; 26 | 27 | const dbUpdateAll = (db: Datastore, changes: unknown): void => { 28 | db.update({}, { $set: changes }, { multi: true }, (err) => { 29 | if (err) throw new Error(err.message); 30 | }); 31 | }; 32 | 33 | const dbRemove = (db: Datastore, id: number): void => { 34 | db.remove({ id }, (err) => { 35 | if (err) throw new Error(err.message); 36 | }); 37 | }; 38 | 39 | const dbRemoveQuery = (db: Datastore, query: unknown): void => { 40 | db.remove(query, { multi: true }, (err) => { 41 | if (err) throw new Error(err.message); 42 | }); 43 | }; 44 | 45 | const dbFind = (db: Datastore, id: number): void => { 46 | db.find({ id: id }, {}, (err) => { 47 | if (err) throw new Error(err.message); 48 | }); 49 | }; 50 | 51 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 52 | const dbFindAll = (db: Datastore, callback: (items: any) => void): void => { 53 | db.find({}, {}, (err, items) => { 54 | if (err) throw new Error(err.message); 55 | callback(items); 56 | }); 57 | }; 58 | 59 | export { dbFactory, dbAdd, dbUpdate, dbRemove, dbRemoveQuery, dbFind, dbFindAll, dbUpdateAll }; 60 | -------------------------------------------------------------------------------- /src/db/snippets.ts: -------------------------------------------------------------------------------- 1 | import Datastore from 'nedb'; 2 | 3 | import { dbFactory, dbAdd, dbUpdate, dbRemove, dbRemoveQuery, dbFindAll, dbUpdateAll } from './db'; 4 | import { DB_SNIPPETS } from './constants'; 5 | import { SnippetInterface } from 'models/Snippet'; 6 | 7 | let db: Datastore; 8 | 9 | const loadDatabase = (path: string): void => { 10 | if (!db) { 11 | db = dbFactory(DB_SNIPPETS, path); 12 | } 13 | }; 14 | 15 | const add = (objArray: unknown | unknown[]): void => { 16 | dbAdd(db, objArray); 17 | }; 18 | 19 | const update = (obj: { id: number }): void => { 20 | dbUpdate(db, obj); 21 | }; 22 | 23 | const remove = (id: number): void => { 24 | dbRemove(db, id); 25 | }; 26 | 27 | const findAll = (callback: (items: SnippetInterface[]) => void): void => { 28 | dbFindAll(db, callback); 29 | }; 30 | 31 | const updateAll = (changes: unknown): void => { 32 | dbUpdateAll(db, changes); 33 | }; 34 | 35 | const removeQuery = (query: unknown): void => { 36 | dbRemoveQuery(db, query); 37 | }; 38 | 39 | export const snippetsDb = { 40 | loadDatabase, 41 | add, 42 | update, 43 | remove, 44 | removeQuery, 45 | findAll, 46 | updateAll, 47 | }; 48 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | @import "~normalize.css"; 2 | @import "./svg-icon-overrides.scss"; // https://github.com/palantir/blueprint/issues/2976#issuecomment-479231949 3 | @import "~@blueprintjs/core/src/blueprint.scss"; 4 | @import "~@blueprintjs/popover2/lib/css/blueprint-popover2.css"; 5 | 6 | html { 7 | user-select: none; 8 | -webkit-user-select: none; 9 | -webkit-user-drag: none; 10 | cursor: default; 11 | } 12 | 13 | body { 14 | overflow: hidden; 15 | } 16 | 17 | input, button, textarea, :focus { 18 | outline: none; 19 | } 20 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import { Provider } from 'react-redux'; 3 | 4 | import App from 'App'; 5 | import store from 'store'; 6 | import * as serviceWorker from 'serviceWorker'; 7 | 8 | import './index.scss'; 9 | 10 | ReactDOM.render( 11 | 12 | 13 | , 14 | document.getElementById('root'), 15 | ); 16 | 17 | // If you want your app to work offline and load faster, you can change 18 | // unregister() to register() below. Note this comes with some pitfalls. 19 | // Learn more about service workers: https://bit.ly/CRA-PWA 20 | serviceWorker.unregister(); 21 | -------------------------------------------------------------------------------- /src/models/Snippet.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | 3 | import { TEXT } from './languages'; 4 | import { TAGS } from './tags'; 5 | 6 | export const sourceType = { 7 | LOCAL: 'local', 8 | GIST: 'gist', 9 | }; 10 | 11 | export interface SnippetInterface { 12 | id: number; 13 | title: string; 14 | source?: string; 15 | uuid?: string; 16 | tags?: string; 17 | description?: string; 18 | language?: string; 19 | content?: string; 20 | lastUpdated?: string; 21 | } 22 | 23 | /** Class representing a snippet. */ 24 | export default class Snippet implements SnippetInterface { 25 | id: number; 26 | title: string; 27 | source: string; 28 | uuid: string; 29 | tags: string; 30 | description: string; 31 | language: string; 32 | content: string; 33 | lastUpdated: string; 34 | 35 | /** 36 | * Create a snippet. 37 | * @param {object} args 38 | */ 39 | constructor(args: SnippetInterface) { 40 | const { 41 | id, 42 | title, 43 | source = sourceType.LOCAL, 44 | uuid = uuidv4(), 45 | tags = '', 46 | description = '', 47 | language = TEXT, 48 | content = '', 49 | lastUpdated = new Date().toISOString(), 50 | } = args; 51 | 52 | this.id = id; 53 | this.source = source; 54 | this.uuid = uuid; 55 | this.tags = tags; 56 | this.title = title; 57 | this.description = description; 58 | this.language = language; 59 | this.content = content; 60 | this.lastUpdated = lastUpdated; 61 | } 62 | } 63 | 64 | export const getColorTags = (tags: string): string[] => { 65 | const colorTags: string[] = []; 66 | const elements = tags.split(','); 67 | elements.forEach((snippetTag: string) => { 68 | const tag = TAGS.find((tag) => tag.key === snippetTag); 69 | if (tag) { 70 | colorTags.push(tag.color); 71 | } 72 | }); 73 | return colorTags; 74 | }; 75 | -------------------------------------------------------------------------------- /src/models/languages.ts: -------------------------------------------------------------------------------- 1 | export const TEXT = 'text'; 2 | export const JAVASCRIPT = 'javascript'; 3 | export const TYPESCRIPT = 'typescript'; 4 | export const JAVA = 'java'; 5 | export const XML = 'xml'; 6 | export const CSS = 'css'; 7 | export const SCSS = 'scss'; 8 | export const MARKDOWN = 'markdown'; 9 | export const SQL = 'sql'; 10 | export const JSON_MODE = 'json'; 11 | export const HTML = 'html'; 12 | export const CSHARP = 'csharp'; 13 | 14 | interface LanguageItem { 15 | [key: string]: { label: string; extension: string }; 16 | } 17 | 18 | const languages: LanguageItem = { 19 | [TEXT]: { 20 | label: 'Text', 21 | extension: '', 22 | }, 23 | [JAVASCRIPT]: { 24 | label: 'JavaScript', 25 | extension: 'js', 26 | }, 27 | [TYPESCRIPT]: { 28 | label: 'TypeScript', 29 | extension: 'ts', 30 | }, 31 | [JAVA]: { 32 | label: 'Java', 33 | extension: 'java', 34 | }, 35 | [XML]: { 36 | label: 'XML', 37 | extension: 'xml', 38 | }, 39 | [CSS]: { 40 | label: 'CSS', 41 | extension: 'css', 42 | }, 43 | [SCSS]: { 44 | label: 'SCSS', 45 | extension: 'scss', 46 | }, 47 | [MARKDOWN]: { 48 | label: 'Markdown', 49 | extension: 'md', 50 | }, 51 | [SQL]: { 52 | label: 'SQL', 53 | extension: 'sql', 54 | }, 55 | [JSON_MODE]: { 56 | label: 'JSON', 57 | extension: 'json', 58 | }, 59 | [HTML]: { 60 | label: 'HTML', 61 | extension: 'html', 62 | }, 63 | [CSHARP]: { 64 | label: 'C#', 65 | extension: 'cs', 66 | }, 67 | }; 68 | 69 | export { languages }; 70 | -------------------------------------------------------------------------------- /src/models/tags.ts: -------------------------------------------------------------------------------- 1 | export type Tag = { 2 | key: string; 3 | color: string; 4 | }; 5 | 6 | export const TAGS: Tag[] = [ 7 | { 8 | key: '1', 9 | color: '#F55656', 10 | }, 11 | { 12 | key: '2', 13 | color: '#F29D49', 14 | }, 15 | { 16 | key: '3', 17 | color: '#F2B824', 18 | }, 19 | { 20 | key: '4', 21 | color: '#15B371', 22 | }, 23 | { 24 | key: '5', 25 | color: '#2B95D6', 26 | }, 27 | ]; 28 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/), 19 | ); 20 | 21 | type Config = { 22 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 23 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 24 | }; 25 | 26 | export function register(config?: Config): void { 27 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 28 | // The URL constructor is available in all browsers that support SW. 29 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 30 | if (publicUrl.origin !== window.location.origin) { 31 | // Our service worker won't work if PUBLIC_URL is on a different origin 32 | // from what our page is served on. This might happen if a CDN is used to 33 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 34 | return; 35 | } 36 | 37 | window.addEventListener('load', () => { 38 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 39 | 40 | if (isLocalhost) { 41 | // This is running on localhost. Let's check if a service worker still exists or not. 42 | checkValidServiceWorker(swUrl, config); 43 | 44 | // Add some additional logging to localhost, pointing developers to the 45 | // service worker/PWA documentation. 46 | navigator.serviceWorker.ready.then(() => { 47 | console.log( 48 | 'This web app is being served cache-first by a service ' + 49 | 'worker. To learn more, visit https://bit.ly/CRA-PWA', 50 | ); 51 | }); 52 | } else { 53 | // Is not localhost. Just register service worker 54 | registerValidSW(swUrl, config); 55 | } 56 | }); 57 | } 58 | } 59 | 60 | function registerValidSW(swUrl: string, config?: Config) { 61 | navigator.serviceWorker 62 | .register(swUrl) 63 | .then((registration) => { 64 | registration.onupdatefound = () => { 65 | const installingWorker = registration.installing; 66 | if (installingWorker == null) { 67 | return; 68 | } 69 | installingWorker.onstatechange = () => { 70 | if (installingWorker.state === 'installed') { 71 | if (navigator.serviceWorker.controller) { 72 | // At this point, the updated precached content has been fetched, 73 | // but the previous service worker will still serve the older 74 | // content until all client tabs are closed. 75 | console.log( 76 | 'New content is available and will be used when all ' + 77 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.', 78 | ); 79 | 80 | // Execute callback 81 | if (config && config.onUpdate) { 82 | config.onUpdate(registration); 83 | } 84 | } else { 85 | // At this point, everything has been precached. 86 | // It's the perfect time to display a 87 | // "Content is cached for offline use." message. 88 | console.log('Content is cached for offline use.'); 89 | 90 | // Execute callback 91 | if (config && config.onSuccess) { 92 | config.onSuccess(registration); 93 | } 94 | } 95 | } 96 | }; 97 | }; 98 | }) 99 | .catch((error) => { 100 | console.error('Error during service worker registration:', error); 101 | }); 102 | } 103 | 104 | function checkValidServiceWorker(swUrl: string, config?: Config) { 105 | // Check if the service worker can be found. If it can't reload the page. 106 | fetch(swUrl, { 107 | headers: { 'Service-Worker': 'script' }, 108 | }) 109 | .then((response) => { 110 | // Ensure service worker exists, and that we really are getting a JS file. 111 | const contentType = response.headers.get('content-type'); 112 | if (response.status === 404 || (contentType != null && contentType.indexOf('javascript') === -1)) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then((registration) => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log('No internet connection found. App is running in offline mode.'); 126 | }); 127 | } 128 | 129 | export function unregister(): void { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then((registration) => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/store/auth/actions.test.ts: -------------------------------------------------------------------------------- 1 | import { setAuthToken } from 'store/auth/actions'; 2 | import { SET_GISTS } from 'store/auth/types'; 3 | import { SET_LOADING } from 'store/ui/types'; 4 | 5 | import mockStore from 'utils/test/mockStore'; 6 | import { GIST_ID, MOCK_TOKEN, MOCK_INVALID_TOKEN } from 'utils/test/mockSnippets'; 7 | 8 | jest.mock('utils/gistActions'); 9 | jest.mock('electron', () => { 10 | return { 11 | ipcRenderer: { 12 | send: jest.fn(), 13 | }, 14 | }; 15 | }); 16 | 17 | describe('auth actions', () => { 18 | let store: ReturnType; 19 | 20 | beforeEach(() => { 21 | store = mockStore({ 22 | auth: { 23 | lastSychronizedGistDate: new Date().toISOString(), 24 | }, 25 | }); 26 | }); 27 | 28 | it('should set authentication token', () => { 29 | return store.dispatch(setAuthToken(MOCK_TOKEN)).then(() => { 30 | const actions = store.getActions(); 31 | expect(actions[0]).toEqual({ type: SET_LOADING, loading: true }); 32 | expect(actions[1]).toEqual({ type: SET_GISTS, gists: [{ id: GIST_ID }] }); 33 | expect(actions[2]).toEqual({ type: SET_LOADING, loading: false }); 34 | }); 35 | }); 36 | 37 | it('should return error on authentication token setting', () => { 38 | return store.dispatch(setAuthToken(MOCK_INVALID_TOKEN)).catch((error) => { 39 | const actions = store.getActions(); 40 | expect(error).toEqual('ERROR'); 41 | expect(actions[0]).toEqual({ type: SET_LOADING, loading: true }); 42 | expect(actions[1]).toEqual({ type: SET_LOADING, loading: false }); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/store/auth/actions.ts: -------------------------------------------------------------------------------- 1 | import { GistsListResponseData } from '@octokit/types'; 2 | 3 | import { AppThunk } from 'store/types'; 4 | import { setLoading } from 'store/ui/actions'; 5 | import { listGists } from 'utils/gistActions'; 6 | 7 | import { SET_GH_DATA, SET_GISTS, CLEAR_GH_DATA, AuthActionTypes } from './types'; 8 | 9 | export const setGitHubDataAction = (data: { 10 | token: string; 11 | backupGistId: string; 12 | gistDate: string; 13 | }): AuthActionTypes => ({ 14 | type: SET_GH_DATA, 15 | token: data.token, 16 | backupGistId: data.backupGistId, 17 | lastSychronizedGistDate: data.gistDate, 18 | }); 19 | 20 | const setGistsAction = (gists: GistsListResponseData): AuthActionTypes => ({ 21 | type: SET_GISTS, 22 | gists, 23 | }); 24 | 25 | const clearAuthDataAction = (): AuthActionTypes => ({ 26 | type: CLEAR_GH_DATA, 27 | }); 28 | 29 | export const loadAuthData = (): AppThunk => { 30 | return (dispatch, _getState, ipcRenderer): void => { 31 | ipcRenderer.invoke('LOAD_GH_DATA').then((data) => { 32 | dispatch(setGitHubDataAction(data)); 33 | }); 34 | }; 35 | }; 36 | 37 | export const setAuthToken = (token: string): AppThunk> => { 38 | return (dispatch, getState): Promise => { 39 | const { 40 | auth: { backupGistId }, 41 | } = getState(); 42 | 43 | return new Promise((resolve, reject) => { 44 | dispatch(setLoading(true)); 45 | 46 | listGists(token) 47 | .then((response) => { 48 | const gists = response.data; 49 | const current = gists.find((gist) => gist.id === backupGistId); 50 | 51 | dispatch(setGistsAction(current ? [current] : gists)); 52 | dispatch(setLoading(false)); 53 | resolve(gists); 54 | }) 55 | .catch((error) => { 56 | dispatch(setLoading(false)); 57 | reject(error); 58 | }); 59 | }); 60 | }; 61 | }; 62 | 63 | export const deleteAuthData = (): AppThunk => { 64 | return (dispatch, _getState, ipcRenderer): void => { 65 | dispatch(clearAuthDataAction()); 66 | ipcRenderer.invoke('DELETE_GH_DATA'); 67 | }; 68 | }; 69 | -------------------------------------------------------------------------------- /src/store/auth/reducers.ts: -------------------------------------------------------------------------------- 1 | import { SET_GH_DATA, SET_GISTS, CLEAR_GH_DATA, AuthState, AuthActionTypes } from './types'; 2 | 3 | const initialState: AuthState = { 4 | token: '', 5 | gists: [], 6 | backupGistId: '', 7 | lastSychronizedGistDate: '', 8 | }; 9 | 10 | export const authReducer = (state = initialState, action: AuthActionTypes): AuthState => { 11 | switch (action.type) { 12 | case SET_GH_DATA: 13 | return { 14 | ...state, 15 | token: action.token, 16 | backupGistId: action.backupGistId, 17 | lastSychronizedGistDate: action.lastSychronizedGistDate, 18 | }; 19 | 20 | case SET_GISTS: 21 | return { 22 | ...state, 23 | gists: action.gists, 24 | }; 25 | 26 | case CLEAR_GH_DATA: 27 | return initialState; 28 | 29 | default: 30 | return state; 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/store/auth/types.ts: -------------------------------------------------------------------------------- 1 | import { GistsListResponseData } from '@octokit/types'; 2 | 3 | export const SET_GH_DATA = 'AUTH_SET_GH_DATA'; 4 | export const SET_GISTS = 'AUTH_SET_GISTS'; 5 | export const CLEAR_GH_DATA = 'AUTH_CLEAR_GH_DATA'; 6 | 7 | export interface AuthState { 8 | token: string; 9 | gists: GistsListResponseData; 10 | backupGistId: string; 11 | lastSychronizedGistDate: string; 12 | } 13 | 14 | interface SetGitHubDataAction { 15 | type: typeof SET_GH_DATA; 16 | token: string; 17 | backupGistId: string; 18 | lastSychronizedGistDate: string; 19 | } 20 | 21 | interface SetGistsAction { 22 | type: typeof SET_GISTS; 23 | gists: GistsListResponseData; 24 | } 25 | 26 | interface ClearAuthDataAction { 27 | type: typeof CLEAR_GH_DATA; 28 | } 29 | 30 | export type AuthActionTypes = SetGitHubDataAction | SetGistsAction | ClearAuthDataAction; 31 | -------------------------------------------------------------------------------- /src/store/editor/actions.ts: -------------------------------------------------------------------------------- 1 | import { SHOW_GUTTER, EditorActionTypes } from './types'; 2 | import { AppThunk } from 'store/types'; 3 | 4 | const showGutterAction = (): EditorActionTypes => ({ 5 | type: SHOW_GUTTER, 6 | }); 7 | 8 | export const showGutter = (): AppThunk => { 9 | return (dispatch): void => { 10 | dispatch(showGutterAction()); 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/store/editor/reducers.ts: -------------------------------------------------------------------------------- 1 | import { SHOW_GUTTER, EditorState, EditorActionTypes } from './types'; 2 | 3 | const initialState: EditorState = { 4 | gutter: true, 5 | }; 6 | 7 | export const editorReducer = (state = initialState, action: EditorActionTypes): EditorState => { 8 | switch (action.type) { 9 | case SHOW_GUTTER: 10 | return { 11 | ...state, 12 | gutter: !state.gutter, 13 | }; 14 | 15 | default: 16 | return state; 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/store/editor/types.ts: -------------------------------------------------------------------------------- 1 | export const SHOW_GUTTER = 'EDITOR_SHOW_GUTTER'; 2 | 3 | export interface EditorState { 4 | gutter: boolean; 5 | } 6 | 7 | interface ShowGutterAction { 8 | type: typeof SHOW_GUTTER; 9 | } 10 | 11 | export type EditorActionTypes = ShowGutterAction; 12 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, combineReducers } from 'redux'; 2 | import ReduxThunk from 'redux-thunk'; 3 | import { composeWithDevTools } from 'redux-devtools-extension'; 4 | import { ipcRenderer } from 'electron'; 5 | 6 | import { uiReducer as ui } from './ui/reducers'; 7 | import { authReducer as auth } from './auth/reducers'; 8 | import { modalReducer as modal } from './modal/reducers'; 9 | import { editorReducer as editor } from './editor/reducers'; 10 | import { snippetsReducer as snippets } from './snippets/reducers'; 11 | 12 | const rootReducer = combineReducers({ 13 | ui, 14 | auth, 15 | modal, 16 | editor, 17 | snippets, 18 | }); 19 | 20 | const store = createStore(rootReducer, composeWithDevTools(applyMiddleware(ReduxThunk.withExtraArgument(ipcRenderer)))); 21 | 22 | export default store; 23 | -------------------------------------------------------------------------------- /src/store/modal/actions.ts: -------------------------------------------------------------------------------- 1 | import { SHOW_MODAL, HIDE_MODAL, ModalActionTypes, ModalProps } from './types'; 2 | 3 | export const showModal = (modalType: string, modalProps: ModalProps): ModalActionTypes => ({ 4 | type: SHOW_MODAL, 5 | modalType, 6 | modalProps, 7 | }); 8 | 9 | export const hideModal = (): ModalActionTypes => ({ 10 | type: HIDE_MODAL, 11 | }); 12 | -------------------------------------------------------------------------------- /src/store/modal/reducers.ts: -------------------------------------------------------------------------------- 1 | import { SHOW_MODAL, HIDE_MODAL, ModalState, ModalActionTypes } from './types'; 2 | 3 | const initialState: ModalState = { 4 | modalType: '', 5 | modalProps: {}, 6 | }; 7 | 8 | export const modalReducer = (state = initialState, action: ModalActionTypes): ModalState => { 9 | switch (action.type) { 10 | case SHOW_MODAL: 11 | return { 12 | ...state, 13 | modalType: action.modalType, 14 | modalProps: action.modalProps, 15 | }; 16 | 17 | case HIDE_MODAL: 18 | return initialState; 19 | 20 | default: 21 | return state; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /src/store/modal/types.ts: -------------------------------------------------------------------------------- 1 | import { GistsListResponseData } from '@octokit/types'; 2 | 3 | export const SHOW_MODAL = 'MODAL_SHOW_MODAL'; 4 | export const HIDE_MODAL = 'MODAL_HIDE_MODAL'; 5 | 6 | export interface ModalState { 7 | modalType: string; 8 | modalProps: ModalProps; 9 | } 10 | 11 | export type ModalProps = AccountModalProps | unknown; 12 | 13 | type AccountModalProps = { 14 | onHideModal?: () => void; 15 | onSetAuthToken?: (token: string) => Promise; 16 | onSynchronizeGist?: (backupLocalSnippets: boolean, token: string, id: string) => Promise; 17 | onCreateBackupGist?: (description: string, token: string) => Promise; 18 | onDeleteAuthData?: () => void; 19 | }; 20 | 21 | interface ShowModalAction { 22 | type: typeof SHOW_MODAL; 23 | modalType: string; 24 | modalProps: ModalProps; 25 | } 26 | 27 | interface HideModalAction { 28 | type: typeof HIDE_MODAL; 29 | } 30 | 31 | export type ModalActionTypes = ShowModalAction | HideModalAction; 32 | -------------------------------------------------------------------------------- /src/store/snippets/actions.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | initSnippets, 3 | addSnippet, 4 | updateSnippet, 5 | deleteSnippet, 6 | synchronizeGist, 7 | createBackupGist, 8 | } from 'store/snippets/actions'; 9 | import { LOAD_SNIPPETS, ADD_SNIPPET, UPDATE_SNIPPET, DELETE_SNIPPET } from 'store/snippets/types'; 10 | import { SET_GH_DATA } from 'store/auth/types'; 11 | import { SET_LOADING } from 'store/ui/types'; 12 | 13 | import { sourceType } from 'models/Snippet'; 14 | 15 | import mockStore from 'utils/test/mockStore'; 16 | import { 17 | mockSnippet, 18 | MOCK_CONTENT, 19 | MOCK_LAST_SYNCHRONIZED_GIST_DATE, 20 | UPDATED_AT, 21 | GIST_ID, 22 | MOCK_TOKEN, 23 | } from 'utils/test/mockSnippets'; 24 | 25 | jest.mock('db/snippets'); 26 | jest.mock('utils/gistActions'); 27 | jest.mock('electron', () => { 28 | return { 29 | ipcRenderer: { 30 | send: jest.fn(), 31 | invoke: jest.fn(), 32 | }, 33 | }; 34 | }); 35 | 36 | describe('snippets actions', () => { 37 | let store: ReturnType; 38 | 39 | beforeEach(() => { 40 | const currentSnippetMock = mockSnippet(); 41 | store = mockStore({ 42 | snippets: { 43 | current: currentSnippetMock, 44 | list: [currentSnippetMock], 45 | lastId: 0, 46 | }, 47 | auth: { 48 | lastSychronizedGistDate: new Date().toISOString(), 49 | }, 50 | }); 51 | }); 52 | 53 | it('should initialize snippets', () => { 54 | return store.dispatch(initSnippets()).then(() => { 55 | const actions = store.getActions(); 56 | expect(actions[0]).toHaveProperty('type', LOAD_SNIPPETS); 57 | }); 58 | }); 59 | 60 | it('should add snippet', () => { 61 | store.dispatch(addSnippet()); 62 | const actions = store.getActions(); 63 | expect(actions[0]).toHaveProperty('type', ADD_SNIPPET); 64 | }); 65 | 66 | it('should update snippet', () => { 67 | const snippetMock = { 68 | id: 0, 69 | title: 'test0', 70 | }; 71 | 72 | store.dispatch(updateSnippet(snippetMock)); 73 | const actions = store.getActions(); 74 | expect(actions[0]).toHaveProperty('type', UPDATE_SNIPPET); 75 | expect(actions[0]).toHaveProperty('snippet.title', snippetMock.title); 76 | }); 77 | 78 | it('should delete snippet', () => { 79 | store.dispatch(deleteSnippet()); 80 | const actions = store.getActions(); 81 | expect(actions[0]).toEqual({ type: DELETE_SNIPPET, current: undefined, list: [] }); 82 | }); 83 | 84 | it('should synchronize with gist (fetch from gist)', () => { 85 | const currentSnippetMock = mockSnippet(); 86 | store = mockStore({ 87 | snippets: { 88 | current: currentSnippetMock, 89 | list: [currentSnippetMock], 90 | lastId: 0, 91 | }, 92 | auth: { 93 | lastSychronizedGistDate: '2020-01-01T18:00:00.000Z', 94 | }, 95 | }); 96 | 97 | return store.dispatch(synchronizeGist(false, MOCK_TOKEN, GIST_ID)).then(() => { 98 | const actions = store.getActions(); 99 | 100 | expect(actions[0]).toEqual({ type: SET_LOADING, loading: true }); 101 | expect(actions[1]).toEqual({ 102 | type: SET_GH_DATA, 103 | token: MOCK_TOKEN, 104 | backupGistId: GIST_ID, 105 | lastSychronizedGistDate: '2020-02-01T18:00:00.000Z', 106 | }); 107 | expect(actions[2]).toHaveProperty('type', LOAD_SNIPPETS); 108 | expect(actions[2]).toHaveProperty('current.content', MOCK_CONTENT); 109 | expect(actions[3]).toEqual({ type: SET_LOADING, loading: false }); 110 | }); 111 | }); 112 | 113 | it('should synchronize with gist (update gist without backing up local snippets)', () => { 114 | const currentSnippetMock = mockSnippet('2020-03-01T18:00:00.000Z'); 115 | currentSnippetMock.source = sourceType.GIST; 116 | store = mockStore({ 117 | snippets: { 118 | current: currentSnippetMock, 119 | list: [currentSnippetMock], 120 | lastId: 0, 121 | }, 122 | auth: { 123 | lastSychronizedGistDate: '2020-02-01T18:00:00.000Z', 124 | }, 125 | }); 126 | 127 | return store.dispatch(synchronizeGist(false, MOCK_TOKEN, GIST_ID)).then(() => { 128 | const actions = store.getActions(); 129 | expect(actions[0]).toEqual({ type: SET_LOADING, loading: true }); 130 | expect(actions[1]).toEqual({ 131 | type: SET_GH_DATA, 132 | token: MOCK_TOKEN, 133 | backupGistId: GIST_ID, 134 | lastSychronizedGistDate: MOCK_LAST_SYNCHRONIZED_GIST_DATE, 135 | }); 136 | expect(actions[2]).toEqual({ type: SET_LOADING, loading: false }); 137 | }); 138 | }); 139 | 140 | it('should create backup gist', () => { 141 | return store.dispatch(createBackupGist('description', MOCK_TOKEN)).then(() => { 142 | const actions = store.getActions(); 143 | expect(actions[0]).toEqual({ type: SET_LOADING, loading: true }); 144 | expect(actions[1]).toEqual({ 145 | type: SET_GH_DATA, 146 | token: MOCK_TOKEN, 147 | backupGistId: GIST_ID, 148 | lastSychronizedGistDate: UPDATED_AT, 149 | }); 150 | expect(actions[2]).toHaveProperty('type', LOAD_SNIPPETS); 151 | expect(actions[2]).toHaveProperty('current.content', MOCK_CONTENT); 152 | expect(actions[3]).toEqual({ type: SET_LOADING, loading: false }); 153 | }); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /src/store/snippets/actions.ts: -------------------------------------------------------------------------------- 1 | import Snippet, { SnippetInterface, sourceType } from 'models/Snippet'; 2 | import { AppThunk } from 'store/types'; 3 | import { setLoading } from 'store/ui/actions'; 4 | import { setGitHubDataAction } from 'store/auth/actions'; 5 | import { snippetsDb } from 'db/snippets'; 6 | import { sortById } from 'utils/utils'; 7 | import { getGist, updateGist, createGist } from 'utils/gistActions'; 8 | 9 | import { 10 | LOAD_SNIPPETS, 11 | ADD_SNIPPET, 12 | UPDATE_SNIPPET, 13 | DELETE_SNIPPET, 14 | SET_CURRENT_SNIPPET, 15 | SnippetsActionTypes, 16 | } from './types'; 17 | 18 | const loadSnippetsAction = (list: Snippet[], current: Snippet, lastId: number): SnippetsActionTypes => ({ 19 | type: LOAD_SNIPPETS, 20 | list, 21 | current, 22 | lastId, 23 | }); 24 | 25 | const addSnippetAction = (snippet: Snippet, list: Snippet[]): SnippetsActionTypes => ({ 26 | type: ADD_SNIPPET, 27 | snippet, 28 | list, 29 | }); 30 | 31 | const updateSnippetAction = (snippet: Snippet, list: Snippet[]): SnippetsActionTypes => ({ 32 | type: UPDATE_SNIPPET, 33 | snippet, 34 | list, 35 | }); 36 | 37 | const deleteSnippetAction = (current: Snippet, list: Snippet[]): SnippetsActionTypes => ({ 38 | type: DELETE_SNIPPET, 39 | current, 40 | list, 41 | }); 42 | 43 | export const setCurrentSnippet = (id: number): SnippetsActionTypes => ({ 44 | type: SET_CURRENT_SNIPPET, 45 | id, 46 | }); 47 | 48 | export const initSnippets = (): AppThunk> => { 49 | return (dispatch): Promise => { 50 | return new Promise((resolve, reject) => { 51 | try { 52 | snippetsDb.findAll((data: SnippetInterface[]) => { 53 | const snippets = data.sort(sortById).map((entry: SnippetInterface) => new Snippet({ ...entry })); 54 | const lastId = Math.max(...snippets.map((entry: Snippet) => entry.id)) | 0; 55 | 56 | dispatch(loadSnippetsAction(snippets, snippets[0], lastId)); 57 | resolve(lastId.toString()); 58 | }); 59 | } catch (error) { 60 | reject(error); 61 | } 62 | }); 63 | }; 64 | }; 65 | 66 | export const addSnippet = (): AppThunk => { 67 | return (dispatch, getState): void => { 68 | const { 69 | snippets: { lastId, list }, 70 | } = getState(); 71 | 72 | const newSnippet = new Snippet({ title: 'New', id: lastId + 1 }); 73 | const updatedList = [...list, newSnippet].sort(sortById); 74 | 75 | snippetsDb.add(newSnippet); 76 | dispatch(addSnippetAction(newSnippet, updatedList)); 77 | }; 78 | }; 79 | 80 | export const updateSnippet = (properties: { [key: string]: unknown }): AppThunk => { 81 | return (dispatch, getState): void => { 82 | const { 83 | snippets: { current, list }, 84 | } = getState(); 85 | 86 | if (!current) { 87 | return; 88 | } 89 | 90 | const toUpdateIndex = list.findIndex((element) => element.id === current.id); 91 | const updatedSnippet = new Snippet({ ...current, ...properties, lastUpdated: new Date().toISOString() }); 92 | const updatedList = [...list]; 93 | updatedList[toUpdateIndex] = updatedSnippet; 94 | 95 | snippetsDb.update(updatedSnippet); 96 | dispatch(updateSnippetAction(updatedSnippet, updatedList)); 97 | }; 98 | }; 99 | 100 | export const deleteSnippet = (): AppThunk => { 101 | return (dispatch, getState): void => { 102 | const { 103 | snippets: { current, list }, 104 | } = getState(); 105 | 106 | if (!current) { 107 | return; 108 | } 109 | 110 | const updatedList = list.filter((element) => element.id !== current.id); 111 | 112 | snippetsDb.remove(current.id); 113 | dispatch(deleteSnippetAction(updatedList[0], updatedList)); 114 | }; 115 | }; 116 | 117 | export const synchronizeGist = ( 118 | backupLocalSnippets: boolean, 119 | authToken: string, 120 | backupGistId: string, 121 | ): AppThunk> => { 122 | return (dispatch, getState, ipcRenderer): Promise => { 123 | const { 124 | snippets: { lastId, list }, 125 | auth: { lastSychronizedGistDate }, 126 | } = getState(); 127 | 128 | return new Promise((resolve, reject) => { 129 | dispatch(setLoading(true)); 130 | 131 | getGist(authToken, backupGistId) 132 | .then(async (response) => { 133 | const files = Object.entries(response.data.files); 134 | const lastGist = files[files.length - 1]; 135 | 136 | const lastSynchronizedGistTime = new Date(lastSychronizedGistDate).getTime(); 137 | const gistDate = new Date(lastGist[0]); 138 | 139 | if (!lastSynchronizedGistTime || gistDate.getTime() > lastSynchronizedGistTime) { 140 | let id = lastId; 141 | 142 | const gistContent = JSON.parse(lastGist[1].content || ''); 143 | const synchronized = gistContent 144 | .map( 145 | (snippet: SnippetInterface) => 146 | new Snippet({ 147 | ...snippet, 148 | id: id++, 149 | lastUpdated: gistDate.toISOString(), 150 | }), 151 | ) 152 | .sort(sortById); 153 | 154 | snippetsDb.removeQuery({ source: sourceType.GIST }); 155 | snippetsDb.add(synchronized); 156 | 157 | dispatch(setGitHubDataAction({ token: authToken, backupGistId, gistDate: gistDate.toISOString() })); 158 | dispatch(loadSnippetsAction(synchronized, synchronized[0], id)); 159 | 160 | ipcRenderer.invoke('SET_GH_DATA', { token: authToken, backupGistId, gistDate }); 161 | } else { 162 | const gistSourceSnippets = list.filter((snippet: Snippet) => snippet.source === sourceType.GIST); 163 | const snippets = backupLocalSnippets ? list.slice(0) : gistSourceSnippets; 164 | 165 | snippets.forEach((snippet) => (snippet.source = sourceType.GIST)); 166 | 167 | await updateGist(authToken, backupGistId, snippets).then((gistDate) => { 168 | snippetsDb.updateAll({ source: sourceType.GIST }); 169 | dispatch(setGitHubDataAction({ token: authToken, backupGistId, gistDate })); 170 | ipcRenderer.invoke('SET_GH_DATA', { token: authToken, backupGistId, gistDate }); 171 | }); 172 | } 173 | 174 | dispatch(setLoading(false)); 175 | resolve(backupGistId); 176 | }) 177 | .catch((error) => { 178 | dispatch(setLoading(false)); 179 | reject(error); 180 | }); 181 | }); 182 | }; 183 | }; 184 | 185 | export const createBackupGist = (description: string, authToken: string): AppThunk> => { 186 | return (dispatch, getState, ipcRenderer): Promise => { 187 | const { 188 | snippets: { list, lastId }, 189 | } = getState(); 190 | 191 | return new Promise((resolve, reject) => { 192 | dispatch(setLoading(true)); 193 | 194 | const snippets = list.slice(0); 195 | snippets.forEach((snippet) => (snippet.source = sourceType.GIST)); 196 | 197 | createGist(authToken, description, snippets) 198 | .then((response) => { 199 | const backupGistId = response.data.id; 200 | const gistDate = response.data.updated_at; 201 | 202 | ipcRenderer.send('SET_GH_DATA', { token: authToken, backupGistId, gistDate }); 203 | snippetsDb.updateAll({ source: sourceType.GIST }); 204 | 205 | dispatch(setGitHubDataAction({ token: authToken, backupGistId, gistDate })); 206 | dispatch(loadSnippetsAction(snippets, snippets[0], lastId)); 207 | dispatch(setLoading(false)); 208 | resolve(backupGistId); 209 | }) 210 | .catch((error) => { 211 | dispatch(setLoading(false)); 212 | reject(error); 213 | }); 214 | }); 215 | }; 216 | }; 217 | -------------------------------------------------------------------------------- /src/store/snippets/reducers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ADD_SNIPPET, 3 | UPDATE_SNIPPET, 4 | DELETE_SNIPPET, 5 | SET_CURRENT_SNIPPET, 6 | LOAD_SNIPPETS, 7 | SnippetsState, 8 | SnippetsActionTypes, 9 | } from './types'; 10 | 11 | const initialState: SnippetsState = { 12 | current: null, 13 | list: [], 14 | lastId: 0, 15 | }; 16 | 17 | export const snippetsReducer = (state = initialState, action: SnippetsActionTypes): SnippetsState => { 18 | switch (action.type) { 19 | case ADD_SNIPPET: 20 | return { 21 | ...state, 22 | current: action.snippet, 23 | list: action.list, 24 | lastId: action.snippet.id, 25 | }; 26 | 27 | case UPDATE_SNIPPET: 28 | return { 29 | ...state, 30 | current: action.snippet, 31 | list: action.list, 32 | }; 33 | 34 | case DELETE_SNIPPET: 35 | return { 36 | ...state, 37 | current: action.current, 38 | list: action.list, 39 | }; 40 | 41 | case SET_CURRENT_SNIPPET: 42 | return { 43 | ...state, 44 | current: state.list.find((element) => element.id === action.id) || null, 45 | }; 46 | 47 | case LOAD_SNIPPETS: 48 | return { 49 | ...state, 50 | list: action.list, 51 | current: action.current, 52 | lastId: action.lastId, 53 | }; 54 | 55 | default: 56 | return state; 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/store/snippets/types.ts: -------------------------------------------------------------------------------- 1 | import Snippet from 'models/Snippet'; 2 | 3 | export const ADD_SNIPPET = 'SNIPPETS_ADD_SNIPPET'; 4 | export const UPDATE_SNIPPET = 'SNIPPETS_UPDATE_SNIPPET'; 5 | export const DELETE_SNIPPET = 'SNIPPETS_DELETE_SNIPPET'; 6 | export const SET_CURRENT_SNIPPET = 'SNIPPETS_SET_CURRENT_SNIPPET'; 7 | export const LOAD_SNIPPETS = 'SNIPPETS_LOAD_SNIPPETS'; 8 | 9 | export interface SnippetsState { 10 | current: Snippet | null; 11 | list: Snippet[]; 12 | lastId: number; 13 | } 14 | 15 | interface LoadSnippetsAction { 16 | type: typeof LOAD_SNIPPETS; 17 | list: Snippet[]; 18 | current: Snippet; 19 | lastId: number; 20 | } 21 | 22 | interface AddSnippetAction { 23 | type: typeof ADD_SNIPPET; 24 | snippet: Snippet; 25 | list: Snippet[]; 26 | } 27 | 28 | interface UpdateSnippetAction { 29 | type: typeof UPDATE_SNIPPET; 30 | snippet: Snippet; 31 | list: Snippet[]; 32 | } 33 | 34 | interface DeleteSnippetAction { 35 | type: typeof DELETE_SNIPPET; 36 | current: Snippet; 37 | list: Snippet[]; 38 | } 39 | 40 | interface SetCurrentSnippetAction { 41 | type: typeof SET_CURRENT_SNIPPET; 42 | id: number; 43 | } 44 | 45 | export type SnippetsActionTypes = 46 | | LoadSnippetsAction 47 | | AddSnippetAction 48 | | UpdateSnippetAction 49 | | DeleteSnippetAction 50 | | SetCurrentSnippetAction; 51 | -------------------------------------------------------------------------------- /src/store/types.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'redux'; 2 | import { ThunkAction, ThunkDispatch } from 'redux-thunk'; 3 | import { IpcRenderer } from 'electron'; 4 | 5 | import { UIState } from './ui/types'; 6 | import { AuthState } from './auth/types'; 7 | import { SnippetsState } from './snippets/types'; 8 | import { EditorState } from './editor/types'; 9 | import { ModalState } from './modal/types'; 10 | 11 | export interface RootState { 12 | ui: UIState; 13 | auth: AuthState; 14 | modal: ModalState; 15 | editor: EditorState; 16 | snippets: SnippetsState; 17 | } 18 | 19 | export type AppThunk = ThunkAction>; 20 | 21 | export type AppDispatch = ThunkDispatch>; 22 | -------------------------------------------------------------------------------- /src/store/ui/actions.ts: -------------------------------------------------------------------------------- 1 | import { ThemeType } from 'components/Theme/Theme'; 2 | import { AppThunk } from 'store/types'; 3 | 4 | import { APP_INIT, RESIZE_LEFT_PANEL, SET_LOADING, SET_ERROR, SWITCH_THEME, UIActionTypes } from './types'; 5 | 6 | export const appInit = (init: boolean): UIActionTypes => { 7 | return { 8 | type: APP_INIT, 9 | init, 10 | }; 11 | }; 12 | 13 | export const resizeLeftPanel = (leftPanelWidth: number): UIActionTypes => { 14 | return { 15 | type: RESIZE_LEFT_PANEL, 16 | leftPanelWidth, 17 | }; 18 | }; 19 | 20 | export const setLoading = (loading: boolean): UIActionTypes => { 21 | return { 22 | type: SET_LOADING, 23 | loading, 24 | }; 25 | }; 26 | 27 | export const setError = (error: string): UIActionTypes => ({ 28 | type: SET_ERROR, 29 | error, 30 | }); 31 | 32 | const switchThemeAction = (theme: string): UIActionTypes => { 33 | return { 34 | type: SWITCH_THEME, 35 | theme, 36 | }; 37 | }; 38 | 39 | export const switchTheme = (): AppThunk => { 40 | return (dispatch, getState, ipcRenderer): void => { 41 | const { 42 | ui: { theme }, 43 | } = getState(); 44 | 45 | const newTheme = theme === ThemeType.DARK ? ThemeType.LIGHT : ThemeType.DARK; 46 | 47 | ipcRenderer.invoke('SWITCH_THEME', newTheme).then(() => { 48 | dispatch(switchThemeAction(newTheme)); 49 | }); 50 | }; 51 | }; 52 | 53 | export const loadTheme = (): AppThunk => { 54 | return (dispatch, _getState, ipcRenderer): void => { 55 | ipcRenderer.invoke('GET_THEME').then((theme) => { 56 | dispatch(switchThemeAction(theme)); 57 | }); 58 | }; 59 | }; 60 | -------------------------------------------------------------------------------- /src/store/ui/reducers.ts: -------------------------------------------------------------------------------- 1 | import { APP_INIT, RESIZE_LEFT_PANEL, SWITCH_THEME, SET_LOADING, SET_ERROR, UIState, UIActionTypes } from './types'; 2 | import { ThemeType } from 'components/Theme/Theme'; 3 | 4 | const initialState: UIState = { 5 | init: true, 6 | theme: ThemeType.DARK, 7 | leftPanelWidth: 200, 8 | loading: false, 9 | error: null, 10 | }; 11 | 12 | export const uiReducer = (state = initialState, action: UIActionTypes): UIState => { 13 | switch (action.type) { 14 | case APP_INIT: 15 | return { 16 | ...state, 17 | init: action.init, 18 | }; 19 | 20 | case RESIZE_LEFT_PANEL: 21 | return { 22 | ...state, 23 | leftPanelWidth: action.leftPanelWidth, 24 | }; 25 | 26 | case SWITCH_THEME: 27 | return { 28 | ...state, 29 | theme: action.theme, 30 | }; 31 | 32 | case SET_LOADING: 33 | return { 34 | ...state, 35 | loading: action.loading, 36 | }; 37 | 38 | case SET_ERROR: 39 | return { 40 | ...state, 41 | error: action.error, 42 | }; 43 | 44 | default: 45 | return state; 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/store/ui/types.ts: -------------------------------------------------------------------------------- 1 | export const APP_INIT = 'UI_APP_INIT'; 2 | export const RESIZE_LEFT_PANEL = 'UI_RESIZE_LEFT_PANEL'; 3 | export const SWITCH_THEME = 'UI_SWITCH_THEME'; 4 | export const SET_LOADING = 'UI_SET_LOADING'; 5 | export const SET_ERROR = 'UI_SET_ERROR'; 6 | 7 | export interface UIState { 8 | init: boolean; 9 | theme: string; 10 | leftPanelWidth: number; 11 | loading: boolean; 12 | error: string | null; 13 | } 14 | 15 | interface AppInitAction { 16 | type: typeof APP_INIT; 17 | init: boolean; 18 | } 19 | 20 | interface ResizeLeftPanelAction { 21 | type: typeof RESIZE_LEFT_PANEL; 22 | leftPanelWidth: number; 23 | } 24 | 25 | interface SetLoadingAction { 26 | type: typeof SET_LOADING; 27 | loading: boolean; 28 | } 29 | 30 | interface SetErrorAction { 31 | type: typeof SET_ERROR; 32 | error: string; 33 | } 34 | 35 | interface SwitchThemeAction { 36 | type: typeof SWITCH_THEME; 37 | theme: string; 38 | } 39 | 40 | export type UIActionTypes = 41 | | AppInitAction 42 | | ResizeLeftPanelAction 43 | | SetLoadingAction 44 | | SetErrorAction 45 | | SwitchThemeAction; 46 | -------------------------------------------------------------------------------- /src/svg-icon-overrides.scss: -------------------------------------------------------------------------------- 1 | $svg-icon-map: ( 2 | '16px/small-minus.svg': "path fill-rule='evenodd' clip-rule='evenodd' d='M11 7H5c-.55 0-1 .45-1 1s.45 1 1 1h6c.55 0 1-.45 1-1s-.45-1-1-1z' fill='%23fff'/", 3 | '16px/small-tick.svg': "path fill-rule='evenodd' clip-rule='evenodd' d='M12 5c-.28 0-.53.11-.71.29L7 9.59l-2.29-2.3a1.003 1.003 0 0 0-1.42 1.42l3 3c.18.18.43.29.71.29s.53-.11.71-.29l5-5A1.003 1.003 0 0 0 12 5z' fill='%23fff'/", 4 | '16px/chevron-right.svg': "path fill-rule='evenodd' clip-rule='evenodd' d='M10.71 7.29l-4-4a1.003 1.003 0 0 0-1.42 1.42L8.59 8 5.3 11.29c-.19.18-.3.43-.3.71a1.003 1.003 0 0 0 1.71.71l4-4c.18-.18.29-.43.29-.71 0-.28-.11-.53-.29-.71z' fill='%235C7080'/", 5 | '16px/more.svg': "g fill='%235C7080'%3E%3Ccircle cx='2' cy='8.03' r='2'/%3E%3Ccircle cx='14' cy='8.03' r='2'/%3E%3Ccircle cx='8' cy='8.03' r='2'/%3E%3C/g", 6 | ); 7 | 8 | @function svg-icon($inline-svg, $fill-color) { 9 | @return url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3C" + map-get($svg-icon-map, $inline-svg) + "%3E%3C/svg%3E") 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/__mocks__/gistActions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MOCK_CONTENT, 3 | MOCK_TITLE, 4 | MOCK_DESCRIPTION, 5 | MOCK_LAST_SYNCHRONIZED_GIST_DATE, 6 | UPDATED_AT, 7 | GIST_ID, 8 | MOCK_INVALID_TOKEN, 9 | } from 'utils/test/mockSnippets'; 10 | 11 | // Example octokit responses 12 | // https://developer.github.com/v3/gists/ 13 | 14 | const listResponse = { 15 | data: [ 16 | { 17 | id: GIST_ID, 18 | }, 19 | ], 20 | }; 21 | 22 | const getResponse = { 23 | data: { 24 | files: { 25 | '2020-02-01T18:00:00.000Z': { 26 | filename: '2020-02-01T18:00:00.000Z', 27 | content: `[{ 28 | "id":0, 29 | "title":"${MOCK_TITLE}", 30 | "content":"${MOCK_CONTENT}", 31 | "description":"${MOCK_DESCRIPTION}", 32 | "uuid":"4ec2c830-0678-4f64-8ea4-49d3160272c3" 33 | }]`, 34 | }, 35 | }, 36 | }, 37 | }; 38 | 39 | const createResponse = { 40 | data: { 41 | id: GIST_ID, 42 | updated_at: UPDATED_AT, 43 | }, 44 | }; 45 | 46 | const updateResponse = MOCK_LAST_SYNCHRONIZED_GIST_DATE; 47 | 48 | const errorResponse = 'ERROR'; 49 | 50 | export const listGists = (token: string) => { 51 | return new Promise((resolve, reject) => { 52 | if (token === MOCK_INVALID_TOKEN) { 53 | reject(errorResponse); 54 | } 55 | resolve(listResponse); 56 | }); 57 | }; 58 | 59 | export const getGist = () => { 60 | return new Promise((resolve) => { 61 | resolve(getResponse); 62 | }); 63 | }; 64 | 65 | export const updateGist = () => { 66 | return new Promise((resolve) => { 67 | resolve(updateResponse); 68 | }); 69 | }; 70 | 71 | export const createGist = () => { 72 | return new Promise((resolve) => { 73 | resolve(createResponse); 74 | }); 75 | }; 76 | -------------------------------------------------------------------------------- /src/utils/appCommand.ts: -------------------------------------------------------------------------------- 1 | import { AppDispatch } from 'store/types'; 2 | import { switchTheme } from 'store/ui/actions'; 3 | 4 | export const APP_COMMAND = 'APP_COMMAND'; 5 | 6 | export type AppCommandMessage = { 7 | action: string; 8 | }; 9 | 10 | export default (dispatch: AppDispatch, message: AppCommandMessage): void => { 11 | const { action } = message; 12 | 13 | switch (action) { 14 | case 'SWITCH_THEME': 15 | dispatch(switchTheme()); 16 | break; 17 | 18 | case 'ERROR': 19 | console.log(message); // TODO 20 | break; 21 | 22 | default: 23 | break; 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/utils/gistActions.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from '@octokit/rest'; 2 | import { OctokitResponse, GistsListResponseData, GistsGetResponseData, GistsCreateResponseData } from '@octokit/types'; 3 | import Snippet from 'models/Snippet'; 4 | 5 | const listGists = (authToken: string): Promise> => { 6 | const octokit = new Octokit({ auth: authToken }); 7 | return octokit.gists.list({ 8 | headers: { 'If-None-Match': '' }, 9 | }); 10 | }; 11 | 12 | const getGist = (authToken: string, backupGistId: string): Promise> => { 13 | const octokit = new Octokit({ auth: authToken }); 14 | return octokit.gists.get({ 15 | gist_id: backupGistId, 16 | headers: { 'If-None-Match': '' }, 17 | }); 18 | }; 19 | 20 | const updateGist = (authToken: string, backupGistId: string, snippets: Snippet[]): Promise => { 21 | return new Promise((resolve, reject) => { 22 | const octokit = new Octokit({ auth: authToken }); 23 | const filename = new Date().toISOString(); 24 | const request = { 25 | gist_id: backupGistId, 26 | files: { 27 | [filename]: { 28 | filename, 29 | content: JSON.stringify(snippets), 30 | }, 31 | }, 32 | }; 33 | 34 | octokit.gists 35 | .update(request) 36 | .then(() => { 37 | resolve(filename); 38 | }) 39 | .catch((error) => { 40 | reject(error); 41 | }); 42 | }); 43 | }; 44 | 45 | const createGist = ( 46 | authToken: string, 47 | gistDescription: string, 48 | snippets: Snippet[], 49 | ): Promise> => { 50 | const octokit = new Octokit({ auth: authToken }); 51 | const fileName = new Date().toISOString(); 52 | const request = { 53 | description: gistDescription, 54 | public: false, 55 | files: { 56 | [fileName]: { 57 | content: JSON.stringify(snippets), 58 | }, 59 | }, 60 | }; 61 | 62 | return octokit.gists.create(request); 63 | }; 64 | 65 | export { listGists, getGist, updateGist, createGist }; 66 | -------------------------------------------------------------------------------- /src/utils/test/mockSnippets.ts: -------------------------------------------------------------------------------- 1 | import Snippet, { sourceType } from 'models/Snippet'; 2 | import { TEXT } from 'models/languages'; 3 | 4 | export const MOCK_TOKEN = 'mockToken'; 5 | export const MOCK_INVALID_TOKEN = 'mockInvalidToken'; 6 | 7 | export const MOCK_TITLE = 'mockTitle'; 8 | export const MOCK_DESCRIPTION = 'mockDescription'; 9 | export const MOCK_CONTENT = 'mockContent'; 10 | 11 | export const MOCK_LAST_SYNCHRONIZED_GIST_DATE = 'lastSychronizedGistDate'; 12 | export const UPDATED_AT = 'updatedAt'; 13 | export const GIST_ID = 'gistId'; 14 | 15 | export const mockSnippet = (lastUpdated?: string): Snippet => { 16 | return { 17 | id: 0, 18 | title: MOCK_TITLE, 19 | source: sourceType.LOCAL, 20 | uuid: 'uuid', 21 | tags: '#F29D49', 22 | description: MOCK_DESCRIPTION, 23 | content: MOCK_CONTENT, 24 | language: TEXT, 25 | lastUpdated: lastUpdated || new Date().toISOString(), 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /src/utils/test/mockStore.ts: -------------------------------------------------------------------------------- 1 | import { configureMockStore } from '@jedmao/redux-mock-store'; 2 | import { Action } from 'redux'; 3 | import ReduxThunk, { ThunkDispatch } from 'redux-thunk'; 4 | import { ipcRenderer, IpcRenderer } from 'electron'; 5 | 6 | import { RootState } from 'store/types'; 7 | 8 | const middlewares = [ReduxThunk.withExtraArgument(ipcRenderer)]; 9 | 10 | export default configureMockStore, ThunkDispatch>>( 11 | middlewares, 12 | ); 13 | -------------------------------------------------------------------------------- /src/utils/useWindowDimensions.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | type WindowDimensions = { 4 | width: number; 5 | height: number; 6 | }; 7 | 8 | const getWindowDimensions = (): WindowDimensions => { 9 | const { innerWidth: width, innerHeight: height } = window; 10 | return { 11 | width, 12 | height, 13 | }; 14 | }; 15 | 16 | const useWindowDimensions = (): WindowDimensions => { 17 | const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions()); 18 | 19 | useEffect(() => { 20 | const handleResize = (): void => { 21 | setWindowDimensions(getWindowDimensions()); 22 | }; 23 | 24 | window.addEventListener('resize', handleResize); 25 | return (): void => window.removeEventListener('resize', handleResize); 26 | }, []); 27 | 28 | return windowDimensions; 29 | }; 30 | 31 | export default useWindowDimensions; 32 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | const sortById = (a: { id: number }, b: { id: number }): number => (a.id < b.id ? 1 : -1); 2 | 3 | export { sortById }; 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "baseUrl": "./src", 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | "noFallthroughCasesInSwitch": true 23 | }, 24 | "paths": { 25 | "*": [ 26 | "*" 27 | ] 28 | }, 29 | "include": [ 30 | "src" 31 | ] 32 | } 33 | --------------------------------------------------------------------------------