├── .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 [](https://travis-ci.com/WilsonDev/kangaroo) [](https://opensource.org/licenses/MIT)
2 |
3 |
4 |
5 |

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 | 
19 | 
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 |
23 |
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 |
79 |
80 |
81 |
82 |
83 |
84 | >
85 | );
86 | };
87 |
88 | const renderGistSelector = (): React.ReactElement | null => {
89 | if (remoteGists.length === 0) {
90 | return null;
91 | }
92 |
93 | return (
94 | <>
95 | Synchronize with Gist
96 |
97 |
98 | {backupGistId ? (
99 | {gistItems[0].label}
100 | ) : (
101 |
102 | )}
103 |
104 |
105 |
106 |
107 |
108 |
115 | {lastSychronizedGistDate && (
116 |
117 | Last synchronized Gist: {new Date(lastSychronizedGistDate).toISOString()}
118 |
119 | )}
120 |
121 | >
122 | );
123 | };
124 |
125 | const renderUnlinkAccountButton = (): React.ReactElement | null => {
126 | if (!backupGistId || remoteGists.length === 0) {
127 | return null;
128 | }
129 |
130 | return (
131 | <>
132 |
133 |
134 |
135 |
142 | >
143 | );
144 | };
145 |
146 | return (
147 |
148 | {renderGistCreator()}
149 | {renderGistSelector()}
150 | {renderUnlinkAccountButton()}
151 |
152 | );
153 | };
154 |
155 | export default GistSelectorPanel;
156 |
--------------------------------------------------------------------------------
/src/components/Modal/ErrorModal/ErrorModal.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import { STATUS_CODES } from 'http';
3 | import { FormGroup, Callout, Intent, Classes } from '@blueprintjs/core';
4 |
5 | import styles from '../Panel.module.scss';
6 |
7 | const cx = classNames.bind(styles);
8 |
9 | type ErrorModalProps = {
10 | error: {
11 | status?: string;
12 | message?: string;
13 | };
14 | };
15 |
16 | const ErrorModal: React.FC = ({ error }) => {
17 | return (
18 |
19 |
20 | ERROR: {error.status ? STATUS_CODES[error.status] : error.message}
21 |
22 |
23 | );
24 | };
25 |
26 | export default ErrorModal;
27 |
--------------------------------------------------------------------------------
/src/components/Modal/ModalOverlay/ModalOverlay.tsx:
--------------------------------------------------------------------------------
1 | import { useSelector, useDispatch } from 'react-redux';
2 | import classNames from 'classnames';
3 | import { Dialog } from '@blueprintjs/core';
4 |
5 | import AccountModal from 'components/Modal/AccountModal/AccountModal';
6 | import ErrorModal from 'components/Modal/ErrorModal/ErrorModal';
7 | import { ThemeType } from 'components/Theme/Theme';
8 | import { RootState } from 'store/types';
9 | import { hideModal } from 'store/modal/actions';
10 | import { ACCOUNT_MODAL, ERROR_MODAL } from './constants';
11 |
12 | interface ModalComponents {
13 | [key: string]: { title: string; component: React.ElementType };
14 | }
15 |
16 | const MODAL_COMPONENTS: ModalComponents = {
17 | [ACCOUNT_MODAL]: {
18 | title: 'Connect to GitHub Gist',
19 | component: AccountModal,
20 | },
21 | [ERROR_MODAL]: {
22 | title: 'Error',
23 | component: ErrorModal,
24 | },
25 | };
26 |
27 | const ModalOverlay: React.FC = () => {
28 | const dispatch = useDispatch();
29 | const { modalType, modalProps } = useSelector((state: RootState) => state.modal);
30 | const { loading, theme } = useSelector((state: RootState) => state.ui);
31 |
32 | const handleHideModal = (): void => {
33 | dispatch(hideModal());
34 | };
35 |
36 | const modalTitle = (): string => {
37 | if (!modalType) {
38 | return '';
39 | }
40 |
41 | const modalConfig = MODAL_COMPONENTS[modalType];
42 | const modalTitle = modalConfig.title;
43 |
44 | return modalTitle;
45 | };
46 |
47 | const renderModalComponent = (): null | React.ReactElement => {
48 | if (!modalType) {
49 | return null;
50 | }
51 |
52 | const modalConfig = MODAL_COMPONENTS[modalType];
53 | const ModalComponent = modalConfig.component;
54 |
55 | return ;
56 | };
57 |
58 | return (
59 |
67 | );
68 | };
69 |
70 | export default ModalOverlay;
71 |
--------------------------------------------------------------------------------
/src/components/Modal/ModalOverlay/constants.ts:
--------------------------------------------------------------------------------
1 | const ACCOUNT_MODAL = 'ACCOUNT_MODAL';
2 | const ERROR_MODAL = 'ERROR_MODAL';
3 |
4 | export { ACCOUNT_MODAL, ERROR_MODAL };
5 |
--------------------------------------------------------------------------------
/src/components/Modal/Panel.module.scss:
--------------------------------------------------------------------------------
1 | .dialogBody {
2 | margin-bottom: 0px !important;
3 | }
4 |
5 | .synchronizationDate {
6 | vertical-align: middle;
7 | margin-left: 10px;
8 | }
9 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/Editor/Editor.module.scss:
--------------------------------------------------------------------------------
1 | .editor {
2 | flex: 1;
3 | isolation: isolate;
4 | }
5 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/Editor/Editor.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 { ThemeType } from 'components/Theme/Theme';
8 | import Editor from './Editor';
9 |
10 | jest.mock('ace-builds/webpack-resolver', () => jest.fn());
11 |
12 | configure({ adapter: new Adapter() });
13 |
14 | describe('', () => {
15 | const mockStore = configureMockStore();
16 | const mockProps = {
17 | gutter: true,
18 | onChange: jest.fn(),
19 | };
20 | const initialState = { ui: { theme: ThemeType.DARK, leftPanelWidth: 200 } };
21 |
22 | let store;
23 |
24 | it('render without crashing', () => {
25 | store = mockStore(initialState);
26 | shallow(
27 |
28 |
29 | ,
30 | );
31 | });
32 |
33 | it('matches snapshot', () => {
34 | store = mockStore(initialState);
35 | const wrapper = renderer.create(
36 |
37 |
38 | ,
39 | );
40 | expect(wrapper).toMatchSnapshot();
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/Editor/Editor.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { useSelector } from 'react-redux';
3 | import AceEditor, { IEditorProps } from 'react-ace';
4 |
5 | import { ThemeType } from 'components/Theme/Theme';
6 | import useWindowDimensions from 'utils/useWindowDimensions';
7 | import { RootState } from 'store/types';
8 | import { languages, TEXT } from 'models/languages';
9 |
10 | import styles from './Editor.module.scss';
11 |
12 | import './ace-themes';
13 |
14 | Object.keys(languages).forEach((lang) => {
15 | require(`ace-builds/src-noconflict/mode-${lang}`);
16 | });
17 |
18 | type EditorProps = {
19 | snippetId?: number;
20 | snippetContent?: string;
21 | snippetLanguage?: string;
22 | gutter: boolean;
23 | onChange: (value: string) => void;
24 | };
25 |
26 | const aceTheme: { [key: string]: string } = {
27 | [ThemeType.DARK]: 'sm-dark',
28 | [ThemeType.LIGHT]: 'sm-light',
29 | };
30 |
31 | const handleOnLoad = (editor: IEditorProps): void => {
32 | editor.resize();
33 | editor.setShowFoldWidgets(false);
34 | editor.renderer.scrollToRow(0);
35 | editor.commands.removeCommand('find');
36 | };
37 |
38 | const Editor: React.FC = ({ snippetId, snippetContent, snippetLanguage, gutter, onChange }) => {
39 | const { theme, leftPanelWidth } = useSelector((state: RootState) => state.ui);
40 | const { height, width } = useWindowDimensions();
41 |
42 | return (
43 |
62 | );
63 | };
64 |
65 | export default memo(Editor);
66 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/Editor/__snapshots__/Editor.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` matches snapshot 1`] = `
4 |
17 | `;
18 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/Editor/ace-themes/darkTheme.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 |
3 | const ace = require('ace-builds/src-noconflict/ace');
4 | const scss = require('./darkTheme.scss');
5 |
6 | ace.define('ace/theme/sm-dark', ['require', 'exports', 'module', 'ace/lib/dom'], function (require, exports) {
7 | exports.isDark = true;
8 | exports.cssClass = 'sm-dark';
9 | exports.cssText = scss;
10 |
11 | const dom = require('../lib/dom');
12 | dom.importCssString(exports.cssText, exports.cssClass);
13 | });
14 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/Editor/ace-themes/darkTheme.scss:
--------------------------------------------------------------------------------
1 | .sm-dark {
2 | background: var(--background-color-primary);
3 | color: #FFF;
4 | }
5 |
6 | /* CURSOR */
7 |
8 | .sm-dark .ace_cursor {
9 | color: #FFF;
10 | }
11 |
12 | .sm-dark .ace_hidden-cursors .ace_cursor {
13 | visibility: hidden;
14 | }
15 |
16 | /* GUTTER */
17 |
18 | .sm-dark .ace_gutter {
19 | background: var(--background-color-primary);
20 | color: #8A9BA8;
21 | }
22 |
23 | .sm-dark.ace_focus .ace_gutter-active-line {
24 | background-color : #394B59;
25 | }
26 |
27 | .sm-dark .ace_gutter-active-line {
28 | background: none;
29 | }
30 |
31 | .sm-dark .ace_print-margin {
32 | width: 1px;
33 | background: #e8e8e8;
34 | }
35 |
36 | .sm-dark .ace_marker-layer .ace_selection {
37 | background: #0E5A8A;
38 | }
39 |
40 | .sm-dark.ace_multiselect .ace_selection.ace_start {
41 | box-shadow: 0 0 3px 0px #272822;
42 | }
43 |
44 | .sm-dark .ace_keyword,
45 | .sm-dark .ace_meta.ace_tag,
46 | .sm-dark .ace_storage {
47 | color: #C274C2;
48 | }
49 |
50 | .sm-dark .ace_meta.ace_tag.ace_tag-name.ace_xml {
51 | color: #43BF4D;
52 | }
53 |
54 | .sm-dark .ace_meta.ace_tag.ace_punctuation.ace_xml {
55 | color: #FFF;
56 | }
57 |
58 | .sm-dark .ace_entity.ace_other.ace_attribute-name.ace_xml {
59 | color: #FFB366;
60 | }
61 |
62 | .sm-dark .ace_string {
63 | color: #D1F26D;
64 | }
65 |
66 | .sm-dark .ace_variable {
67 | color: #00B3A4;
68 | }
69 |
70 | /* .sm-dark .ace_variable.ace_class {
71 | color: teal;
72 | } */
73 |
74 | .sm-dark .ace_constant.ace_numeric {
75 | color: #FF66A1;
76 | }
77 |
78 | .sm-dark .ace_constant.ace_buildin {
79 | color: #0086B3;
80 | }
81 |
82 | .sm-dark .ace_support.ace_function {
83 | color: #48AFF0;
84 | }
85 |
86 | .sm-dark .ace_comment {
87 | color: #8A9BA8;
88 | }
89 |
90 | .sm-dark .ace_variable.ace_language {
91 | color: #14CCBD;
92 | }
93 |
94 | .sm-dark .ace_string.ace_regexp {
95 | color: #009926;
96 | font-weight: normal;
97 | }
98 |
99 | .sm-dark .ace_variable.ace_instance {
100 | color: teal;
101 | }
102 |
103 | .sm-dark.ace_focus .ace_marker-layer .ace_active-line {
104 | background: #394B59;
105 | }
106 |
107 | .sm-dark.ace_multiselect .ace_selection.ace_start {
108 | box-shadow: 0 0 3px 0px white;
109 | }
110 |
111 | .sm-dark .ace_marker-layer .ace_step {
112 | background: rgb(252, 255, 0);
113 | }
114 |
115 | .sm-dark .ace_marker-layer .ace_stack {
116 | background: rgb(164, 229, 101);
117 | }
118 |
119 | .sm-dark .ace_marker-layer .ace_bracket {
120 | border: 1px solid rgb(192, 192, 192);
121 | }
122 |
123 | .sm-dark .ace_marker-layer .ace_selected-word {
124 | background: #5C7080;
125 | }
126 |
127 | .sm-dark .ace_invisible {
128 | color: #BFBFBF;
129 | }
130 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/Editor/ace-themes/index.ts:
--------------------------------------------------------------------------------
1 | import './lightTheme';
2 | import './darkTheme';
3 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/Editor/ace-themes/lightTheme.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 |
3 | const ace = require('ace-builds/src-noconflict/ace');
4 | const scss = require('./lightTheme.scss');
5 |
6 | ace.define('ace/theme/sm-light', ['require', 'exports', 'module', 'ace/lib/dom'], function (require, exports) {
7 | exports.isDark = true;
8 | exports.cssClass = 'sm-light';
9 | exports.cssText = scss;
10 |
11 | const dom = require('../lib/dom');
12 | dom.importCssString(exports.cssText, exports.cssClass);
13 | });
14 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/Editor/ace-themes/lightTheme.scss:
--------------------------------------------------------------------------------
1 | .sm-light {
2 | background: var(--background-color-primary);
3 | color: #182026;
4 | }
5 |
6 | /* CURSOR */
7 |
8 | .sm-light .ace_cursor {
9 | color: #182026;
10 | }
11 |
12 | .sm-light .ace_hidden-cursors .ace_cursor {
13 | visibility: hidden;
14 | }
15 |
16 | /* GUTTER */
17 |
18 | .sm-light .ace_gutter {
19 | background: var(--background-color-primary);
20 | color: #182026;
21 | }
22 |
23 | .sm-light.ace_focus .ace_gutter-active-line {
24 | background-color : #D8E1E8;
25 | }
26 |
27 | .sm-light .ace_gutter-active-line {
28 | background: none;
29 | }
30 |
31 | .sm-light .ace_print-margin {
32 | width: 1px;
33 | background: #e8e8e8;
34 | }
35 |
36 | .sm-light.ace_focus .ace_marker-layer .ace_active-line {
37 | background: #D8E1E8;
38 | }
39 |
40 | .sm-light .ace_marker-layer .ace_selection {
41 | background: #48AFF0;
42 | }
43 |
44 | .sm-light.ace_multiselect .ace_selection.ace_start {
45 | box-shadow: 0 0 3px 0px #272822;
46 | }
47 |
48 | .sm-light .ace_keyword,
49 | .sm-light .ace_meta.ace_tag,
50 | .sm-light .ace_storage {
51 | color: #A854A8;
52 | }
53 |
54 | .sm-light .ace_meta.ace_tag.ace_tag-name.ace_xml {
55 | color: #238C2C;
56 | }
57 |
58 | .sm-light .ace_meta.ace_tag.ace_punctuation.ace_xml {
59 | color: #293742;
60 | }
61 |
62 | .sm-light .ace_entity.ace_other.ace_attribute-name.ace_xml {
63 | color: #D9822B;
64 | }
65 |
66 | .sm-light .ace_string {
67 | color: #87A629;
68 | }
69 |
70 | .sm-light .ace_variable {
71 | color: #00998C;
72 | }
73 |
74 | /* .sm-light .ace_variable.ace_class {
75 | color: teal;
76 | } */
77 |
78 | .sm-light .ace_constant.ace_numeric {
79 | color: #FF66A1;
80 | }
81 |
82 | .sm-light .ace_constant.ace_buildin {
83 | color: #0086B3;
84 | }
85 |
86 | .sm-light .ace_support.ace_function {
87 | color: #2B95D6;
88 | }
89 |
90 | .sm-light .ace_comment {
91 | color: #738694;
92 | }
93 |
94 | .sm-light .ace_variable.ace_language {
95 | color: #00B3A4;
96 | }
97 |
98 | .sm-light .ace_string.ace_regexp {
99 | color: #009926;
100 | font-weight: normal;
101 | }
102 |
103 | .sm-light .ace_variable.ace_instance {
104 | color: teal;
105 | }
106 |
107 | .sm-light.ace_multiselect .ace_selection.ace_start {
108 | box-shadow: 0 0 3px 0px white;
109 | }
110 |
111 | .sm-light .ace_marker-layer .ace_step {
112 | background: rgb(252, 255, 0);
113 | }
114 |
115 | .sm-light .ace_marker-layer .ace_stack {
116 | background: rgb(164, 229, 101);
117 | }
118 |
119 | .sm-light .ace_marker-layer .ace_bracket {
120 | border: 1px solid rgb(192, 192, 192);
121 | }
122 |
123 | .sm-light .ace_marker-layer .ace_selected-word {
124 | background: #E1E8ED;
125 | }
126 |
127 | .sm-light .ace_invisible {
128 | color: #BFBFBF;
129 | }
130 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/EditorHeader/EditorHeader.module.scss:
--------------------------------------------------------------------------------
1 | .container {
2 | display: flex;
3 | height: var(--editor-header-height);
4 | align-items: center;
5 | -webkit-app-region: drag;
6 | padding: 0 10px;
7 |
8 | h3 {
9 | width: 100%;
10 | margin: 0 !important;
11 | }
12 | }
13 |
14 | .snippetTitle {
15 | width: 100%;
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/EditorHeader/EditorHeader.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 EditorHeader from './EditorHeader';
6 |
7 | configure({ adapter: new Adapter() });
8 |
9 | describe('', () => {
10 | const mockProps = {
11 | snippetId: 1,
12 | snippetTitle: 'test',
13 | onTitleChange: jest.fn(),
14 | };
15 |
16 | it('render without crashing', () => {
17 | shallow();
18 | });
19 |
20 | it('matches snapshot', () => {
21 | const wrapper = renderer.create();
22 | expect(wrapper).toMatchSnapshot();
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/EditorHeader/EditorHeader.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import { H3, EditableText } from '@blueprintjs/core';
3 |
4 | import styles from './EditorHeader.module.scss';
5 |
6 | type EditorHeaderProps = {
7 | snippetId?: number;
8 | snippetTitle?: string;
9 | onTitleChange: (value: string) => void;
10 | };
11 |
12 | const EditorHeader: React.FC = ({ snippetId, snippetTitle = '', onTitleChange }) => (
13 |
14 |
15 |
24 |
25 |
26 | );
27 |
28 | export default memo(EditorHeader);
29 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/EditorHeader/__snapshots__/EditorHeader.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` matches snapshot 1`] = `
4 |
7 |
10 |
14 |
24 |
25 |
26 |
27 | `;
28 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/SnippetEditor.module.scss:
--------------------------------------------------------------------------------
1 | .container {
2 | display: flex;
3 | flex-direction: column;
4 | justify-content: flex-end;
5 | margin-left: auto;
6 | background-color: var(--background-color-primary);
7 | }
8 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/SnippetEditor.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 |
4 | import EditorHeader from './EditorHeader/EditorHeader';
5 | import Editor from './Editor/Editor';
6 | import StatusBar from './StatusBar/StatusBar';
7 |
8 | import { RootState } from 'store/types';
9 | import { updateSnippet } from 'store/snippets/actions';
10 | import { showGutter } from 'store/editor/actions';
11 |
12 | import styles from './SnippetEditor.module.scss';
13 |
14 | const SnippetEditor: React.FC = () => {
15 | const dispatch = useDispatch();
16 | const { current: snippet } = useSelector((state: RootState) => state.snippets);
17 | const { gutter } = useSelector((state: RootState) => state.editor);
18 | const tags = snippet ? snippet.tags : '';
19 |
20 | const handleTitleChange = useCallback(
21 | (value: string): void => {
22 | dispatch(updateSnippet({ title: value }));
23 | },
24 | [dispatch],
25 | );
26 |
27 | const handleOnTagChange = useCallback(
28 | (tag: string, remove: boolean): void => {
29 | const tagElements = tags.split(',');
30 |
31 | let updated = tagElements;
32 | if (remove) {
33 | tagElements.splice(
34 | tagElements.findIndex((element) => element === tag),
35 | 1,
36 | );
37 | updated = tagElements;
38 | } else {
39 | updated.push(tag);
40 | }
41 |
42 | dispatch(updateSnippet({ tags: updated.join(',') }));
43 | },
44 | [dispatch, tags],
45 | );
46 |
47 | const handleOnLanguageChange = useCallback(
48 | (event: React.ChangeEvent): void => {
49 | dispatch(updateSnippet({ language: event.currentTarget.value }));
50 | },
51 | [dispatch],
52 | );
53 |
54 | const handleShowGutter = useCallback((): void => {
55 | dispatch(showGutter());
56 | }, [dispatch]);
57 |
58 | const handleOnChange = useCallback(
59 | (value: string): void => {
60 | dispatch(updateSnippet({ content: value }));
61 | },
62 | [dispatch],
63 | );
64 |
65 | return (
66 |
67 |
68 |
69 |
76 |
77 |
85 |
86 | );
87 | };
88 |
89 | export default SnippetEditor;
90 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/StatusBar/StatusBar.module.scss:
--------------------------------------------------------------------------------
1 | .container {
2 | display: flex;
3 | justify-content: space-between;
4 | align-items: center;
5 | height: var(--status-bar-height);
6 | }
7 |
8 | .showGutter {
9 | width: var(--status-bar-height);
10 | height: var(--status-bar-height);
11 | }
12 |
13 | .synchronizationIconContainer {
14 | position: relative;
15 | }
16 |
17 | .synchronizationIcon {
18 | color: var(--icon-color);
19 | }
20 |
21 | .synchronizationIconStatus {
22 | position: absolute;
23 | width: 5px;
24 | height: 5px;
25 | border-radius: 50%;
26 | bottom: 0;
27 | right: 0;
28 | border: 1px solid var(--background-color-primary);
29 | box-sizing: content-box;
30 |
31 | &.local {
32 | background-color: #BFCCD6;
33 | }
34 |
35 | &.gist {
36 | background-color: #3DCC91;
37 | }
38 | }
39 |
40 | .languageSelector {
41 | text-transform: capitalize;
42 | }
43 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/StatusBar/StatusBar.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 StatusBar from './StatusBar';
6 |
7 | configure({ adapter: new Adapter() });
8 |
9 | describe('', () => {
10 | const mockProps = {
11 | snippetLanguage: 'text',
12 | snippetTags: '',
13 | snippetSource: 'local',
14 | onShowGutter: jest.fn(),
15 | onTagChange: jest.fn(),
16 | onLanguageChange: jest.fn(),
17 | };
18 |
19 | it('render without crashing', () => {
20 | shallow();
21 | });
22 |
23 | it('matches snapshot', () => {
24 | const wrapper = renderer.create();
25 | expect(wrapper).toMatchSnapshot();
26 | });
27 | });
28 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/StatusBar/StatusBar.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 | import classNames from 'classnames';
3 | import { Button, HTMLSelect, Position, Icon, Navbar } from '@blueprintjs/core';
4 | import { Tooltip2 } from '@blueprintjs/popover2';
5 |
6 | import TagBar from 'components/SnippetEditor/StatusBar/TagBar/TagBar';
7 | import { languages } from 'models/languages';
8 |
9 | import styles from './StatusBar.module.scss';
10 |
11 | const cx = classNames.bind(styles);
12 |
13 | type StatusBarProps = {
14 | snippetLanguage?: string;
15 | snippetTags?: string;
16 | snippetSource?: string;
17 | onShowGutter: () => void;
18 | onTagChange: (tag: string, remove: boolean) => void;
19 | onLanguageChange: (event: React.ChangeEvent) => void;
20 | };
21 |
22 | const languageItems = Object.entries(languages).map(([key, value]) => ({ label: value.label, value: key }));
23 |
24 | const StatusBar: React.FC = ({
25 | snippetLanguage,
26 | snippetTags,
27 | snippetSource,
28 | onShowGutter,
29 | onTagChange,
30 | onLanguageChange,
31 | }) => (
32 |
33 |
34 |
41 |
42 |
43 | {snippetSource && (
44 | <>
45 |
46 |
47 |
48 |
49 |
53 |
54 |
55 | >
56 | )}
57 |
58 | {snippetLanguage ? (
59 |
67 | ) : (
68 |
69 | )}
70 |
71 | );
72 |
73 | export default memo(StatusBar);
74 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/StatusBar/TagBar/TagBar.module.scss:
--------------------------------------------------------------------------------
1 | .container {
2 | display: flex;
3 | margin-left: auto;
4 | }
5 |
6 | .wrapper {
7 | width: 24px;
8 | height: var(--status-bar-height);
9 | padding: 7px 4px;
10 | cursor: pointer;
11 | border-radius: 3px;
12 |
13 | &:hover {
14 | background: var(--tag-hover-background);
15 |
16 | .actionIcon {
17 | display: unset !important;
18 | }
19 | }
20 | }
21 |
22 | .tag {
23 | display: flex;
24 | align-items: center;
25 | justify-content: center;
26 | width: 100%;
27 | height: 100%;
28 | border-radius: 50%;
29 | border: var(--tag-border);
30 | }
31 |
32 | .actionIcon {
33 | display: none !important;
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/StatusBar/TagBar/TagBar.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 TagBar from './TagBar';
6 |
7 | configure({ adapter: new Adapter() });
8 |
9 | describe('', () => {
10 | const mockProps = {
11 | tags: '1,2,3',
12 | onSelect: jest.fn(),
13 | };
14 |
15 | it('render without crashing', () => {
16 | shallow();
17 | });
18 |
19 | it('matches snapshot', () => {
20 | const wrapper = renderer.create();
21 | expect(wrapper).toMatchSnapshot();
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/StatusBar/TagBar/TagBar.tsx:
--------------------------------------------------------------------------------
1 | import { ReactElement } from 'react';
2 | import { Icon } from '@blueprintjs/core';
3 |
4 | import { Tag, TAGS } from 'models/tags';
5 |
6 | import styles from './TagBar.module.scss';
7 |
8 | type TagBarProps = {
9 | tags?: string;
10 | onSelect: (tag: string, remove: boolean) => void;
11 | };
12 |
13 | const TagBar: React.FC = ({ tags, onSelect }) => {
14 | const handleOnSelect = (tag: string, remove: boolean) => (): void => {
15 | onSelect(tag, remove);
16 | };
17 |
18 | const renderColorTag = (tag: Tag): ReactElement => {
19 | const selectedTags = tags?.split(',') || [];
20 | const isSelected = selectedTags && selectedTags.includes(tag.key);
21 |
22 | return (
23 |
28 | );
29 | };
30 |
31 | return {TAGS.map((tag) => renderColorTag(tag))}
;
32 | };
33 |
34 | export default TagBar;
35 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/StatusBar/TagBar/__snapshots__/TagBar.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` matches snapshot 1`] = `
4 |
7 |
10 |
19 |
24 |
35 |
36 |
37 |
38 |
41 |
50 |
55 |
66 |
67 |
68 |
69 |
72 |
81 |
86 |
97 |
98 |
99 |
100 |
103 |
112 |
117 |
128 |
129 |
130 |
131 |
134 |
143 |
148 |
159 |
160 |
161 |
162 |
163 | `;
164 |
--------------------------------------------------------------------------------
/src/components/SnippetEditor/StatusBar/__snapshots__/StatusBar.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` matches snapshot 1`] = `
4 |
7 |
16 |
43 |
44 |
47 |
50 |
59 |
64 |
75 |
76 |
77 |
78 |
81 |
90 |
95 |
106 |
107 |
108 |
109 |
112 |
121 |
126 |
137 |
138 |
139 |
140 |
143 |
152 |
157 |
168 |
169 |
170 |
171 |
174 |
183 |
188 |
199 |
200 |
201 |
202 |
203 |
206 |
215 |
219 |
224 |
235 |
236 |
239 |
240 |
241 |
244 |
247 |
325 |
329 |
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 ;
80 | }
81 | };
82 |
83 | return (
84 |
85 |
86 |
87 |
88 |
89 |
90 |
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 |
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 |
--------------------------------------------------------------------------------