├── .nvmrc ├── source ├── styles │ ├── base │ │ ├── _fonts.scss │ │ ├── _reset.scss │ │ ├── _variables.scss │ │ └── _components.scss │ └── main.scss ├── components │ ├── Icon │ │ ├── index.ts │ │ ├── styles.scss │ │ ├── Cross.tsx │ │ ├── Tick.tsx │ │ ├── Zap.tsx │ │ ├── Clock.tsx │ │ ├── Eye.tsx │ │ ├── QRCode.tsx │ │ ├── StarYellow.tsx │ │ ├── StarWhite.tsx │ │ ├── Copy.tsx │ │ ├── Refresh.tsx │ │ ├── EyeClosed.tsx │ │ ├── Spinner.tsx │ │ ├── Settings.tsx │ │ └── Icon.tsx │ ├── BodyWrapper.tsx │ └── Loader.tsx ├── History │ ├── styles.scss │ ├── index.tsx │ ├── Modal.tsx │ ├── History.tsx │ └── Table.tsx ├── assets │ ├── logo.png │ ├── favicon-16.png │ ├── favicon-32.png │ ├── favicon-48.png │ └── favicon-128.png ├── Background │ ├── constants.ts │ └── index.ts ├── util │ ├── mesageUtil.ts │ ├── link.ts │ ├── tabs.ts │ ├── browser.ts │ └── settings.ts ├── Options │ ├── Header.tsx │ ├── index.tsx │ ├── Footer.tsx │ ├── Options.tsx │ └── Form.tsx ├── Popup │ ├── index.tsx │ ├── ResponseBody.tsx │ ├── Header.tsx │ ├── Popup.tsx │ └── Form.tsx ├── manifest.json └── contexts │ ├── request-status-context.tsx │ ├── shortened-links-context.tsx │ └── extension-settings-context.tsx ├── .eslintignore ├── .github ├── assets │ ├── options-v4-1.png │ ├── popup-v4-1.png │ └── direct-download.png ├── ISSUE_TEMPLATE │ └── issue-template.md └── FUNDING.yml ├── tailwind.config.js ├── babel-plugin-macros.config.js ├── twin.d.ts ├── views ├── popup.html ├── history.html └── options.html ├── .travis.yml ├── tsconfig.json ├── cssprop.d.ts ├── .eslintrc.json ├── .babelrc ├── license ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── .gitignore ├── package.json ├── README.md └── webpack.config.js /.nvmrc: -------------------------------------------------------------------------------- 1 | 12 2 | -------------------------------------------------------------------------------- /source/styles/base/_fonts.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /source/components/Icon/index.ts: -------------------------------------------------------------------------------- 1 | export {default} from './Icon'; 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | extension/ 4 | .yarn/ 5 | .pnp.js -------------------------------------------------------------------------------- /source/History/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../styles/main.scss'; 2 | 3 | body { 4 | background-color: #edf2f7; 5 | } -------------------------------------------------------------------------------- /source/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedevs-network/kutt-extension/HEAD/source/assets/logo.png -------------------------------------------------------------------------------- /source/assets/favicon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedevs-network/kutt-extension/HEAD/source/assets/favicon-16.png -------------------------------------------------------------------------------- /source/assets/favicon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedevs-network/kutt-extension/HEAD/source/assets/favicon-32.png -------------------------------------------------------------------------------- /source/assets/favicon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedevs-network/kutt-extension/HEAD/source/assets/favicon-48.png -------------------------------------------------------------------------------- /.github/assets/options-v4-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedevs-network/kutt-extension/HEAD/.github/assets/options-v4-1.png -------------------------------------------------------------------------------- /.github/assets/popup-v4-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedevs-network/kutt-extension/HEAD/.github/assets/popup-v4-1.png -------------------------------------------------------------------------------- /source/assets/favicon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedevs-network/kutt-extension/HEAD/source/assets/favicon-128.png -------------------------------------------------------------------------------- /.github/assets/direct-download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedevs-network/kutt-extension/HEAD/.github/assets/direct-download.png -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: [], 3 | theme: { 4 | extend: {}, 5 | }, 6 | variants: {}, 7 | plugins: [], 8 | } 9 | -------------------------------------------------------------------------------- /source/styles/main.scss: -------------------------------------------------------------------------------- 1 | @import '~tailwindcss/dist/base.min.css'; 2 | 3 | @import "base/fonts"; 4 | @import "base/variables"; 5 | @import "base/components"; 6 | @import "base/reset"; -------------------------------------------------------------------------------- /babel-plugin-macros.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | twin: { 3 | preset: 'styled-components', 4 | config: './tailwind.config.js', 5 | autoCssProp: true, // This adds the css prop when it's needed 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /twin.d.ts: -------------------------------------------------------------------------------- 1 | import 'twin.macro'; 2 | import styledComponent, {css as cssProperty} from 'styled-components'; 3 | 4 | declare module 'twin.macro' { 5 | const css: typeof cssProperty; 6 | const styled: typeof styledComponent; 7 | } 8 | -------------------------------------------------------------------------------- /source/components/Icon/styles.scss: -------------------------------------------------------------------------------- 1 | @keyframes spin { 2 | from { 3 | transform: rotate(0deg); 4 | } 5 | 6 | to { 7 | transform: rotate(360deg); 8 | } 9 | } 10 | 11 | #spinner { 12 | animation: spin 1s linear infinite; 13 | } -------------------------------------------------------------------------------- /views/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Kutt 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /source/styles/base/_reset.scss: -------------------------------------------------------------------------------- 1 | @import '~advanced-css-reset/dist/reset.css'; 2 | 3 | // Add your custom reset rules here 4 | 5 | * { 6 | margin: 0; 7 | padding: 0; 8 | outline: 0; 9 | } 10 | 11 | html { 12 | height: auto; 13 | } 14 | 15 | body { 16 | min-height: 100%; 17 | } -------------------------------------------------------------------------------- /views/history.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | History: Kutt 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /views/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Options: Kutt 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue template 3 | about: 'Describe this issue:' 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /source/Background/constants.ts: -------------------------------------------------------------------------------- 1 | export const CHECK_API_KEY = 'api.checkApiKey'; 2 | export const CHECK_API_KEY_TIMEOUT = 8000; // 8secs 3 | 4 | export const SHORTEN_URL = 'api.shortenUrl'; 5 | export const SHORTEN_URL_TIMEOUT = 20000; // 20secs 6 | 7 | export const FETCH_URLS_HISTORY = 'api.fetchUrlsHistory'; 8 | export const MAX_HISTORY_ITEMS = 15; 9 | -------------------------------------------------------------------------------- /source/util/mesageUtil.ts: -------------------------------------------------------------------------------- 1 | import {browser} from 'webextension-polyfill-ts'; 2 | 3 | const messageUtil = { 4 | send(name: string, params?: unknown): Promise { 5 | const data = { 6 | action: name, 7 | params, 8 | }; 9 | 10 | return browser.runtime.sendMessage(data); 11 | }, 12 | }; 13 | 14 | export default messageUtil; 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: 3 | directories: 4 | - ~/.npm 5 | node_js: 6 | - 12 7 | git: 8 | depth: 3 9 | script: 10 | - yarn run build 11 | deploy: 12 | provider: pages 13 | skip-cleanup: true 14 | keep-history: true 15 | github-token: $GITHUB_TOKEN 16 | local-dir: extension 17 | target-branch: extension 18 | on: 19 | branch: master 20 | -------------------------------------------------------------------------------- /source/components/BodyWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import 'twin.macro'; 3 | 4 | type WrapperProperties = { 5 | children: React.ReactChild; 6 | }; 7 | 8 | const BodyWrapper: React.FC = ({children}) => { 9 | // ToDo: get from props 10 | const isLoading = false; 11 | 12 | return ( 13 | <> 14 |
{isLoading ? 'Loading...' : children}
15 | 16 | ); 17 | }; 18 | 19 | export default BodyWrapper; 20 | -------------------------------------------------------------------------------- /source/util/link.ts: -------------------------------------------------------------------------------- 1 | import {IPV4_REGEX} from '@abhijithvijayan/ts-utils'; 2 | 3 | export const removeProtocol = (link: string): string => 4 | link.replace(/^https?:\/\//, ''); 5 | 6 | export function isValidUrl(url: string): boolean { 7 | // https://regex101.com/r/BzoIRR/1 8 | const ipRegex = IPV4_REGEX.toString().slice(2, -2); 9 | const re = new RegExp( 10 | `^(http[s]?:\\/\\/)(www\\.){0,1}(([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,5}[.]{0,1})|(${ipRegex}))` 11 | ); 12 | 13 | return re.test(url); 14 | } 15 | -------------------------------------------------------------------------------- /source/util/tabs.ts: -------------------------------------------------------------------------------- 1 | import {browser, Tabs} from 'webextension-polyfill-ts'; 2 | 3 | export function openExtOptionsPage(): Promise { 4 | return browser.runtime.openOptionsPage(); 5 | } 6 | 7 | export function openHistoryPage(): Promise { 8 | return browser.tabs.create({ 9 | active: true, 10 | url: 'history.html', 11 | }); 12 | } 13 | 14 | export function getCurrentTab(): Promise { 15 | return browser.tabs.query({ 16 | active: true, 17 | lastFocusedWindow: true, 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /source/components/Icon/Cross.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Cross: React.FC = () => { 4 | return ( 5 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default React.memo(Cross); 22 | -------------------------------------------------------------------------------- /source/components/Icon/Tick.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Tick: React.FC = () => { 4 | return ( 5 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default React.memo(Tick); 22 | -------------------------------------------------------------------------------- /source/components/Icon/Zap.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Zap: React.FC = () => { 4 | return ( 5 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default React.memo(Zap); 22 | -------------------------------------------------------------------------------- /source/Options/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import 'twin.macro'; 3 | 4 | const Header: React.FC = () => { 5 | return ( 6 | <> 7 |
8 | logo 15 | 16 |

Kutt

17 |
18 | 19 | ); 20 | }; 21 | 22 | export default React.memo(Header); 23 | -------------------------------------------------------------------------------- /source/styles/base/_variables.scss: -------------------------------------------------------------------------------- 1 | // **** colors **** 2 | $black: #111111; 3 | $light-black: #0f0f0f; 4 | $grey-white: #f3f3f3; 5 | $white: #ffffff; 6 | 7 | // **** fonts **** 8 | 9 | 10 | // font weights 11 | $thin: 100; 12 | $exlight: 200; 13 | $light: 300; 14 | $regular: 400; 15 | $medium: 500; 16 | $semibold: 600; 17 | $bold: 700; 18 | $exbold: 800; 19 | $exblack: 900; 20 | 21 | // **** other variables **** 22 | .d-none { 23 | display: none !important; 24 | } 25 | 26 | $copyIconBg: hsl(144, 100%, 96%); 27 | $statsTotalUnderline: hsl(200, 35%, 65%); -------------------------------------------------------------------------------- /source/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import tw, {css} from 'twin.macro'; 3 | 4 | import Icon from './Icon'; 5 | 6 | const Loader: React.FC = (props) => { 7 | return ( 8 |
20 | 21 |
22 | ); 23 | }; 24 | 25 | export default Loader; 26 | -------------------------------------------------------------------------------- /source/components/Icon/Clock.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Clock: React.FC = () => { 4 | return ( 5 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default React.memo(Clock); 23 | -------------------------------------------------------------------------------- /source/components/Icon/Eye.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Eye: React.FC = () => { 4 | return ( 5 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default React.memo(Eye); 23 | -------------------------------------------------------------------------------- /source/components/Icon/QRCode.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const QRCode: React.FC = () => { 4 | return ( 5 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default React.memo(QRCode); 22 | -------------------------------------------------------------------------------- /source/components/Icon/StarYellow.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const StarYellow: React.FC = () => { 4 | return ( 5 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default React.memo(StarYellow); 22 | -------------------------------------------------------------------------------- /source/components/Icon/StarWhite.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const StarWhite: React.FC = () => { 4 | return ( 5 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default React.memo(StarWhite); 22 | -------------------------------------------------------------------------------- /source/components/Icon/Copy.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Copy: React.FC = () => { 4 | return ( 5 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default React.memo(Copy); 23 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [abhijithvijayan] 4 | patreon: abhijithvijayan 5 | open_collective: abhijithvijayan 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: abhijithvijayan 11 | otechie: # Replace with a single Otechie username 12 | custom: ['https://www.buymeacoffee.com/abhijithvijayan', 'https://www.paypal.me/iamabhijithvijayan'] 13 | -------------------------------------------------------------------------------- /source/components/Icon/Refresh.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Refresh: React.FC = () => { 4 | return ( 5 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default React.memo(Refresh); 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@abhijithvijayan/tsconfig", 3 | "compilerOptions": { 4 | "target": "es5", // ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. 5 | "module": "esnext", // Module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. 6 | "lib": [ 7 | "dom", 8 | "dom.iterable", 9 | "esnext" 10 | ], 11 | "declaration": false, 12 | "isolatedModules": true, 13 | /* Additional Checks */ 14 | "useDefineForClassFields": true, 15 | "skipLibCheck": true, 16 | }, 17 | "include": [ 18 | "source", 19 | "twin.d.ts", 20 | "cssprop.d.ts", 21 | "webpack.config.js" 22 | ] 23 | } -------------------------------------------------------------------------------- /source/components/Icon/EyeClosed.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const EyeClosed: React.FC = () => { 4 | return ( 5 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default React.memo(EyeClosed); 22 | -------------------------------------------------------------------------------- /cssprop.d.ts: -------------------------------------------------------------------------------- 1 | import {} from 'react'; 2 | import {CSSProp} from 'styled-components'; 3 | 4 | declare module 'react' { 5 | interface Attributes { 6 | // NOTE: unlike the plain javascript version, it is not possible to get access 7 | // to the element's own attributes inside function interpolations. 8 | // Only theme will be accessible, and only with the DefaultTheme due to the global 9 | // nature of this declaration. 10 | // If you are writing this inline you already have access to all the attributes anyway, 11 | // no need for the extra indirection. 12 | /** 13 | * If present, this React element will be converted by 14 | * `babel-plugin-styled-components` into a styled component 15 | * with the given css as its styles. 16 | */ 17 | css?: CSSProp; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /source/styles/base/_components.scss: -------------------------------------------------------------------------------- 1 | .icon { 2 | width: 26px; 3 | height: 26px; 4 | background-color: rgb(235, 255, 243); 5 | align-items: center; 6 | justify-content: center; 7 | display: flex; 8 | position: relative; 9 | cursor: pointer; 10 | box-shadow: rgba(138, 158, 168, 0.12) 0px 2px 1px; 11 | padding: 4px; 12 | outline: none; 13 | transition: transform 0.4s ease-out 0s; 14 | border-radius: 100%; 15 | 16 | svg { 17 | // width: 100%; 18 | // height: 100%; 19 | transition: all 0.2s ease-out 0s; 20 | } 21 | } 22 | 23 | .max-w-min { 24 | max-width: min-content; 25 | } 26 | 27 | .max-w-max { 28 | max-width: max-content; 29 | } 30 | 31 | .max-h-min { 32 | max-height: min-content; 33 | } 34 | 35 | .max-h-max { 36 | max-height: max-content; 37 | } 38 | 39 | .h-min { 40 | height: min-content; 41 | } 42 | 43 | .h-max { 44 | height: max-content; 45 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "webextensions": true 4 | }, 5 | "extends": [ 6 | "@abhijithvijayan/eslint-config/typescript", 7 | "@abhijithvijayan/eslint-config/node", 8 | "@abhijithvijayan/eslint-config/react" 9 | ], 10 | "parserOptions": { 11 | "project": "./tsconfig.json", 12 | "sourceType": "module" 13 | }, 14 | "rules": { 15 | "no-console": "off", 16 | "no-shadow": ["error", { 17 | "builtinGlobals": false, 18 | "hoist": "functions", 19 | "allow": [] 20 | }], 21 | "react/jsx-props-no-spreading": "off", 22 | "jsx-a11y/label-has-associated-control": "off", 23 | "@typescript-eslint/no-explicit-any": "warn", 24 | "react/no-array-index-key": "warn", 25 | "node/no-unsupported-features/es-syntax": ["error", { 26 | "ignores": ["modules"] 27 | }] 28 | }, 29 | "settings": { 30 | "node": { 31 | "tryExtensions": [".tsx"] // append tsx to the list as well 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /source/components/Icon/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './styles.scss'; 4 | 5 | const Spinner: React.FC = () => { 6 | return ( 7 | <> 8 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default React.memo(Spinner); 35 | -------------------------------------------------------------------------------- /source/Popup/index.tsx: -------------------------------------------------------------------------------- 1 | import {ThemeProvider} from 'styled-components'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | 5 | // Common styles 6 | import '../styles/main.scss'; 7 | 8 | import {ExtensionSettingsProvider} from '../contexts/extension-settings-context'; 9 | import {RequestStatusProvider} from '../contexts/request-status-context'; 10 | import Popup from './Popup'; 11 | 12 | // eslint-disable-next-line import/no-webpack-loader-syntax, import/no-unresolved, @typescript-eslint/no-var-requires, node/no-missing-require 13 | const theme = require('sass-extract-loader?{"plugins": ["sass-extract-js"]}!../styles/base/_variables.scss'); 14 | // Require sass variables using sass-extract-loader and specify the plugin 15 | 16 | ReactDOM.render( 17 | 18 | 19 | 20 | 21 | 22 | 23 | , 24 | document.getElementById('popup-root') 25 | ); 26 | -------------------------------------------------------------------------------- /source/Options/index.tsx: -------------------------------------------------------------------------------- 1 | import {ThemeProvider} from 'styled-components'; 2 | import ReactDOM from 'react-dom'; 3 | import React from 'react'; 4 | 5 | // Common styles 6 | import '../styles/main.scss'; 7 | 8 | import {ExtensionSettingsProvider} from '../contexts/extension-settings-context'; 9 | import {RequestStatusProvider} from '../contexts/request-status-context'; 10 | import Options from './Options'; 11 | 12 | // eslint-disable-next-line import/no-webpack-loader-syntax, import/no-unresolved, @typescript-eslint/no-var-requires, node/no-missing-require 13 | const theme = require('sass-extract-loader?{"plugins": ["sass-extract-js"]}!../styles/base/_variables.scss'); 14 | // Require sass variables using sass-extract-loader and specify the plugin 15 | 16 | ReactDOM.render( 17 | 18 | 19 | 20 | 21 | 22 | 23 | , 24 | document.getElementById('options-root') 25 | ); 26 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | // Latest stable ECMAScript features 5 | "@babel/preset-env", 6 | { 7 | "useBuiltIns": false, 8 | // Do not transform modules to CJS 9 | "modules": false, 10 | "targets": { 11 | "chrome": "49", 12 | "firefox": "52", 13 | "opera": "36", 14 | "edge": "79" 15 | } 16 | } 17 | ], 18 | "@babel/typescript", 19 | "@babel/react" 20 | ], 21 | "plugins": [ 22 | ["@babel/plugin-proposal-class-properties"], 23 | ["@babel/plugin-transform-destructuring", { 24 | "useBuiltIns": true 25 | }], 26 | ["@babel/plugin-proposal-object-rest-spread", { 27 | "useBuiltIns": true 28 | }], 29 | [ 30 | // Polyfills the runtime needed for async/await and generators 31 | "@babel/plugin-transform-runtime", 32 | { 33 | "helpers": false, 34 | "regenerator": true 35 | } 36 | ], 37 | // Support for twin.macro 38 | "babel-plugin-macros", 39 | // https://git.io/JJUrL 40 | "@babel/plugin-transform-react-jsx" 41 | ] 42 | } -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Abhijith Vijayan (https://abhijithvijayan.in) 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 | -------------------------------------------------------------------------------- /source/History/index.tsx: -------------------------------------------------------------------------------- 1 | import {ThemeProvider} from 'styled-components'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | 5 | // Common styles 6 | import './styles.scss'; 7 | 8 | import History from './History'; 9 | import {ExtensionSettingsProvider} from '../contexts/extension-settings-context'; 10 | import {ShortenedLinksProvider} from '../contexts/shortened-links-context'; 11 | import {RequestStatusProvider} from '../contexts/request-status-context'; 12 | 13 | // eslint-disable-next-line import/no-webpack-loader-syntax, import/no-unresolved, @typescript-eslint/no-var-requires, node/no-missing-require 14 | const theme = require('sass-extract-loader?{"plugins": ["sass-extract-js"]}!../styles/base/_variables.scss'); 15 | // Require sass variables using sass-extract-loader and specify the plugin 16 | 17 | ReactDOM.render( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | , 27 | document.getElementById('history-root') 28 | ); 29 | -------------------------------------------------------------------------------- /source/components/Icon/Settings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Settings: React.FC = () => { 4 | return ( 5 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default React.memo(Settings); 23 | -------------------------------------------------------------------------------- /source/components/Icon/Icon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import StarYellowIcon from './StarYellow'; 4 | import EyeClosedIcon from './EyeClosed'; 5 | import StarWhiteIcon from './StarWhite'; 6 | import SettingsIcon from './Settings'; 7 | import RefreshIcon from './Refresh'; 8 | import SpinnerIcon from './Spinner'; 9 | import QRCodeIcon from './QRCode'; 10 | import CrossIcon from './Cross'; 11 | import ClockIcon from './Clock'; 12 | import CopyIcon from './Copy'; 13 | import TickIcon from './Tick'; 14 | import ZapIcon from './Zap'; 15 | import EyeIcon from './Eye'; 16 | 17 | const icons = { 18 | clock: ClockIcon, 19 | copy: CopyIcon, 20 | cross: CrossIcon, 21 | eye: EyeIcon, 22 | 'eye-closed': EyeClosedIcon, 23 | qrcode: QRCodeIcon, 24 | refresh: RefreshIcon, 25 | settings: SettingsIcon, 26 | spinner: SpinnerIcon, 27 | 'star-yellow': StarYellowIcon, 28 | 'star-white': StarWhiteIcon, 29 | tick: TickIcon, 30 | zap: ZapIcon, 31 | }; 32 | 33 | export type Icons = keyof typeof icons; 34 | 35 | type Props = { 36 | name: Icons; 37 | title?: string; 38 | stroke?: string; 39 | fill?: string; 40 | hoverFill?: string; 41 | hoverStroke?: string; 42 | strokeWidth?: string; 43 | className?: string; 44 | onClick?: () => void; 45 | }; 46 | 47 | const Icon: React.FC = ({name, ...rest}) => { 48 | return
{React.createElement(icons[name])}
; 49 | }; 50 | 51 | export default Icon; 52 | -------------------------------------------------------------------------------- /source/util/browser.ts: -------------------------------------------------------------------------------- 1 | import {EMPTY_STRING} from '@abhijithvijayan/ts-utils'; 2 | 3 | // Custom fork of https://github.com/DamonOehlman/detect-browser/blob/master/src/index.ts 4 | type Browser = 'edge-chromium' | 'chrome' | 'firefox' | 'opera'; 5 | type UserAgentRule = [Browser, RegExp]; 6 | type UserAgentMatch = [Browser, RegExpExecArray] | false; 7 | 8 | const userAgentRules: UserAgentRule[] = [ 9 | ['edge-chromium', /Edg\/([0-9.]+)/], 10 | ['chrome', /(?!Chrom.*OPR)Chrom(?:e|ium)\/([0-9.]+)(:?\s|$)/], 11 | ['firefox', /Firefox\/([0-9.]+)(?:\s|$)/], 12 | ['opera', /Opera\/([0-9.]+)(?:\s|$)/], 13 | ['opera', /OPR\/([0-9.]+)(:?\s|$)/], 14 | ]; 15 | 16 | function matchUserAgent(ua: string): UserAgentMatch { 17 | return ( 18 | ua !== EMPTY_STRING && 19 | userAgentRules.reduce( 20 | (matched: UserAgentMatch, [browser, regex]) => { 21 | if (matched) { 22 | return matched; 23 | } 24 | 25 | const uaMatch = regex.exec(ua); 26 | return !!uaMatch && [browser, uaMatch]; 27 | }, 28 | false 29 | ) 30 | ); 31 | } 32 | export function detectBrowser(): Browser | null { 33 | const matchedRule = matchUserAgent(navigator.userAgent); 34 | 35 | if (!matchedRule) { 36 | return null; 37 | } 38 | 39 | const [name, match] = matchedRule; 40 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 41 | let versionParts = match[1] && match[1].split(/[._]/).slice(0, 3); 42 | if (!versionParts) { 43 | versionParts = []; 44 | } 45 | 46 | return name; 47 | } 48 | -------------------------------------------------------------------------------- /source/util/settings.ts: -------------------------------------------------------------------------------- 1 | import {browser} from 'webextension-polyfill-ts'; 2 | 3 | import {DomainEntryProperties} from '../Background'; 4 | 5 | // Core Extensions settings props 6 | export type ExtensionSettingsProperties = { 7 | apikey: string; 8 | history: boolean; 9 | user?: { 10 | email?: string; 11 | domains?: DomainEntryProperties[]; 12 | } | null; 13 | }; 14 | 15 | // update extension settings in browser storage 16 | export function saveExtensionSettings(settings: any): Promise { 17 | return browser.storage.local.set({ 18 | settings, 19 | }); 20 | } 21 | 22 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 23 | export function getExtensionSettings(): Promise<{[s: string]: any}> { 24 | return browser.storage.local.get('settings'); 25 | } 26 | 27 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 28 | export async function updateExtensionSettings(newFields?: { 29 | [s: string]: any; 30 | }): Promise { 31 | const {settings = {}} = await getExtensionSettings(); 32 | 33 | return saveExtensionSettings({...settings, ...newFields}); 34 | } 35 | 36 | // ToDo: Remove in the next major release 37 | export function migrateSettings(settings: any): Promise { 38 | // clear all keys 39 | browser.storage.local.clear(); 40 | 41 | return browser.storage.local.set({ 42 | settings, 43 | }); 44 | } 45 | 46 | // ToDo: Remove in the next major release 47 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 48 | export function getPreviousSettings(): Promise<{[s: string]: any}> { 49 | return browser.storage.local.get(null); 50 | } 51 | -------------------------------------------------------------------------------- /source/History/Modal.tsx: -------------------------------------------------------------------------------- 1 | import tw, {css} from 'twin.macro'; 2 | import QRCode from 'qrcode.react'; 3 | import React from 'react'; 4 | 5 | type Props = { 6 | link: string; 7 | setModalView: React.Dispatch>; 8 | }; 9 | 10 | const Modal: React.FC = ({link, setModalView}) => { 11 | return ( 12 | <> 13 |
23 |
32 |
33 | 34 |
35 | 36 |
37 | 57 |
58 |
59 |
60 | 61 | ); 62 | }; 63 | 64 | export default Modal; 65 | -------------------------------------------------------------------------------- /source/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Kutt", 4 | "version": "0.0.0", 5 | "short_name": "Kutt", 6 | "description": "Shorten long URLs with just one click.", 7 | "icons": { 8 | "16": "assets/favicon-16.png", 9 | "32": "assets/favicon-32.png", 10 | "48": "assets/favicon-48.png", 11 | "128": "assets/favicon-128.png" 12 | }, 13 | "homepage_url": "https://github.com/thedevs-network/kutt-extension.git", 14 | "__firefox__browser_specific_settings": { 15 | "gecko": { 16 | "id": "support@kutt.it", 17 | "strict_min_version": "52.0" 18 | } 19 | }, 20 | "__chrome|firefox__author": "abhijithvijayan", 21 | "__opera__developer": { 22 | "name": "abhijithvijayan" 23 | }, 24 | "browser_action": { 25 | "default_popup": "popup.html", 26 | "default_icon": { 27 | "16": "assets/favicon-16.png", 28 | "32": "assets/favicon-32.png", 29 | "48": "assets/favicon-48.png", 30 | "128": "assets/favicon-128.png" 31 | }, 32 | "default_title": "Shorten this URL", 33 | "__chrome|opera__chrome_style": false, 34 | "__firefox__browser_style": false 35 | }, 36 | "background": { 37 | "__chrome|opera__persistent": false, 38 | "scripts": [ 39 | "js/background.bundle.js" 40 | ] 41 | }, 42 | "__chrome__minimum_chrome_version": "49", 43 | "__opera__minimum_opera_version": "36", 44 | "__chrome|opera__permissions": [ 45 | "activeTab", 46 | "storage", 47 | "clipboardRead", 48 | "http://*/*", 49 | "https://*/*" 50 | ], 51 | "__firefox__permissions": [ 52 | "activeTab", 53 | "storage", 54 | "clipboardWrite", 55 | "clipboardRead", 56 | "http://*/*", 57 | "https://*/*" 58 | ], 59 | "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", 60 | "__chrome|opera__options_page": "options.html", 61 | "options_ui": { 62 | "page": "options.html", 63 | "open_in_tab": true, 64 | "__chrome__chrome_style": false, 65 | "__firefox|opera__browser_style": false 66 | } 67 | } -------------------------------------------------------------------------------- /source/Options/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import 'twin.macro'; 3 | 4 | import {detectBrowser} from '../util/browser'; 5 | import {StoreLinks} from '../Background'; 6 | 7 | import Icon from '../components/Icon'; 8 | 9 | const Footer: React.FC = () => { 10 | return ( 11 | <> 12 | 64 | 65 | ); 66 | }; 67 | 68 | export default React.memo(Footer); 69 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing Guidelines 2 | 3 | ## Assets 4 | 5 | - [kutt.it API](https://github.com/thedevs-network/kutt#api) is used to retreive shortened URLs. 6 | 7 | ## Development 8 | 9 | - `npm install` to install dependencies. 10 | - To watch file changes in developement 11 | 12 | - Chrome 13 | - `npm run dev:chrome` 14 | - Firefox 15 | - `npm run dev:firefox` 16 | - Opera 17 | - `npm run dev:opera` 18 | 19 | (Reload Extension Manually in the browser) 20 | 21 | - Load extension in browser 22 | 23 | - ### Chrome 24 | 25 | - Go to the browser address bar and type `chrome://extensions` 26 | - Check the `Developer Mode` button to enable it. 27 | - Click on the `Load Unpacked Extension…` button. 28 | - Select your extension’s extracted directory. 29 | 30 | 31 | 32 | - ### Firefox 33 | 34 | - Load the Add-on via `about:debugging` as temporary Add-on. 35 | - Choose the `manifest.json` file in the extracted directory 36 | 37 | 38 | 39 | - ### Opera 40 | 41 | - Load the extension via `opera:extensions` 42 | - Check the `Developer Mode` and load as unpacked from extension’s extracted directory. 43 | 44 | 45 | 46 | - Generate an API Key from `https://kutt.it/` (Settings page) 47 | - Paste and Save the `Key` in extension's `options page`. 48 | 49 | `npm run build` builds the extension for all the browsers to `extension/(browser)` directory respectively. 50 | 51 | ## Testing 52 | 53 | Download latest `Release` 54 | 55 | [Direct download](https://github.com/thedevs-network/kutt-extension/releases) 58 | 59 |
60 | 61 | ## Self-hosted Kutt 62 | 63 | - **Enable Developer Options** to use with self-hosted kutt 64 | - Save the self hosted domain in the input (eg: https://mykutt.it) 65 | - **Note**: the api endpoint is automatically appended during the api call. 66 | 67 | ### For Opera Users 68 | 69 | In order to install this extension from Chrome Web Store, another opera extension called **Install Chrome Extension** should be installed first. 70 | 71 | - [Opera addon :: Install Chrome Extension](https://addons.opera.com/en/extensions/details/install-chrome-extensions/) 72 | - [Opera addon :: Kutt](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd) 73 | 74 | ![](https://i.imgur.com/TJTisdC.png) 75 | -------------------------------------------------------------------------------- /source/contexts/request-status-context.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import React, {createContext, useReducer, useContext} from 'react'; 3 | 4 | export enum RequestStatusActionTypes { 5 | SET_REQUEST_STATUS = 'set-request-status', 6 | SET_LOADING = 'set-loading', 7 | } 8 | 9 | type SET_REQUEST_STATUS = { 10 | type: RequestStatusActionTypes.SET_REQUEST_STATUS; 11 | payload: { 12 | error: boolean; 13 | message: string; 14 | }; 15 | }; 16 | 17 | type SET_LOADING = { 18 | type: RequestStatusActionTypes.SET_LOADING; 19 | payload: boolean; 20 | }; 21 | 22 | type Action = SET_REQUEST_STATUS | SET_LOADING; 23 | 24 | type InitialValues = { 25 | loading: boolean; 26 | error: boolean | null; 27 | message: string; 28 | }; 29 | 30 | const initialValues: InitialValues = { 31 | loading: true, 32 | error: null, 33 | message: '', 34 | }; 35 | 36 | type State = InitialValues; 37 | type Dispatch = (action: Action) => void; 38 | 39 | const RequestStatusStateContext = createContext(undefined); 40 | const RequestStatusDispatchContext = createContext( 41 | undefined 42 | ); 43 | 44 | function requestStatusReducer(state: State, action: Action): State { 45 | switch (action.type) { 46 | case RequestStatusActionTypes.SET_REQUEST_STATUS: { 47 | return {...state, ...action.payload}; 48 | } 49 | 50 | case RequestStatusActionTypes.SET_LOADING: { 51 | return {...state, loading: action.payload}; 52 | } 53 | 54 | default: 55 | return state; 56 | } 57 | } 58 | 59 | function useRequestStatusState(): State { 60 | const context = useContext(RequestStatusStateContext); 61 | 62 | if (context === undefined) { 63 | throw new Error( 64 | 'useRequestStatusState must be used within a RequestStatusProvider' 65 | ); 66 | } 67 | 68 | return context; 69 | } 70 | 71 | function useRequestStatusDispatch(): Dispatch { 72 | const context = useContext(RequestStatusDispatchContext); 73 | 74 | if (context === undefined) { 75 | throw new Error( 76 | 'useRequestStatusDispatch must be used within a RequestStatusProvider' 77 | ); 78 | } 79 | 80 | return context; 81 | } 82 | 83 | function useRequestStatus(): [State, Dispatch] { 84 | // To access const [state, dispatch] = useRequestStatus() 85 | return [useRequestStatusState(), useRequestStatusDispatch()]; 86 | } 87 | 88 | type RequestStatusProviderProps = { 89 | children: React.ReactNode; 90 | }; 91 | 92 | const RequestStatusProvider: React.FC = ({ 93 | children, 94 | }) => { 95 | const [state, dispatch] = useReducer(requestStatusReducer, initialValues); 96 | 97 | return ( 98 | <> 99 | 100 | 101 | {children} 102 | 103 | 104 | 105 | ); 106 | }; 107 | 108 | export {RequestStatusProvider, useRequestStatus}; 109 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at thebluedoor@protonmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. -------------------------------------------------------------------------------- /source/Popup/ResponseBody.tsx: -------------------------------------------------------------------------------- 1 | import CopyToClipboard from 'react-copy-to-clipboard'; 2 | import React, {useState, useEffect} from 'react'; 3 | import tw, {styled, css} from 'twin.macro'; 4 | import QRCode from 'qrcode.react'; 5 | 6 | import {useRequestStatus} from '../contexts/request-status-context'; 7 | import {removeProtocol} from '../util/link'; 8 | import Icon from '../components/Icon'; 9 | 10 | export type ProcessedRequestProperties = { 11 | error: boolean | null; 12 | message: string; 13 | }; 14 | 15 | const StyledPopupBody = styled.div` 16 | ${tw`flex items-center justify-center px-4 pt-4 pb-0`} 17 | 18 | .icon { 19 | svg { 20 | stroke: rgb(101, 189, 137); 21 | stroke-width: 2; 22 | } 23 | } 24 | 25 | h1 { 26 | border-bottom: 1px dotted ${({theme}): string => theme.statsTotalUnderline}; 27 | padding-bottom: 2px; 28 | color: rgb(41, 71, 86); 29 | 30 | ${tw`hover:opacity-75 min-w-0 m-0 text-2xl font-light cursor-pointer`} 31 | } 32 | `; 33 | 34 | const ResponseBody: React.FC = () => { 35 | const [{error, message}] = useRequestStatus(); 36 | const [copied, setCopied] = useState(false); 37 | const [QRView, setQRView] = useState(false); 38 | 39 | // reset copy message 40 | useEffect(() => { 41 | let timer: NodeJS.Timeout | null = null; 42 | 43 | timer = setTimeout(() => { 44 | setCopied(false); 45 | }, 1300); 46 | 47 | return (): void => { 48 | if (timer) { 49 | clearTimeout(timer); 50 | } 51 | }; 52 | }, [copied]); 53 | 54 | return ( 55 | <> 56 | 57 | {!error ? ( 58 | <> 59 | { 70 | return setQRView(!QRView); 71 | }} 72 | /> 73 | 74 | {!copied ? ( 75 | { 78 | return setCopied(true); 79 | }} 80 | > 81 | 82 | 83 | ) : ( 84 | 85 | )} 86 | 87 | { 90 | return setCopied(true); 91 | }} 92 | > 93 |

{removeProtocol(message)}

94 |
95 | 96 | ) : ( 97 |

98 | {message} 99 |

100 | )} 101 |
102 | 103 | {!error && QRView && ( 104 |
105 | 106 |
107 | )} 108 | 109 | ); 110 | }; 111 | 112 | export default ResponseBody; 113 | -------------------------------------------------------------------------------- /source/contexts/shortened-links-context.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import React, {createContext, useContext, useReducer} from 'react'; 3 | 4 | import {UserShortenedLinkStats} from '../Background'; 5 | 6 | export enum ShortenedLinksActionTypes { 7 | HYDRATE_SHORTENED_LINKS = 'hydrate-shortened-links', 8 | SET_CURRENT_SELECTED = 'toggle-qrcode-modal', 9 | } 10 | 11 | type HYDRATE_SHORTENED_LINKS = { 12 | type: ShortenedLinksActionTypes.HYDRATE_SHORTENED_LINKS; 13 | payload: { 14 | items: UserShortenedLinkStats[]; 15 | total: number; 16 | }; 17 | }; 18 | 19 | type SET_CURRENT_SELECTED = { 20 | type: ShortenedLinksActionTypes.SET_CURRENT_SELECTED; 21 | payload: string; 22 | }; 23 | 24 | type Action = HYDRATE_SHORTENED_LINKS | SET_CURRENT_SELECTED; 25 | 26 | type InitialValues = { 27 | items: UserShortenedLinkStats[]; 28 | total: number; 29 | selected: UserShortenedLinkStats | null; 30 | }; 31 | 32 | const initialValues: InitialValues = { 33 | items: [], 34 | total: 0, 35 | selected: null, 36 | }; 37 | 38 | type State = InitialValues; 39 | type Dispatch = (action: Action) => void; 40 | 41 | const ShortenedLinksStateContext = createContext(undefined); 42 | const ShortenedLinksDispatchContext = createContext( 43 | undefined 44 | ); 45 | 46 | const shortenedLinksReducer = (state: State, action: Action): State => { 47 | switch (action.type) { 48 | case ShortenedLinksActionTypes.HYDRATE_SHORTENED_LINKS: { 49 | return { 50 | ...state, 51 | ...action.payload, 52 | }; 53 | } 54 | 55 | case ShortenedLinksActionTypes.SET_CURRENT_SELECTED: { 56 | const selected: null | UserShortenedLinkStats = 57 | state.items.filter((item) => item.id === action.payload)[0] || null; 58 | 59 | return {...state, selected}; 60 | } 61 | 62 | default: 63 | return state; 64 | } 65 | }; 66 | 67 | function useShortenedLinksContextState(): State { 68 | const context = useContext(ShortenedLinksStateContext); 69 | 70 | if (context === undefined) { 71 | throw new Error( 72 | 'useShortenedLinksContextState must be used within a ShortenedLinksProvider' 73 | ); 74 | } 75 | 76 | return context; 77 | } 78 | 79 | function useShortenedLinksContextDispatch(): Dispatch { 80 | const context = useContext(ShortenedLinksDispatchContext); 81 | 82 | if (context === undefined) { 83 | throw new Error( 84 | 'useShortenedLinksContextDispatch must be used within a ShortenedLinksProvider' 85 | ); 86 | } 87 | 88 | return context; 89 | } 90 | 91 | function useShortenedLinks(): [State, Dispatch] { 92 | return [useShortenedLinksContextState(), useShortenedLinksContextDispatch()]; 93 | } 94 | 95 | const ShortenedLinksProvider: React.FC = ({children}) => { 96 | const [state, dispatch] = useReducer(shortenedLinksReducer, initialValues); 97 | 98 | return ( 99 | <> 100 | 101 | 102 | {children} 103 | 104 | 105 | 106 | ); 107 | }; 108 | 109 | export {useShortenedLinks, ShortenedLinksProvider}; 110 | -------------------------------------------------------------------------------- /source/Options/Options.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from 'react'; 2 | import 'twin.macro'; 3 | 4 | import {getExtensionSettings} from '../util/settings'; 5 | import { 6 | HostProperties, 7 | useExtensionSettings, 8 | ExtensionSettingsActionTypes, 9 | } from '../contexts/extension-settings-context'; 10 | import { 11 | useRequestStatus, 12 | RequestStatusActionTypes, 13 | } from '../contexts/request-status-context'; 14 | import {isValidUrl} from '../util/link'; 15 | import {Kutt} from '../Background'; 16 | 17 | import BodyWrapper from '../components/BodyWrapper'; 18 | import Loader from '../components/Loader'; 19 | import Header from './Header'; 20 | import Footer from './Footer'; 21 | import Form from './Form'; 22 | 23 | const Options: React.FC = () => { 24 | const extensionSettingsDispatch = useExtensionSettings()[1]; 25 | const [requestStatusState, requestStatusDispatch] = useRequestStatus(); 26 | 27 | useEffect(() => { 28 | async function getSavedSettings(): Promise { 29 | const {settings = {}} = await getExtensionSettings(); 30 | const advancedSettings: boolean = 31 | (settings?.advanced as boolean) || false; 32 | 33 | const defaultHost: HostProperties = 34 | (advancedSettings && 35 | (settings?.host as string) && 36 | isValidUrl(settings.host as string) && { 37 | hostDomain: (settings.host as string) 38 | .replace('http://', '') 39 | .replace('https://', '') 40 | .replace('www.', '') 41 | .split(/[/?#]/)[0], // extract domain 42 | hostUrl: (settings.host as string).endsWith('/') 43 | ? (settings.host as string).slice(0, -1) 44 | : (settings.host as string), // slice `/` at the end 45 | }) || 46 | Kutt; 47 | 48 | // inject existing keys (if field doesn't exist, use default) 49 | const defaultExtensionConfig = { 50 | apikey: (settings?.apikey as string)?.trim() || '', 51 | history: (settings?.history as boolean) || false, 52 | advanced: 53 | defaultHost.hostUrl.trim() !== Kutt.hostUrl && advancedSettings, // disable `advanced` if customhost is not set 54 | host: defaultHost, 55 | }; 56 | 57 | extensionSettingsDispatch({ 58 | type: ExtensionSettingsActionTypes.HYDRATE_EXTENSION_SETTINGS, 59 | payload: defaultExtensionConfig, 60 | }); 61 | requestStatusDispatch({ 62 | type: RequestStatusActionTypes.SET_LOADING, 63 | payload: false, 64 | }); 65 | } 66 | 67 | getSavedSettings(); 68 | }, [extensionSettingsDispatch, requestStatusDispatch]); 69 | 70 | return ( 71 | <> 72 | 73 |
77 |
81 |
82 | 83 | {!requestStatusState.loading ? ( 84 |
85 | ) : ( 86 |
87 | 88 |
89 | )} 90 | 91 |
92 |
93 |
94 |
95 | 96 | ); 97 | }; 98 | 99 | export default Options; 100 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ignore all haters 2 | haters/ 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (https://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # TypeScript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # next.js build output 64 | .next 65 | .DS_Store 66 | 67 | ## scripts build 68 | extension/ 69 | dist/ 70 | 71 | # awesome-ts-loader cache 72 | .awcache 73 | 74 | # yarn 2 75 | # https://github.com/yarnpkg/berry/issues/454#issuecomment-530312089 76 | .yarn/* 77 | !.yarn/releases 78 | !.yarn/plugins 79 | .pnp.* 80 | 81 | ### WebStorm+all ### 82 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 83 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 84 | 85 | # User-specific stuff 86 | .idea/**/workspace.xml 87 | .idea/**/tasks.xml 88 | .idea/**/usage.statistics.xml 89 | .idea/**/dictionaries 90 | .idea/**/shelf 91 | 92 | # Generated files 93 | .idea/**/contentModel.xml 94 | 95 | # Sensitive or high-churn files 96 | .idea/**/dataSources/ 97 | .idea/**/dataSources.ids 98 | .idea/**/dataSources.local.xml 99 | .idea/**/sqlDataSources.xml 100 | .idea/**/dynamic.xml 101 | .idea/**/uiDesigner.xml 102 | .idea/**/dbnavigator.xml 103 | 104 | # Gradle 105 | .idea/**/gradle.xml 106 | .idea/**/libraries 107 | 108 | # Gradle and Maven with auto-import 109 | # When using Gradle or Maven with auto-import, you should exclude module files, 110 | # since they will be recreated, and may cause churn. Uncomment if using 111 | # auto-import. 112 | # .idea/artifacts 113 | # .idea/compiler.xml 114 | # .idea/jarRepositories.xml 115 | # .idea/modules.xml 116 | # .idea/*.iml 117 | # .idea/modules 118 | # *.iml 119 | # *.ipr 120 | 121 | # CMake 122 | cmake-build-*/ 123 | 124 | # Mongo Explorer plugin 125 | .idea/**/mongoSettings.xml 126 | 127 | # File-based project format 128 | *.iws 129 | 130 | # IntelliJ 131 | out/ 132 | 133 | # mpeltonen/sbt-idea plugin 134 | .idea_modules/ 135 | 136 | # JIRA plugin 137 | atlassian-ide-plugin.xml 138 | 139 | # Cursive Clojure plugin 140 | .idea/replstate.xml 141 | 142 | # Crashlytics plugin (for Android Studio and IntelliJ) 143 | com_crashlytics_export_strings.xml 144 | crashlytics.properties 145 | crashlytics-build.properties 146 | fabric.properties 147 | 148 | # Editor-based Rest Client 149 | .idea/httpRequests 150 | 151 | # Android studio 3.1+ serialized cache file 152 | .idea/caches/build_file_checksums.ser 153 | 154 | ### WebStorm+all Patch ### 155 | # Ignores the whole .idea folder and all .iml files 156 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 157 | 158 | .idea/ 159 | 160 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 161 | 162 | *.iml 163 | modules.xml 164 | .idea/misc.xml 165 | *.ipr 166 | 167 | # Sonarlint plugin 168 | .idea/sonarlint 169 | -------------------------------------------------------------------------------- /source/Popup/Header.tsx: -------------------------------------------------------------------------------- 1 | import {isNull, EMPTY_STRING} from '@abhijithvijayan/ts-utils'; 2 | import React, {useState} from 'react'; 3 | import tw, {styled} from 'twin.macro'; 4 | 5 | import {openExtOptionsPage, openHistoryPage} from '../util/tabs'; 6 | import {updateExtensionSettings} from '../util/settings'; 7 | import {CHECK_API_KEY} from '../Background/constants'; 8 | import { 9 | ExtensionSettingsActionTypes, 10 | useExtensionSettings, 11 | } from '../contexts/extension-settings-context'; 12 | import messageUtil from '../util/mesageUtil'; 13 | import { 14 | SuccessfulApiKeyCheckProperties, 15 | AuthRequestBodyProperties, 16 | ApiErroredProperties, 17 | ErrorStateProperties, 18 | } from '../Background'; 19 | 20 | import Icon from '../components/Icon'; 21 | 22 | const StyledIcon = styled(Icon)` 23 | ${tw`hover:opacity-75 bg-transparent shadow-none`} 24 | 25 | color: rgb(187, 187, 187); 26 | `; 27 | 28 | const Header: React.FC = () => { 29 | const [extensionSettingsState, extensionSettingsDispatch] = 30 | useExtensionSettings(); 31 | const [loading, setLoading] = useState(false); 32 | const [errored, setErrored] = useState({ 33 | error: null, 34 | message: EMPTY_STRING, 35 | }); 36 | 37 | async function fetchUserDomains(): Promise { 38 | // show loading spinner 39 | setLoading(true); 40 | 41 | const apiKeyValidationBody: AuthRequestBodyProperties = { 42 | apikey: extensionSettingsState.apikey, 43 | hostUrl: extensionSettingsState.host.hostUrl, 44 | }; 45 | 46 | // request API 47 | const response: SuccessfulApiKeyCheckProperties | ApiErroredProperties = 48 | await messageUtil.send(CHECK_API_KEY, apiKeyValidationBody); 49 | 50 | // stop spinner 51 | setLoading(false); 52 | 53 | if (!response.error) { 54 | // ---- success ---- // 55 | setErrored({error: false, message: 'Fetching domains successful'}); 56 | 57 | // Store user account information 58 | const {domains, email} = response.data; 59 | await updateExtensionSettings({user: {domains, email}}); 60 | } else { 61 | // ---- errored ---- // 62 | setErrored({error: true, message: response.message}); 63 | 64 | // Delete `user` field from settings 65 | await updateExtensionSettings({user: null}); 66 | } 67 | 68 | // hot reload page(read from localstorage and update state) 69 | extensionSettingsDispatch({ 70 | type: ExtensionSettingsActionTypes.RELOAD_EXTENSION_SETTINGS, 71 | payload: !extensionSettingsState.reload, 72 | }); 73 | 74 | setTimeout(() => { 75 | // Reset status 76 | setErrored({error: null, message: EMPTY_STRING}); 77 | }, 1000); 78 | } 79 | 80 | const iconToShow = loading 81 | ? 'spinner' 82 | : (!isNull(errored.error) && (!errored.error ? 'tick' : 'cross')) || 83 | 'refresh'; 84 | 85 | return ( 86 | <> 87 |
88 |
89 | logo 96 |
97 | 98 |
99 | 105 | {extensionSettingsState.history && ( 106 | 112 | )} 113 | 119 |
120 |
121 | 122 | ); 123 | }; 124 | 125 | export default Header; 126 | -------------------------------------------------------------------------------- /source/contexts/extension-settings-context.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import React, {createContext, useReducer, useContext} from 'react'; 3 | 4 | import {Kutt} from '../Background'; 5 | 6 | export enum ExtensionSettingsActionTypes { 7 | HYDRATE_EXTENSION_SETTINGS = 'set-extension-settings', 8 | RELOAD_EXTENSION_SETTINGS = 'reload-extension-settings', 9 | } 10 | 11 | export type HostProperties = { 12 | hostDomain: string; 13 | hostUrl: string; 14 | }; 15 | 16 | export type DomainOptionsProperties = { 17 | option: string; 18 | value: string; 19 | id: string; 20 | disabled?: boolean; 21 | }; 22 | 23 | type HYDRATE_EXTENSION_SETTINGS = { 24 | type: ExtensionSettingsActionTypes.HYDRATE_EXTENSION_SETTINGS; 25 | payload: 26 | | { 27 | apikey: string; 28 | domainOptions: DomainOptionsProperties[]; 29 | host: HostProperties; 30 | } 31 | | { 32 | apikey: string; 33 | host: HostProperties; 34 | history: boolean; 35 | advanced: boolean; 36 | }; 37 | }; 38 | 39 | type RELOAD_EXTENSION_SETTINGS = { 40 | type: ExtensionSettingsActionTypes.RELOAD_EXTENSION_SETTINGS; 41 | payload: boolean; 42 | }; 43 | 44 | type Action = HYDRATE_EXTENSION_SETTINGS | RELOAD_EXTENSION_SETTINGS; 45 | 46 | type InitialValues = { 47 | apikey: string; 48 | domainOptions: DomainOptionsProperties[]; 49 | host: HostProperties; 50 | reload: boolean; 51 | history: boolean; 52 | advanced: boolean; 53 | }; 54 | 55 | const initialValues: InitialValues = { 56 | apikey: '', 57 | domainOptions: [], 58 | host: Kutt, 59 | reload: false, 60 | history: false, 61 | advanced: false, 62 | }; 63 | 64 | type State = InitialValues; 65 | type Dispatch = (action: Action) => void; 66 | 67 | const ExtensionSettingsStateContext = createContext( 68 | undefined 69 | ); 70 | const ExtensionSettingsDispatchContext = createContext( 71 | undefined 72 | ); 73 | 74 | function extensionSettingsReducer(state: State, action: Action): State { 75 | switch (action.type) { 76 | case ExtensionSettingsActionTypes.HYDRATE_EXTENSION_SETTINGS: { 77 | return {...state, ...action.payload}; 78 | } 79 | 80 | case ExtensionSettingsActionTypes.RELOAD_EXTENSION_SETTINGS: { 81 | return {...state, reload: action.payload}; 82 | } 83 | 84 | default: 85 | return state; 86 | } 87 | } 88 | 89 | function useExtensionSettingsState(): State { 90 | const context = useContext(ExtensionSettingsStateContext); 91 | 92 | if (context === undefined) { 93 | throw new Error( 94 | 'useExtensionSettingsState must be used within a ExtensionSettingsProvider' 95 | ); 96 | } 97 | 98 | return context; 99 | } 100 | 101 | function useExtensionSettingsDispatch(): Dispatch { 102 | const context = useContext(ExtensionSettingsDispatchContext); 103 | 104 | if (context === undefined) { 105 | throw new Error( 106 | 'useExtensionSettingsDispatch must be used within a ExtensionSettingsProvider' 107 | ); 108 | } 109 | 110 | return context; 111 | } 112 | 113 | function useExtensionSettings(): [State, Dispatch] { 114 | // To access const [state, dispatch] = useExtensionSettings() 115 | return [useExtensionSettingsState(), useExtensionSettingsDispatch()]; 116 | } 117 | 118 | type ExtensionSettingsProviderProps = { 119 | children: React.ReactNode; 120 | }; 121 | 122 | const ExtensionSettingsProvider: React.FC = ({ 123 | children, 124 | }) => { 125 | const [state, dispatch] = useReducer(extensionSettingsReducer, initialValues); 126 | 127 | return ( 128 | <> 129 | 130 | 131 | {children} 132 | 133 | 134 | 135 | ); 136 | }; 137 | 138 | export {ExtensionSettingsProvider, useExtensionSettings}; 139 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kutt-extension", 3 | "version": "4.3.0-beta.2", 4 | "description": "Kutt.it extension for browsers.", 5 | "license": "MIT", 6 | "repository": "https://github.com/thedevs-network/kutt-extension.git", 7 | "author": { 8 | "name": "abhijithvijayan", 9 | "email": "email@abhijithvijayan.in", 10 | "url": "https://abhijithvijayan.in" 11 | }, 12 | "engines": { 13 | "node": ">=10.0.0", 14 | "yarn": ">=1.0.0" 15 | }, 16 | "scripts": { 17 | "dev:chrome": "cross-env NODE_ENV=development cross-env TARGET_BROWSER=chrome webpack --watch", 18 | "dev:firefox": "cross-env NODE_ENV=development cross-env TARGET_BROWSER=firefox webpack --watch", 19 | "dev:opera": "cross-env NODE_ENV=development cross-env TARGET_BROWSER=opera webpack --watch", 20 | "build:chrome": "cross-env NODE_ENV=production cross-env TARGET_BROWSER=chrome webpack", 21 | "build:firefox": "cross-env NODE_ENV=production cross-env TARGET_BROWSER=firefox webpack", 22 | "build:opera": "cross-env NODE_ENV=production cross-env TARGET_BROWSER=opera webpack", 23 | "build": "yarn run build:chrome && yarn run build:firefox && yarn run build:opera", 24 | "lint": "eslint . --ext .ts,.tsx", 25 | "lint:fix": "eslint . --ext .ts,.tsx --fix" 26 | }, 27 | "husky": { 28 | "hooks": { 29 | "pre-commit": "lint-staged" 30 | } 31 | }, 32 | "lint-staged": { 33 | "*.{ts,tsx}": [ 34 | "eslint . --ext .ts,.tsx" 35 | ] 36 | }, 37 | "keywords": [ 38 | "url", 39 | "shortener", 40 | "browser", 41 | "extension", 42 | "addon", 43 | "kutt" 44 | ], 45 | "private": true, 46 | "dependencies": { 47 | "@abhijithvijayan/ts-utils": "^1.2.2", 48 | "@babel/runtime": "^7.14.6", 49 | "advanced-css-reset": "^1.2.2", 50 | "axios": "^0.21.1", 51 | "qrcode.react": "^1.0.1", 52 | "react": "^17.0.2", 53 | "react-copy-to-clipboard": "^5.0.3", 54 | "react-dom": "^17.0.2", 55 | "react-use-form-state": "^0.13.2", 56 | "styled-components": "^5.3.0", 57 | "twin.macro": "^1.12.1", 58 | "webextension-polyfill-ts": "^0.26.0" 59 | }, 60 | "devDependencies": { 61 | "@abhijithvijayan/eslint-config": "2.6.3", 62 | "@abhijithvijayan/eslint-config-airbnb": "^1.0.2", 63 | "@abhijithvijayan/tsconfig": "^1.3.0", 64 | "@babel/core": "^7.14.6", 65 | "@babel/eslint-parser": "^7.14.7", 66 | "@babel/plugin-proposal-class-properties": "^7.14.5", 67 | "@babel/plugin-proposal-object-rest-spread": "^7.14.7", 68 | "@babel/plugin-transform-destructuring": "^7.14.7", 69 | "@babel/plugin-transform-react-jsx": "^7.14.5", 70 | "@babel/plugin-transform-runtime": "^7.14.5", 71 | "@babel/preset-env": "^7.14.7", 72 | "@babel/preset-react": "^7.14.5", 73 | "@babel/preset-typescript": "^7.14.5", 74 | "@types/lodash.isequal": "^4.5.5", 75 | "@types/node": "^14.17.5", 76 | "@types/qrcode.react": "^1.0.2", 77 | "@types/react": "^17.0.14", 78 | "@types/react-copy-to-clipboard": "^5.0.1", 79 | "@types/react-dom": "^17.0.9", 80 | "@types/styled-components": "^5.1.11", 81 | "@types/webpack": "^4.41.30", 82 | "@typescript-eslint/eslint-plugin": "^4.28.3", 83 | "@typescript-eslint/parser": "^4.28.3", 84 | "autoprefixer": "^10.3.1", 85 | "babel-loader": "^8.2.2", 86 | "clean-webpack-plugin": "^3.0.0", 87 | "copy-webpack-plugin": "^6.4.1", 88 | "cross-env": "^7.0.3", 89 | "css-loader": "^5.2.7", 90 | "eslint": "^7.30.0", 91 | "eslint-config-prettier": "^6.15.0", 92 | "eslint-plugin-import": "^2.23.4", 93 | "eslint-plugin-jsx-a11y": "^6.4.1", 94 | "eslint-plugin-node": "^11.1.0", 95 | "eslint-plugin-prettier": "^3.4.0", 96 | "eslint-plugin-react": "^7.24.0", 97 | "eslint-plugin-react-hooks": "^4.2.0", 98 | "filemanager-webpack-plugin": "^3.1.1", 99 | "fork-ts-checker-webpack-plugin": "^6.2.12", 100 | "html-webpack-plugin": "^4.5.2", 101 | "husky": "^6.0.0", 102 | "lint-staged": "^11.0.1", 103 | "mini-css-extract-plugin": "^1.6.2", 104 | "node-sass": "^4.14.1", 105 | "optimize-css-assets-webpack-plugin": "^5.0.8", 106 | "postcss": "^8.3.5", 107 | "postcss-loader": "^4.3.0", 108 | "prettier": "^2.3.2", 109 | "resolve-url-loader": "^3.1.4", 110 | "sass-extract": "^2.1.0", 111 | "sass-extract-js": "^0.4.0", 112 | "sass-extract-loader": "^1.1.0", 113 | "sass-loader": "^10.2.0", 114 | "terser-webpack-plugin": "^4.2.3", 115 | "typescript": "4.3.5", 116 | "webpack": "^4.46.0", 117 | "webpack-cli": "^4.7.2", 118 | "webpack-extension-reloader": "^1.1.4", 119 | "wext-manifest-loader": "^2.3.0", 120 | "wext-manifest-webpack-plugin": "^1.2.1" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /source/History/History.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from 'react'; 2 | import 'twin.macro'; 3 | 4 | import { 5 | useShortenedLinks, 6 | ShortenedLinksActionTypes, 7 | } from '../contexts/shortened-links-context'; 8 | import { 9 | HostProperties, 10 | useExtensionSettings, 11 | } from '../contexts/extension-settings-context'; 12 | import { 13 | useRequestStatus, 14 | RequestStatusActionTypes, 15 | } from '../contexts/request-status-context'; 16 | import messageUtil from '../util/mesageUtil'; 17 | import {FETCH_URLS_HISTORY} from '../Background/constants'; 18 | import {getExtensionSettings} from '../util/settings'; 19 | import { 20 | SuccessfulUrlsHistoryFetchProperties, 21 | AuthRequestBodyProperties, 22 | ApiErroredProperties, 23 | ErrorStateProperties, 24 | Kutt, 25 | } from '../Background'; 26 | import {isValidUrl} from '../util/link'; 27 | 28 | import BodyWrapper from '../components/BodyWrapper'; 29 | import Loader from '../components/Loader'; 30 | import Header from '../Options/Header'; 31 | import Table from './Table'; 32 | 33 | const History: React.FC = () => { 34 | const [, shortenedLinksDispatch] = useShortenedLinks(); 35 | const [, extensionSettingsDispatch] = useExtensionSettings(); 36 | const [requestStatusState, requestStatusDispatch] = useRequestStatus(); 37 | const [errored, setErrored] = useState({ 38 | error: null, 39 | message: '', 40 | }); 41 | 42 | useEffect(() => { 43 | async function getUrlsHistoryStats(): Promise { 44 | // ********************************* // 45 | // **** GET EXTENSIONS SETTINGS **** // 46 | // ********************************* // 47 | const {settings = {}} = await getExtensionSettings(); 48 | const advancedSettings: boolean = 49 | (settings?.advanced as boolean) || false; 50 | 51 | const defaultHost: HostProperties = 52 | (advancedSettings && 53 | (settings?.host as string) && 54 | isValidUrl(settings.host as string) && { 55 | hostDomain: (settings.host as string) 56 | .replace('http://', '') 57 | .replace('https://', '') 58 | .replace('www.', '') 59 | .split(/[/?#]/)[0], // extract domain 60 | hostUrl: (settings.host as string).endsWith('/') 61 | ? (settings.host as string).slice(0, -1) 62 | : (settings.host as string), // slice `/` at the end 63 | }) || 64 | Kutt; 65 | 66 | // inject existing keys (if field doesn't exist, use default) 67 | const defaultExtensionConfig = { 68 | apikey: (settings?.apikey as string)?.trim() || '', 69 | history: (settings?.history as boolean) || false, 70 | advanced: 71 | defaultHost.hostUrl.trim() !== Kutt.hostUrl && advancedSettings, // disable `advanced` if customhost is not set 72 | host: defaultHost, 73 | }; 74 | 75 | // extensionSettingsDispatch({ 76 | // type: ExtensionSettingsActionTypes.HYDRATE_EXTENSION_SETTINGS, 77 | // payload: defaultExtensionConfig, 78 | // }); 79 | 80 | if (defaultExtensionConfig.history) { 81 | // ****************************************************** // 82 | // **************** FETCH URLS HISTORY ****************** // 83 | // ****************************************************** // 84 | const urlsHistoryFetchRequetBody: AuthRequestBodyProperties = { 85 | apikey: defaultExtensionConfig.apikey, 86 | hostUrl: defaultExtensionConfig.host.hostUrl, 87 | }; 88 | 89 | // call api 90 | const response: 91 | | SuccessfulUrlsHistoryFetchProperties 92 | | ApiErroredProperties = await messageUtil.send( 93 | FETCH_URLS_HISTORY, 94 | urlsHistoryFetchRequetBody 95 | ); 96 | 97 | if (!response.error) { 98 | setErrored({error: false, message: 'Fetch successful'}); 99 | 100 | shortenedLinksDispatch({ 101 | type: ShortenedLinksActionTypes.HYDRATE_SHORTENED_LINKS, 102 | payload: { 103 | items: response.data.data, 104 | total: response.data.total, 105 | }, 106 | }); 107 | } else { 108 | setErrored({error: true, message: response.message}); 109 | } 110 | } else { 111 | setErrored({ 112 | error: true, 113 | message: 'History page disabled. Please enable it from settings.', 114 | }); 115 | } 116 | 117 | requestStatusDispatch({ 118 | type: RequestStatusActionTypes.SET_LOADING, 119 | payload: false, 120 | }); 121 | } 122 | 123 | getUrlsHistoryStats(); 124 | }, [ 125 | extensionSettingsDispatch, 126 | requestStatusDispatch, 127 | shortenedLinksDispatch, 128 | ]); 129 | 130 | return ( 131 | 132 |
133 |
134 |
135 | 136 | {/* eslint-disable-next-line no-nested-ternary */} 137 | {!requestStatusState.loading ? ( 138 | !errored.error ? ( 139 | 140 | ) : ( 141 |

{errored.message}

142 | ) 143 | ) : ( 144 |
145 | 146 |
147 | )} 148 | 149 | 150 | 151 | ); 152 | }; 153 | 154 | export default History; 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

kutt-extension

3 |

Browser extension for Kutt.it

4 | 24 |
25 | 26 | ❤️ it? ⭐️ it on [GitHub](https://github.com/thedevs-network/kutt-extension/stargazers) 27 | 28 | ## Features 29 | 30 | - Minimal UI 31 | - Instant QR Code 32 | - Cross Browser Support 33 | - Supports Secure Passwords for URLs 34 | - History & Incognito Feature 35 | - Auto Copy Feature 36 | - Free and Open Source 37 | - Uses WebExtensions API 38 | 39 | ## Browser Support 40 | 41 | | [![Chrome](https://raw.github.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png)](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd) | [![Firefox](https://raw.github.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png)](https://addons.mozilla.org/firefox/addon/kutt/) | [![Opera](https://raw.github.com/alrra/browser-logos/master/src/opera/opera_48x48.png)](CONTRIBUTING.md#for-opera-users) | [![Edge](https://raw.github.com/alrra/browser-logos/master/src/edge/edge_48x48.png)](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd) | [![Yandex](https://raw.github.com/alrra/browser-logos/master/src/yandex/yandex_48x48.png)](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd) | [![Brave](https://raw.github.com/alrra/browser-logos/master/src/brave/brave_48x48.png)](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd) | [![vivaldi](https://raw.github.com/alrra/browser-logos/master/src/vivaldi/vivaldi_48x48.png)](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd) | 42 | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 43 | | 49 & later ✔ | 52 & later ✔ | 36 & later ✔ | 79 & later ✔ | Latest ✔ | Latest ✔ | Latest ✔ 44 | 45 | ## How to use 46 | 47 | - Download for browser(s) 48 | 49 | - Chrome: [Kutt :: Chrome Web Store](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd) 50 | - Firefox: [Kutt :: Add-ons for Firefox](https://addons.mozilla.org/firefox/addon/kutt/) 51 | - Opera [Kutt :: Opera addons](CONTRIBUTING.md#for-opera-users) 52 | - Edge: [Kutt :: Chrome Web Store](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd) 53 | 54 | - Generate an API Key from `https://kutt.it/` after signing up. (Settings page) 55 | 56 | 57 | 58 | - Paste and Save this `Key` in extension's `options page` when asked. 59 | 60 |
61 | 62 | ## Screenshots 63 | 64 |
65 | popup 66 |
_
67 | options 68 |
69 | 70 |
71 | 72 | ## Note 73 | 74 | - Kutt.it API permits **50** URLs shortening per day using the API Key. 75 | - **Enable Custom Host** option to use with self-hosted kutt 76 | - Save the self hosted domain in the input (eg: ) 77 | - **Note**: the api endpoint is automatically appended during the api call. 78 | - _Delay at times while shortening might be the issue with Kutt.it API and not with the extension's._ 79 | 80 | ## Contributing and Support 81 | 82 | View the Contributing guidelines [here](CONTRIBUTING.md). 83 | 84 | Original Repo: [thedevs-network/kutt](https://github.com/thedevs-network/kutt) 85 | 86 | ## Licence 87 | 88 | Code released under the [MIT License](license). 89 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const TerserPlugin = require('terser-webpack-plugin'); 4 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | const {CleanWebpackPlugin} = require('clean-webpack-plugin'); 7 | const ExtensionReloader = require('webpack-extension-reloader'); 8 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 9 | const FilemanagerPlugin = require('filemanager-webpack-plugin'); 10 | const WextManifestWebpackPlugin = require('wext-manifest-webpack-plugin'); 11 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); 12 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 13 | 14 | const viewsPath = path.join(__dirname, 'views'); 15 | const sourcePath = path.join(__dirname, 'source'); 16 | const destPath = path.join(__dirname, 'extension'); 17 | const nodeEnv = process.env.NODE_ENV || 'development'; 18 | const targetBrowser = process.env.TARGET_BROWSER; 19 | 20 | const extensionReloader = 21 | nodeEnv === 'development' 22 | ? new ExtensionReloader({ 23 | port: 9128, 24 | reloadPage: true, 25 | entries: { 26 | // TODO: reload manifest on update 27 | background: 'background', 28 | extensionPage: ['popup', 'options', 'history'], 29 | }, 30 | }) 31 | : () => { 32 | this.apply = () => {}; 33 | }; 34 | 35 | const getExtensionFileType = (browser) => { 36 | if (browser === 'opera') { 37 | return 'crx'; 38 | } 39 | if (browser === 'firefox') { 40 | return 'xpi'; 41 | } 42 | 43 | return 'zip'; 44 | }; 45 | 46 | module.exports = { 47 | devtool: false, 48 | 49 | stats: { 50 | all: false, 51 | builtAt: true, 52 | errors: true, 53 | hash: true, 54 | }, 55 | 56 | mode: nodeEnv, 57 | 58 | entry: { 59 | manifest: path.join(sourcePath, 'manifest.json'), 60 | background: path.join(sourcePath, 'Background', 'index.ts'), 61 | popup: path.join(sourcePath, 'Popup', 'index.tsx'), 62 | options: path.join(sourcePath, 'Options', 'index.tsx'), 63 | history: path.join(sourcePath, 'History', 'index.tsx'), 64 | }, 65 | 66 | output: { 67 | path: path.join(destPath, targetBrowser), 68 | filename: 'js/[name].bundle.js', 69 | }, 70 | 71 | resolve: { 72 | extensions: ['.ts', '.tsx', '.js', '.json'], 73 | alias: { 74 | 'webextension-polyfill-ts': path.resolve( 75 | path.join(__dirname, 'node_modules', 'webextension-polyfill-ts') 76 | ), 77 | }, 78 | }, 79 | 80 | module: { 81 | rules: [ 82 | { 83 | test: /manifest\.json$/, 84 | type: 'javascript/auto', 85 | use: { 86 | loader: 'wext-manifest-loader', 87 | options: { 88 | usePackageJSONVersion: true, 89 | }, 90 | }, 91 | exclude: /node_modules/, 92 | }, 93 | { 94 | test: /\.(ts|js)x?$/, 95 | loader: 'babel-loader', 96 | exclude: /node_modules/, 97 | }, 98 | { 99 | test: /\.(sa|sc|c)ss$/, 100 | use: [ 101 | { 102 | loader: MiniCssExtractPlugin.loader, // It creates a CSS file per JS file which contains CSS 103 | }, 104 | { 105 | loader: 'css-loader', // Takes the CSS files and returns the CSS with imports and url(...) for Webpack 106 | options: { 107 | sourceMap: true, 108 | }, 109 | }, 110 | { 111 | loader: 'postcss-loader', 112 | options: { 113 | postcssOptions: { 114 | plugins: [ 115 | [ 116 | 'autoprefixer', 117 | { 118 | // Options 119 | }, 120 | ], 121 | ], 122 | }, 123 | }, 124 | }, 125 | 'resolve-url-loader', // Rewrites relative paths in url() statements 126 | 'sass-loader', // Takes the Sass/SCSS file and compiles to the CSS 127 | ], 128 | }, 129 | ], 130 | }, 131 | 132 | plugins: [ 133 | new WextManifestWebpackPlugin(), 134 | // Generate sourcemaps 135 | new webpack.SourceMapDevToolPlugin({filename: false}), 136 | new ForkTsCheckerWebpackPlugin(), 137 | // environment variables 138 | new webpack.EnvironmentPlugin(['NODE_ENV', 'TARGET_BROWSER']), 139 | // delete previous build files 140 | new CleanWebpackPlugin({ 141 | cleanOnceBeforeBuildPatterns: [ 142 | path.join(process.cwd(), `extension/${targetBrowser}`), 143 | path.join( 144 | process.cwd(), 145 | `extension/${targetBrowser}.${getExtensionFileType()}` 146 | ), 147 | ], 148 | cleanStaleWebpackAssets: false, 149 | verbose: true, 150 | }), 151 | new HtmlWebpackPlugin({ 152 | template: path.join(viewsPath, 'popup.html'), 153 | inject: 'body', 154 | chunks: ['popup'], 155 | hash: true, 156 | filename: 'popup.html', 157 | }), 158 | new HtmlWebpackPlugin({ 159 | template: path.join(viewsPath, 'options.html'), 160 | inject: 'body', 161 | chunks: ['options'], 162 | hash: true, 163 | filename: 'options.html', 164 | }), 165 | new HtmlWebpackPlugin({ 166 | template: path.join(viewsPath, 'history.html'), 167 | inject: 'body', 168 | chunks: ['history'], 169 | hash: true, 170 | filename: 'history.html', 171 | }), 172 | // write css file(s) to build folder 173 | new MiniCssExtractPlugin({filename: 'css/[name].css'}), 174 | // copy static assets 175 | new CopyWebpackPlugin({ 176 | patterns: [{from: path.join(sourcePath, 'assets'), to: 'assets'}], 177 | }), 178 | // plugin to enable browser reloading in development mode 179 | extensionReloader, 180 | ], 181 | 182 | optimization: { 183 | minimize: true, 184 | minimizer: [ 185 | new TerserPlugin({ 186 | parallel: true, 187 | terserOptions: { 188 | format: { 189 | comments: false, 190 | }, 191 | }, 192 | extractComments: false, 193 | }), 194 | new OptimizeCSSAssetsPlugin({ 195 | cssProcessorPluginOptions: { 196 | preset: ['default', {discardComments: {removeAll: true}}], 197 | }, 198 | }), 199 | new FilemanagerPlugin({ 200 | events: { 201 | onEnd: { 202 | archive: [ 203 | { 204 | format: 'zip', 205 | source: path.join(destPath, targetBrowser), 206 | destination: `${path.join( 207 | destPath, 208 | targetBrowser 209 | )}.${getExtensionFileType(targetBrowser)}`, 210 | options: {zlib: {level: 6}}, 211 | }, 212 | ], 213 | }, 214 | }, 215 | }), 216 | ], 217 | }, 218 | }; 219 | -------------------------------------------------------------------------------- /source/Background/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * kutt-extension 3 | * 4 | * @author abhijithvijayan 5 | * @license MIT License 6 | */ 7 | 8 | /* eslint-disable @typescript-eslint/no-explicit-any */ 9 | import {browser} from 'webextension-polyfill-ts'; 10 | 11 | import axios, {AxiosPromise} from 'axios'; 12 | import * as constants from './constants'; 13 | 14 | export enum Kutt { 15 | hostDomain = 'kutt.it', 16 | hostUrl = 'https://kutt.it', 17 | } 18 | 19 | export enum StoreLinks { 20 | chrome = 'https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd/reviews', 21 | firefox = 'https://addons.mozilla.org/en-US/firefox/addon/kutt/reviews/', 22 | } 23 | 24 | // **** ------------------ **** // 25 | 26 | export type ErrorStateProperties = { 27 | error: boolean | null; 28 | message: string; 29 | }; 30 | 31 | export type ApiErroredProperties = { 32 | error: true; 33 | message: string; 34 | }; 35 | 36 | export type AuthRequestBodyProperties = { 37 | apikey: string; 38 | hostUrl: HostUrlProperties; 39 | }; 40 | 41 | // **** ------------------ **** // 42 | 43 | type HostUrlProperties = string; 44 | 45 | export type DomainEntryProperties = { 46 | address: string; 47 | banned: boolean; 48 | created_at: string; 49 | id: string; 50 | homepage: string; 51 | updated_at: string; 52 | }; 53 | 54 | type ShortenUrlBodyProperties = { 55 | target: string; 56 | password?: string; 57 | customurl?: string; 58 | reuse: boolean; 59 | domain?: string; 60 | }; 61 | 62 | type ShortenLinkResponseProperties = { 63 | id: string; 64 | address: string; 65 | banned: boolean; 66 | password: boolean; 67 | target: string; 68 | visit_count: number; 69 | created_at: string; 70 | updated_at: string; 71 | link: string; 72 | }; 73 | 74 | export interface ApiBodyProperties extends ShortenUrlBodyProperties { 75 | apikey: string; 76 | } 77 | 78 | export type ShortUrlActionBodyProperties = { 79 | apiBody: ApiBodyProperties; 80 | hostUrl: HostUrlProperties; 81 | }; 82 | 83 | export type SuccessfulShortenStatusProperties = { 84 | error: false; 85 | data: ShortenLinkResponseProperties; 86 | }; 87 | 88 | /** 89 | * Shorten URL using v2 API 90 | */ 91 | async function shortenUrl({ 92 | apiBody, 93 | hostUrl, 94 | }: ShortUrlActionBodyProperties): Promise< 95 | SuccessfulShortenStatusProperties | ApiErroredProperties 96 | > { 97 | try { 98 | const {apikey, ...otherParams} = apiBody; 99 | 100 | const {data}: {data: ShortenLinkResponseProperties} = await axios({ 101 | method: 'POST', 102 | timeout: constants.SHORTEN_URL_TIMEOUT, 103 | url: `${hostUrl}/api/v2/links`, 104 | headers: { 105 | 'X-API-Key': apikey, 106 | }, 107 | data: { 108 | ...otherParams, 109 | }, 110 | }); 111 | 112 | return { 113 | error: false, 114 | data, 115 | }; 116 | } catch (err) { 117 | if (err.response) { 118 | if (err.response.status === 401) { 119 | return { 120 | error: true, 121 | message: 'Error: Invalid API Key', 122 | }; 123 | } 124 | 125 | // server request validation errors 126 | if ( 127 | err.response.status === 400 && 128 | Object.prototype.hasOwnProperty.call(err.response.data, 'error') 129 | ) { 130 | return { 131 | error: true, 132 | message: `Error: ${err.response.data.error}`, 133 | }; 134 | } 135 | 136 | // ToDo: remove in the next major update 137 | if (err.response.status === 404) { 138 | return { 139 | error: true, 140 | message: 141 | 'Error: This extension now uses API v2, please update your kutt.it instance.', 142 | }; 143 | } 144 | } 145 | 146 | if (err.code === 'ECONNABORTED') { 147 | return { 148 | error: true, 149 | message: 'Error: Timed out', 150 | }; 151 | } 152 | 153 | return { 154 | error: true, 155 | message: 'Error: Something went wrong', 156 | }; 157 | } 158 | } 159 | 160 | // **** ------------------ **** // 161 | 162 | export type UserSettingsResponseProperties = { 163 | apikey: string; 164 | email: string; 165 | domains: DomainEntryProperties[]; 166 | }; 167 | 168 | export type SuccessfulApiKeyCheckProperties = { 169 | error: false; 170 | data: UserSettingsResponseProperties; 171 | }; 172 | 173 | function getUserSettings({ 174 | apikey, 175 | hostUrl, 176 | }: AuthRequestBodyProperties): AxiosPromise { 177 | return axios({ 178 | method: 'GET', 179 | url: `${hostUrl}/api/v2/users`, 180 | timeout: constants.CHECK_API_KEY_TIMEOUT, 181 | headers: { 182 | 'X-API-Key': apikey, 183 | }, 184 | }); 185 | } 186 | 187 | async function checkApiKey({ 188 | apikey, 189 | hostUrl, 190 | }: AuthRequestBodyProperties): Promise< 191 | SuccessfulApiKeyCheckProperties | ApiErroredProperties 192 | > { 193 | try { 194 | const {data}: {data: UserSettingsResponseProperties} = 195 | await getUserSettings({ 196 | apikey, 197 | hostUrl, 198 | }); 199 | 200 | return { 201 | error: false, 202 | data, 203 | }; 204 | } catch (err) { 205 | if (err.response) { 206 | if (err.response.status === 401) { 207 | return { 208 | error: true, 209 | message: 'Error: Invalid API Key', 210 | }; 211 | } 212 | 213 | return { 214 | error: true, 215 | message: 'Error: Something went wrong.', 216 | }; 217 | } 218 | 219 | if (err.code === 'ECONNABORTED') { 220 | return { 221 | error: true, 222 | message: 'Error: Timed out', 223 | }; 224 | } 225 | 226 | return { 227 | error: true, 228 | message: 'Error: Requesting to server failed.', 229 | }; 230 | } 231 | } 232 | 233 | // **** ------------------ **** // 234 | 235 | export type SuccessfulUrlsHistoryFetchProperties = { 236 | error: false; 237 | data: UserShortenedLinksHistoryResponseBody; 238 | }; 239 | 240 | type UserShortenedLinksHistoryResponseBody = { 241 | limit: number; 242 | skip: number; 243 | total: number; 244 | data: UserShortenedLinkStats[]; 245 | }; 246 | 247 | export type UserShortenedLinkStats = { 248 | address: string; 249 | banned: boolean; 250 | created_at: string; 251 | id: string; 252 | link: string; 253 | password: boolean; 254 | target: string; 255 | updated_at: string; 256 | visit_count: number; 257 | }; 258 | 259 | /** 260 | * Fetch User's recent 15 shortened urls 261 | */ 262 | 263 | async function fetchUrlsHistory({ 264 | apikey, 265 | hostUrl, 266 | }: AuthRequestBodyProperties): Promise< 267 | SuccessfulUrlsHistoryFetchProperties | ApiErroredProperties 268 | > { 269 | try { 270 | const {data}: {data: UserShortenedLinksHistoryResponseBody} = await axios({ 271 | method: 'GET', 272 | timeout: constants.SHORTEN_URL_TIMEOUT, 273 | url: `${hostUrl}/api/v2/links`, 274 | params: { 275 | limit: constants.MAX_HISTORY_ITEMS, 276 | }, 277 | headers: { 278 | 'X-API-Key': apikey, 279 | }, 280 | }); 281 | 282 | return { 283 | error: false, 284 | data, 285 | }; 286 | } catch (err) { 287 | if (err.response) { 288 | if (err.response.status === 401) { 289 | return { 290 | error: true, 291 | message: 'Error: Invalid API Key', 292 | }; 293 | } 294 | 295 | return { 296 | error: true, 297 | message: 'Error: Something went wrong.', 298 | }; 299 | } 300 | 301 | if (err.code === 'ECONNABORTED') { 302 | return { 303 | error: true, 304 | message: 'Error: Timed out', 305 | }; 306 | } 307 | 308 | return { 309 | error: true, 310 | message: 'Error: Requesting to server failed.', 311 | }; 312 | } 313 | } 314 | 315 | // **** ------------------ **** // 316 | 317 | /** 318 | * Listen for messages from UI pages 319 | */ 320 | browser.runtime.onMessage.addListener( 321 | (request, _sender): void | Promise => { 322 | // eslint-disable-next-line consistent-return 323 | // eslint-disable-next-line default-case 324 | switch (request.action) { 325 | case constants.CHECK_API_KEY: { 326 | return checkApiKey(request.params); 327 | } 328 | 329 | case constants.SHORTEN_URL: { 330 | return shortenUrl(request.params); 331 | } 332 | 333 | case constants.FETCH_URLS_HISTORY: { 334 | return fetchUrlsHistory(request.params); 335 | } 336 | } 337 | } 338 | ); 339 | -------------------------------------------------------------------------------- /source/Popup/Popup.tsx: -------------------------------------------------------------------------------- 1 | import {isNull, EMPTY_STRING} from '@abhijithvijayan/ts-utils'; 2 | import React, {useEffect} from 'react'; 3 | import tw, {css} from 'twin.macro'; 4 | 5 | import {Kutt, UserSettingsResponseProperties} from '../Background'; 6 | import {openExtOptionsPage} from '../util/tabs'; 7 | import {isValidUrl} from '../util/link'; 8 | 9 | import { 10 | ExtensionSettingsActionTypes, 11 | DomainOptionsProperties, 12 | useExtensionSettings, 13 | HostProperties, 14 | } from '../contexts/extension-settings-context'; 15 | import { 16 | RequestStatusActionTypes, 17 | useRequestStatus, 18 | } from '../contexts/request-status-context'; 19 | import { 20 | getExtensionSettings, 21 | getPreviousSettings, 22 | migrateSettings, 23 | } from '../util/settings'; 24 | 25 | import BodyWrapper from '../components/BodyWrapper'; 26 | import ResponseBody from './ResponseBody'; 27 | import PopupHeader from './Header'; 28 | import Loader from '../components/Loader'; 29 | import Form, {CONSTANTS} from './Form'; 30 | 31 | const Popup: React.FC = () => { 32 | const [extensionSettingsState, extensionSettingsDispatch] = 33 | useExtensionSettings(); 34 | const [requestStatusState, requestStatusDispatch] = useRequestStatus(); 35 | const {reload: liveReloadFlag} = extensionSettingsState; 36 | 37 | // re-renders on `liveReloadFlag` change 38 | useEffect((): void => { 39 | async function getUserSettings(): Promise { 40 | // -----------------------------------------------------------------------------// 41 | // -----------------------------------------------------------------------------// 42 | // ----- // ToDo: remove in next major release // ----- // 43 | // ----- Ref: https://github.com/thedevs-network/kutt-extension/issues/78 ----- // 44 | // -----------------------------------------------------------------------------// 45 | // -----------------------------------------------------------------------------// 46 | 47 | const { 48 | // old keys from extension v3.x.x 49 | key = EMPTY_STRING, 50 | host = EMPTY_STRING, 51 | userOptions = { 52 | autoCopy: false, 53 | devMode: false, 54 | keepHistory: false, 55 | pwdForUrls: false, 56 | }, 57 | } = await getPreviousSettings(); 58 | 59 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 60 | const migrationSettings: any = {}; 61 | let performMigration = false; 62 | 63 | if ((key as string).trim().length > 0) { 64 | // map it to `settings.apikey` 65 | migrationSettings.apikey = key; 66 | performMigration = true; 67 | } 68 | if ( 69 | (host as string).trim().length > 0 && 70 | (userOptions.devMode as boolean) 71 | ) { 72 | // map `host` to `settings.host` 73 | migrationSettings.host = host; 74 | // set `advanced` to true 75 | migrationSettings.advanced = true; 76 | performMigration = true; 77 | } 78 | if (userOptions.keepHistory as boolean) { 79 | // set `settings.history` to true 80 | migrationSettings.history = true; 81 | performMigration = true; 82 | } 83 | if (performMigration) { 84 | // perform migration 85 | await migrateSettings(migrationSettings); 86 | } 87 | 88 | // -----------------------------------------------------------------------------// 89 | // -----------------------------------------------------------------------------// 90 | // -----------------------------------------------------------------------------// 91 | // -----------------------------------------------------------------------------// 92 | // -----------------------------------------------------------------------------// 93 | // -----------------------------------------------------------------------------// 94 | 95 | // ToDo: set types: refer https://kutt.it/jITyIU 96 | const {settings = {}} = await getExtensionSettings(); 97 | 98 | // No API Key set 99 | if ( 100 | !Object.prototype.hasOwnProperty.call(settings, 'apikey') || 101 | (settings.apikey as string) === EMPTY_STRING 102 | ) { 103 | requestStatusDispatch({ 104 | type: RequestStatusActionTypes.SET_REQUEST_STATUS, 105 | payload: { 106 | error: true, 107 | message: 'Extension requires an API Key to work', 108 | }, 109 | }); 110 | requestStatusDispatch({ 111 | type: RequestStatusActionTypes.SET_LOADING, 112 | payload: false, 113 | }); 114 | 115 | // Open options page 116 | setTimeout(() => { 117 | return openExtOptionsPage(); 118 | }, 1300); 119 | 120 | return; 121 | } 122 | 123 | let defaultHost: HostProperties = Kutt; 124 | 125 | // If `advanced` field is true 126 | if ( 127 | Object.prototype.hasOwnProperty.call(settings, 'advanced') && 128 | (settings.advanced as boolean) 129 | ) { 130 | // If `host` field is set 131 | if ( 132 | Object.prototype.hasOwnProperty.call(settings, 'host') && 133 | (settings.host as string)?.trim().length > 0 && 134 | isValidUrl(settings.host as string) 135 | ) { 136 | defaultHost = { 137 | hostDomain: (settings.host as string) 138 | .replace('http://', EMPTY_STRING) 139 | .replace('https://', EMPTY_STRING) 140 | .replace('www.', EMPTY_STRING) 141 | .split(/[/?#]/)[0], // extract domain 142 | hostUrl: (settings.host as string).endsWith('/') 143 | ? (settings.host as string).slice(0, -1) 144 | : (settings.host as string), // slice `/` at the end 145 | }; 146 | } 147 | } 148 | 149 | let historyEnabled = false; 150 | // `history` field set 151 | if ( 152 | Object.prototype.hasOwnProperty.call(settings, 'history') && 153 | (settings.history as boolean) 154 | ) { 155 | historyEnabled = settings.history as boolean; 156 | } 157 | 158 | // options menu 159 | const defaultOptions: DomainOptionsProperties[] = [ 160 | { 161 | id: EMPTY_STRING, 162 | option: '-- Choose Domain --', 163 | value: EMPTY_STRING, 164 | disabled: true, 165 | }, 166 | { 167 | id: CONSTANTS.DefaultDomainId, 168 | option: defaultHost.hostDomain, 169 | value: defaultHost.hostUrl, 170 | disabled: false, 171 | }, 172 | ]; 173 | 174 | // `user` & `apikey` fields exist on storage 175 | if ( 176 | Object.prototype.hasOwnProperty.call(settings, 'user') && 177 | (settings.user as UserSettingsResponseProperties) 178 | ) { 179 | const {user}: {user: UserSettingsResponseProperties} = settings; 180 | 181 | let optionsList: DomainOptionsProperties[] = user.domains.map( 182 | ({id, address, homepage, banned}) => { 183 | return { 184 | id, 185 | option: homepage, 186 | value: address, 187 | disabled: banned, 188 | }; 189 | } 190 | ); 191 | 192 | // merge to beginning of array 193 | optionsList = defaultOptions.concat(optionsList); 194 | 195 | // update domain list 196 | extensionSettingsDispatch({ 197 | type: ExtensionSettingsActionTypes.HYDRATE_EXTENSION_SETTINGS, 198 | payload: { 199 | apikey: (settings.apikey as string)?.trim(), 200 | domainOptions: optionsList, 201 | host: defaultHost, 202 | history: historyEnabled, 203 | }, 204 | }); 205 | } else { 206 | // no `user` but `apikey` exist on storage 207 | extensionSettingsDispatch({ 208 | type: ExtensionSettingsActionTypes.HYDRATE_EXTENSION_SETTINGS, 209 | payload: { 210 | apikey: (settings.apikey as string)?.trim(), 211 | domainOptions: defaultOptions, 212 | host: defaultHost, 213 | history: historyEnabled, 214 | }, 215 | }); 216 | } 217 | 218 | // stop loader 219 | requestStatusDispatch({ 220 | type: RequestStatusActionTypes.SET_LOADING, 221 | payload: false, 222 | }); 223 | } 224 | 225 | getUserSettings(); 226 | }, [liveReloadFlag, extensionSettingsDispatch, requestStatusDispatch]); 227 | 228 | return ( 229 | 230 | 250 | 251 | ); 252 | }; 253 | 254 | export default Popup; 255 | -------------------------------------------------------------------------------- /source/History/Table.tsx: -------------------------------------------------------------------------------- 1 | import CopyToClipboard from 'react-copy-to-clipboard'; 2 | import React, {useEffect, useState} from 'react'; 3 | import tw, {css, styled} from 'twin.macro'; 4 | 5 | import { 6 | useShortenedLinks, 7 | ShortenedLinksActionTypes, 8 | } from '../contexts/shortened-links-context'; 9 | import {MAX_HISTORY_ITEMS} from '../Background/constants'; 10 | 11 | import Icon from '../components/Icon'; 12 | import Modal from './Modal'; 13 | 14 | const StyledTd = styled.td` 15 | ${tw`relative flex items-center px-0 py-4`} 16 | `; 17 | 18 | const StyledIcon = styled(Icon)` 19 | ${tw`flex items-center justify-center p-0 my-0 transition-all duration-200 ease-out border-none outline-none cursor-pointer`} 20 | 21 | margin-right: 2px; 22 | margin-left: 12px; 23 | width: 26px; 24 | height: 26px; 25 | box-shadow: rgba(100, 100, 100, 0.1) 0px 2px 4px; 26 | background-color: rgb(222, 222, 222); 27 | border-radius: 100%; 28 | 29 | &:hover { 30 | transform: translateY(-3px); 31 | } 32 | 33 | svg { 34 | stroke: rgb(101, 189, 137); 35 | stroke-width: 2; 36 | } 37 | `; 38 | 39 | const Table: React.FC = () => { 40 | const [shortenedLinksState, shortenedLinksDispatch] = useShortenedLinks(); 41 | const [QRView, setQRView] = useState(false); 42 | const [copied, setCopied] = useState(false); 43 | 44 | // reset copy message 45 | useEffect(() => { 46 | let timer: NodeJS.Timeout | null = null; 47 | 48 | timer = setTimeout(() => { 49 | setCopied(false); 50 | // reset selected id from context 51 | }, 1300); 52 | 53 | return (): void => { 54 | if (timer) { 55 | clearTimeout(timer); 56 | } 57 | }; 58 | }, [copied]); 59 | 60 | function handleCopyToClipboard(selectedItemId: string): void { 61 | shortenedLinksDispatch({ 62 | type: ShortenedLinksActionTypes.SET_CURRENT_SELECTED, 63 | payload: selectedItemId, 64 | }); 65 | 66 | setCopied(true); 67 | } 68 | 69 | function handleQRCodeViewToggle(selectedItemId: string): void { 70 | shortenedLinksDispatch({ 71 | type: ShortenedLinksActionTypes.SET_CURRENT_SELECTED, 72 | payload: selectedItemId, 73 | }); 74 | 75 | setQRView(true); 76 | } 77 | 78 | return ( 79 | <> 80 |
89 |
99 |
100 |

101 | Recent shortened links. (last {MAX_HISTORY_ITEMS} results) 102 |

103 |
104 |
114 | 125 | 134 | 145 | 156 | 157 | 158 | 159 | {!(shortenedLinksState.total === 0) ? ( 160 | shortenedLinksState.items.map((item) => { 161 | return ( 162 | 172 | 196 | 208 | {item.target} 209 | 210 | 211 | 212 | 236 | {copied && 237 | shortenedLinksState.selected?.id === item.id && ( 238 |
247 | Copied to clipboard! 248 |
249 | )} 250 | 251 | 267 |
268 | 269 | 270 |
271 | {/* // **** COPY TO CLIPBOARD **** // */} 272 | 273 | {copied && 274 | shortenedLinksState.selected?.id === item.id ? ( 275 | 276 | ) : ( 277 | { 280 | return handleCopyToClipboard(item.id); 281 | }} 282 | > 283 | 284 | 285 | )} 286 | 287 | 289 | handleQRCodeViewToggle(item.id) 290 | } 291 | className="icon" 292 | name="qrcode" 293 | /> 294 |
295 | 296 | {/* // **** QR CODE MODAL **** // */} 297 | {QRView && 298 | shortenedLinksState.selected?.id === item.id && ( 299 | 300 | )} 301 |
302 | 303 | ); 304 | }) 305 | ) : ( 306 | 307 | 308 | 309 | )} 310 | 311 |
143 | Original URL 144 | 154 | Short URL 155 |
No URLs History
312 |
313 |
314 | 315 | ); 316 | }; 317 | 318 | export default Table; 319 | -------------------------------------------------------------------------------- /source/Popup/Form.tsx: -------------------------------------------------------------------------------- 1 | import {useFormState} from 'react-use-form-state'; 2 | import tw, {css, styled} from 'twin.macro'; 3 | import React, {useState} from 'react'; 4 | import { 5 | EMPTY_STRING, 6 | isUndefined, 7 | isEmpty, 8 | isNull, 9 | get, 10 | } from '@abhijithvijayan/ts-utils'; 11 | 12 | import {useExtensionSettings} from '../contexts/extension-settings-context'; 13 | import {SHORTEN_URL} from '../Background/constants'; 14 | import messageUtil from '../util/mesageUtil'; 15 | import {getCurrentTab} from '../util/tabs'; 16 | import { 17 | RequestStatusActionTypes, 18 | useRequestStatus, 19 | } from '../contexts/request-status-context'; 20 | import {isValidUrl} from '../util/link'; 21 | import { 22 | SuccessfulShortenStatusProperties, 23 | ShortUrlActionBodyProperties, 24 | ApiErroredProperties, 25 | ApiBodyProperties, 26 | } from '../Background'; 27 | 28 | import Icon from '../components/Icon'; 29 | 30 | export enum CONSTANTS { 31 | DefaultDomainId = 'default', 32 | } 33 | 34 | const StyledValidateButton = styled.button` 35 | ${tw`focus:outline-none hover:text-gray-200 inline-flex items-center justify-center w-full px-3 py-1 mb-1 text-xs font-semibold text-center text-white duration-300 ease-in-out rounded shadow-lg`} 36 | 37 | background: linear-gradient(to right,rgb(126, 87, 194),rgb(98, 0, 234)); 38 | min-height: 36px; 39 | 40 | .create__icon { 41 | ${tw`inline-flex px-0 bg-transparent`} 42 | 43 | svg { 44 | ${tw`transition-transform duration-300 ease-in-out`} 45 | 46 | stroke: currentColor; 47 | stroke-width: 2; 48 | } 49 | } 50 | `; 51 | 52 | const Form: React.FC = () => { 53 | const extensionSettingsState = useExtensionSettings()[0]; 54 | const requestStatusDispatch = useRequestStatus()[1]; 55 | const [showPassword, setShowPassword] = useState(false); 56 | const [isSubmitting, setIsSubmitting] = useState(false); 57 | const { 58 | domainOptions, 59 | host: {hostDomain}, 60 | } = extensionSettingsState; 61 | 62 | const [ 63 | formState, 64 | { 65 | text: textProps, 66 | password: passwordProps, 67 | select: selectProps, 68 | label: labelProps, 69 | }, 70 | ] = useFormState<{ 71 | domain: string; 72 | customurl: string; 73 | password: string; 74 | }>( 75 | { 76 | domain: 77 | domainOptions 78 | .find(({id}) => { 79 | return id === CONSTANTS.DefaultDomainId; 80 | }) 81 | ?.value?.trim() || EMPTY_STRING, // empty string will map to disabled entry 82 | }, 83 | { 84 | withIds: true, // enable automatic creation of id and htmlFor props 85 | } 86 | ); 87 | const { 88 | errors: formStateErrors, 89 | validity: formStateValidity, 90 | setField: setFormStateField, 91 | setFieldError: setFormStateFieldError, 92 | } = formState; 93 | 94 | const isFormValid: boolean = 95 | ((isUndefined(formStateValidity.customurl) || 96 | formStateValidity.customurl) && 97 | (isUndefined(formStateValidity.password) || formStateValidity.password) && 98 | isUndefined(formStateErrors.customurl) && 99 | isUndefined(formStateErrors.password)) || 100 | false; 101 | 102 | async function handleFormSubmit({ 103 | customurl, 104 | password, 105 | domain, 106 | }: { 107 | domain: string; 108 | customurl: string; 109 | password: string; 110 | }): Promise { 111 | // enable loading screen 112 | setIsSubmitting(true); 113 | 114 | // Get target link to shorten 115 | const tabs = await getCurrentTab(); 116 | const target: string | null = get(tabs, '[0].url', null); 117 | const shouldSubmit: boolean = !isNull(target) && isValidUrl(target); 118 | 119 | if (!shouldSubmit) { 120 | setIsSubmitting(false); 121 | 122 | requestStatusDispatch({ 123 | type: RequestStatusActionTypes.SET_REQUEST_STATUS, 124 | payload: { 125 | error: true, 126 | message: 'Not a valid URL', 127 | }, 128 | }); 129 | 130 | return; 131 | } 132 | 133 | const apiBody: ApiBodyProperties = { 134 | apikey: extensionSettingsState.apikey, 135 | target: target as unknown as string, 136 | ...(customurl.trim() !== EMPTY_STRING && {customurl: customurl.trim()}), // add key only if field is not empty 137 | ...(!isEmpty(password) && {password}), 138 | reuse: false, 139 | ...(domain.trim() !== EMPTY_STRING && {domain: domain.trim()}), 140 | }; 141 | 142 | const apiShortenUrlBody: ShortUrlActionBodyProperties = { 143 | apiBody, 144 | hostUrl: extensionSettingsState.host.hostUrl, 145 | }; 146 | // shorten url in the background 147 | const response: SuccessfulShortenStatusProperties | ApiErroredProperties = 148 | await messageUtil.send(SHORTEN_URL, apiShortenUrlBody); 149 | 150 | // disable spinner 151 | setIsSubmitting(false); 152 | 153 | if (!response.error) { 154 | const { 155 | data: {link}, 156 | } = response; 157 | // show shortened url 158 | requestStatusDispatch({ 159 | type: RequestStatusActionTypes.SET_REQUEST_STATUS, 160 | payload: { 161 | error: false, 162 | message: link, 163 | }, 164 | }); 165 | } else { 166 | // errored 167 | requestStatusDispatch({ 168 | type: RequestStatusActionTypes.SET_REQUEST_STATUS, 169 | payload: { 170 | error: true, 171 | message: response.message, 172 | }, 173 | }); 174 | } 175 | } 176 | 177 | function handleCustomUrlInputChange(url: string): void { 178 | setFormStateField('customurl', url); 179 | // ToDo: Remove special symbols 180 | 181 | if (url.length > 0 && url.length < 3) { 182 | setFormStateFieldError( 183 | 'customurl', 184 | 'Custom URL must be at-least 3 characters' 185 | ); 186 | } 187 | } 188 | 189 | function handlePasswordInputChange(password: string): void { 190 | setFormStateField('password', password); 191 | // ToDo: Remove special symbols 192 | 193 | if (password.length > 0 && password.length < 3) { 194 | setFormStateFieldError( 195 | 'password', 196 | 'Password must be at-least 3 characters' 197 | ); 198 | } 199 | } 200 | 201 | return ( 202 | <> 203 |
204 |
205 | 211 | 212 |
213 | 233 |
234 |
235 | 236 |
237 | 243 | 244 | ): void => { 249 | // NOTE: overriding onChange to show errors 250 | handleCustomUrlInputChange(value.trim()); 251 | }} 252 | disabled={isSubmitting} 253 | spellCheck="false" 254 | css={[ 255 | tw`focus:outline-none sm:text-base focus:border-indigo-400 w-full px-2 py-2 text-sm placeholder-gray-400 bg-gray-200 border rounded`, 256 | 257 | css` 258 | margin-top: 1.2rem; 259 | `, 260 | 261 | !isUndefined(formStateValidity.customurl) && 262 | !formStateValidity.customurl && 263 | tw`border-red-500`, 264 | ]} 265 | /> 266 | 267 | 268 | {formStateErrors.customurl} 269 | 270 |
271 | 272 |
273 | 279 | 280 |
281 |
290 | { 292 | if (!isSubmitting) { 293 | setShowPassword(!showPassword); 294 | } 295 | }} 296 | name={!showPassword ? 'eye-closed' : 'eye'} 297 | css={[ 298 | tw`z-10 flex items-center justify-center w-full h-full rounded-tl rounded-bl cursor-pointer`, 299 | 300 | css` 301 | color: rgb(187, 187, 187); 302 | `, 303 | ]} 304 | /> 305 |
306 | 307 | ): void => { 314 | // NOTE: overriding onChange to show errors 315 | handlePasswordInputChange(value); 316 | }} 317 | disabled={isSubmitting} 318 | css={[ 319 | tw`focus:outline-none sm:text-base focus:border-indigo-400 w-full px-2 py-2 text-sm placeholder-gray-400 bg-gray-200 border rounded`, 320 | 321 | css` 322 | margin-top: 1.2rem; 323 | `, 324 | 325 | !isUndefined(formStateValidity.password) && 326 | !formStateValidity.password && 327 | tw`border-red-500`, 328 | ]} 329 | /> 330 |
331 | 332 | 333 | {formStateErrors.password} 334 | 335 |
336 | 337 | { 341 | handleFormSubmit(formState.values); 342 | }} 343 | > 344 | {!isSubmitting ? ( 345 | Create 346 | ) : ( 347 | 348 | )} 349 | 350 |
351 | 352 | ); 353 | }; 354 | export default Form; 355 | -------------------------------------------------------------------------------- /source/Options/Form.tsx: -------------------------------------------------------------------------------- 1 | import {isNull, isUndefined} from '@abhijithvijayan/ts-utils'; 2 | import {useFormState} from 'react-use-form-state'; 3 | import React, {useState, useEffect} from 'react'; 4 | import tw, {styled} from 'twin.macro'; 5 | 6 | import {useExtensionSettings} from '../contexts/extension-settings-context'; 7 | import {updateExtensionSettings} from '../util/settings'; 8 | import {CHECK_API_KEY} from '../Background/constants'; 9 | import messageUtil from '../util/mesageUtil'; 10 | import {isValidUrl} from '../util/link'; 11 | import { 12 | SuccessfulApiKeyCheckProperties, 13 | AuthRequestBodyProperties, 14 | ApiErroredProperties, 15 | ErrorStateProperties, 16 | Kutt, 17 | } from '../Background'; 18 | 19 | import Icon from '../components/Icon'; 20 | 21 | type OptionsFormValuesProperties = { 22 | apikey: string; 23 | history: boolean; 24 | advanced: boolean; 25 | host: string; 26 | }; 27 | 28 | const StyledValidateButton = styled.button` 29 | ${tw`focus:outline-none hover:text-gray-200 inline-flex items-center justify-center px-3 py-2 mt-3 mb-1 text-xs font-semibold text-center text-white duration-300 ease-in-out rounded shadow-lg`} 30 | 31 | background: linear-gradient(to right,rgb(126, 87, 194),rgb(98, 0, 234)); 32 | 33 | .validate__icon { 34 | ${tw`inline-flex px-0 bg-transparent`} 35 | 36 | svg { 37 | ${tw`transition-transform duration-300 ease-in-out`} 38 | 39 | stroke: currentColor; 40 | stroke-width: 2; 41 | } 42 | } 43 | `; 44 | 45 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 46 | const onSave = (values: OptionsFormValuesProperties): Promise => { 47 | // should always return a Promise 48 | return updateExtensionSettings(values); // update local settings 49 | }; 50 | 51 | const Form: React.FC = () => { 52 | const extensionSettingsState = useExtensionSettings()[0]; 53 | const [submitting, setSubmitting] = useState(false); 54 | const [showApiKey, setShowApiKey] = useState(false); 55 | const [errored, setErrored] = useState({ 56 | error: null, 57 | message: '', 58 | }); 59 | const [ 60 | formState, 61 | { 62 | text: textProps, 63 | checkbox: checkboxProps, 64 | password: passwordProps, 65 | label: labelProps, 66 | }, 67 | ] = useFormState<{ 68 | apikey: string; 69 | history: boolean; 70 | advanced: boolean; 71 | host: string; 72 | }>( 73 | { 74 | apikey: extensionSettingsState.apikey, 75 | history: extensionSettingsState.history, 76 | advanced: extensionSettingsState.advanced, 77 | host: 78 | (extensionSettingsState.advanced && 79 | extensionSettingsState.host.hostUrl) || 80 | '', 81 | }, 82 | { 83 | withIds: true, // enable automatic creation of id and htmlFor props 84 | } 85 | ); 86 | 87 | const { 88 | errors: formStateErrors, 89 | values: formStateValues, 90 | validity: formStateValidity, 91 | setField: setFormStateField, 92 | setFieldError: setFormStateFieldError, 93 | } = formState; 94 | 95 | const isFormValid: boolean = 96 | ((isUndefined(formStateValidity.apikey) || formStateValidity.apikey) && 97 | formStateValues.apikey.trim().length === 40 && // invalidate if api key is empty 98 | isUndefined(formStateErrors.apikey) && 99 | (((isUndefined(formStateValidity.host) || formStateValidity.host) && 100 | isUndefined(formStateErrors.host)) || 101 | // Check if `host` field exhibits validation errors, if `host` field is error but `advanced` field is set to false => form is valid(hence the or condition) 102 | !formStateValues.advanced)) || 103 | false; 104 | 105 | // on component mount -> save `settings` object 106 | useEffect(() => { 107 | onSave({ 108 | ...formStateValues, 109 | ...(formStateValues.advanced === false && {host: ''}), 110 | }); 111 | }, [formStateValues]); 112 | 113 | function handleApiKeyInputChange(apikey: string): void { 114 | setFormStateField('apikey', apikey); 115 | // ToDo: Remove special symbols 116 | 117 | if (!(apikey.trim().length > 0)) { 118 | setFormStateFieldError('apikey', 'API key missing'); 119 | } else if (apikey && apikey.trim().length < 40) { 120 | setFormStateFieldError('apikey', 'API key must be 40 characters'); 121 | } else if (apikey && apikey.trim().length > 40) { 122 | setFormStateFieldError('apikey', 'API key cannot exceed 40 characters'); 123 | } 124 | } 125 | 126 | function handleHostUrlInputChange(host: string): void { 127 | if (!formStateValues.advanced) { 128 | setFormStateFieldError('host', 'Enable Advanced Options first'); 129 | 130 | return; 131 | } 132 | 133 | setFormStateField('host', host); 134 | 135 | if (!(host.trim().length > 0)) { 136 | setFormStateFieldError('host', 'Custom URL cannot be empty'); 137 | 138 | return; 139 | } 140 | 141 | if (!isValidUrl(host.trim()) || host.trim().length < 10) { 142 | setFormStateFieldError('host', 'Please enter a valid url'); 143 | } 144 | } 145 | 146 | async function handleApiKeyVerification(): Promise { 147 | setSubmitting(true); 148 | // request API validation request 149 | const apiKeyValidationBody: AuthRequestBodyProperties = { 150 | apikey: formStateValues.apikey.trim(), 151 | hostUrl: 152 | (formStateValues.advanced && 153 | formStateValues.host.trim().length > 0 && 154 | formStateValues.host.trim()) || 155 | Kutt.hostUrl, 156 | }; 157 | 158 | // API call 159 | const response: SuccessfulApiKeyCheckProperties | ApiErroredProperties = 160 | await messageUtil.send(CHECK_API_KEY, apiKeyValidationBody); 161 | 162 | if (!response.error) { 163 | // set top-level status 164 | setErrored({error: false, message: 'Valid API Key'}); 165 | 166 | // Store user account information 167 | const {domains, email} = response.data; 168 | await updateExtensionSettings({user: {domains, email}}); 169 | } else { 170 | // ---- errored ---- // 171 | setErrored({error: true, message: response.message}); 172 | 173 | // Delete `user` field from settings 174 | await updateExtensionSettings({user: null}); 175 | } 176 | 177 | // enable validate button 178 | setSubmitting(false); 179 | 180 | setTimeout(() => { 181 | // Reset status 182 | setErrored({error: null, message: ''}); 183 | }, 1000); 184 | } 185 | 186 | return ( 187 | <> 188 |
189 |
190 | 206 | 207 |
208 |
209 | setShowApiKey(!showApiKey)} 212 | name={!showApiKey ? 'eye-closed' : 'eye'} 213 | /> 214 |
215 | 216 | ): void => { 222 | // NOTE: overriding onChange to show errors 223 | handleApiKeyInputChange(value.trim()); 224 | }} 225 | spellCheck="false" 226 | css={[ 227 | tw`sm:text-base focus:border-indigo-400 focus:outline-none relative w-full py-2 pl-2 pr-12 text-sm placeholder-gray-400 bg-gray-200 border rounded`, 228 | 229 | !isUndefined(formStateValidity.apikey) && 230 | !formStateValidity.apikey && 231 | tw`border-red-500`, 232 | ]} 233 | /> 234 |
235 | 236 | 237 | {formStateErrors.apikey} 238 | 239 |
240 |
241 | 242 |
243 | 248 | Validate 249 | 250 | 260 | 261 |
262 | 263 |
264 | 288 | 289 | 313 | 314 |
315 |
316 | 319 | 320 |
321 | ): void => { 326 | // NOTE: overriding onChange to show errors 327 | handleHostUrlInputChange(value.trim()); 328 | }} 329 | spellCheck="false" 330 | css={[ 331 | tw`sm:text-base focus:border-indigo-400 focus:outline-none relative w-full py-2 pl-2 pr-12 text-sm placeholder-gray-400 bg-gray-200 border rounded`, 332 | 333 | !isUndefined(formStateValidity.host) && 334 | !formStateValidity.host && 335 | tw`border-red-500`, 336 | ]} 337 | /> 338 |
339 | 340 | 341 | {formStateErrors.host} 342 | 343 |
344 |
345 |
346 | 347 | ); 348 | }; 349 | 350 | export default Form; 351 | --------------------------------------------------------------------------------