Compare commits
440 Commits
0.9.0-alph
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3cd8c77e4 | ||
|
|
3ba64a4892 | ||
|
|
90056d24a9 | ||
|
|
5dcb69b622 | ||
|
|
04283ad8ab | ||
|
|
ac596dfef6 | ||
|
|
33c97b7ff8 | ||
|
|
b9d6acdc6e | ||
|
|
ce0b8bb4b9 | ||
|
|
3c34baed9c | ||
|
|
6049e17105 | ||
|
|
a13d6b56ed | ||
|
|
d95c134ec4 | ||
|
|
a01405b883 | ||
|
|
6f450eb79f | ||
|
|
56e08d1558 | ||
|
|
e4e9b8c744 | ||
|
|
640196b587 | ||
|
|
153bdca706 | ||
|
|
8404361c6a | ||
|
|
0ee49cabe6 | ||
|
|
cd0f6833f5 | ||
|
|
d0673e2c0a | ||
|
|
0af9d929d5 | ||
|
|
85586530a8 | ||
|
|
41e893b337 | ||
|
|
444ca0a97c | ||
|
|
6d01680a4b | ||
|
|
02e83642e4 | ||
|
|
aa9532c261 | ||
|
|
c121fc84d8 | ||
|
|
cfcc9edcb4 | ||
|
|
64d6c61bce | ||
|
|
e0fe70ae24 | ||
|
|
02cf89c308 | ||
|
|
c8e6dc3a35 | ||
|
|
4c0b31224a | ||
|
|
24c21f6cb8 | ||
|
|
4b15fde168 | ||
|
|
a5bdd3a242 | ||
|
|
2a5f54573e | ||
|
|
30dfe3b80e | ||
|
|
175eab6464 | ||
|
|
7e25514176 | ||
|
|
e6857a68e4 | ||
|
|
5e2df9fd26 | ||
|
|
a962d6b2b3 | ||
|
|
021db2b5fc | ||
|
|
8334886931 | ||
|
|
c7fc510bb8 | ||
|
|
4dd0ac786c | ||
|
|
24471a5a8f | ||
|
|
ed0191f021 | ||
|
|
16e65b882e | ||
|
|
6e689f274c | ||
|
|
cab383a081 | ||
|
|
e9c73947ed | ||
|
|
efb9eaca69 | ||
|
|
b2d9c2c91b | ||
|
|
bd9aafba6c | ||
|
|
96192eaa1b | ||
|
|
7e29f373c9 | ||
|
|
65b9c285cc | ||
|
|
991349a8b6 | ||
|
|
a7262f6508 | ||
|
|
a3b1e4710b | ||
|
|
12b4875016 | ||
|
|
d00a43b07d | ||
|
|
7da940150f | ||
|
|
a8b3b53947 | ||
|
|
f94479e9a9 | ||
|
|
74b7efbb58 | ||
|
|
6fbee72ae6 | ||
|
|
d01b1a7302 | ||
|
|
e6e256ea86 | ||
|
|
6ed973232f | ||
|
|
d556037e9a | ||
|
|
dbe07cfd26 | ||
|
|
88a0aeba21 | ||
|
|
fd69442db6 | ||
|
|
1d00fbf40f | ||
|
|
3850fc98ae | ||
|
|
20f92b9ffd | ||
|
|
407f331dfc | ||
|
|
c11130b9cf | ||
|
|
b39518d55e | ||
|
|
499b474040 | ||
|
|
e2d4bf3389 | ||
|
|
84f4af61f0 | ||
|
|
8857890f1b | ||
|
|
89edc3ec8c | ||
|
|
a4801caed6 | ||
|
|
f014c4e8a9 | ||
|
|
262327d71c | ||
|
|
dc6259e2e0 | ||
|
|
f80b92b3b9 | ||
|
|
1c9cee5e51 | ||
|
|
8eb174b2c2 | ||
|
|
c8899aabce | ||
|
|
316ac1236b | ||
|
|
24b0abca77 | ||
|
|
362b5ee457 | ||
|
|
1fe218e969 | ||
|
|
9960668161 | ||
|
|
be6b078ba5 | ||
|
|
a28cfdcdb7 | ||
|
|
a40bbb561d | ||
|
|
9f446e6832 | ||
|
|
7657a48961 | ||
|
|
c935ad2521 | ||
|
|
684d0543fd | ||
|
|
529f22c18f | ||
|
|
0910b058fb | ||
|
|
4a89b4a589 | ||
|
|
582081bf12 | ||
|
|
1d808e3003 | ||
|
|
1f49d929d0 | ||
|
|
f24004afb5 | ||
|
|
36f59728be | ||
|
|
e6a5b9d0ee | ||
|
|
e6ef7f1360 | ||
|
|
a95e0f2fb3 | ||
|
|
74a11258e0 | ||
|
|
5f162bc968 | ||
|
|
ca7387f065 | ||
|
|
82f24de613 | ||
|
|
5d80fd3e69 | ||
|
|
ec02b12b27 | ||
|
|
af0bad2716 | ||
|
|
18ebdad1bc | ||
|
|
2f2d656f14 | ||
|
|
85d7f21440 | ||
|
|
7c14f2980b | ||
|
|
a0e45392ed | ||
|
|
93655f5638 | ||
|
|
68d4536a56 | ||
|
|
9db6f04a67 | ||
|
|
1885f7d188 | ||
|
|
958b3321dc | ||
|
|
b5493ae04b | ||
|
|
2180cbabcb | ||
|
|
9f42e8e60c | ||
|
|
f8242e10c5 | ||
|
|
3b6c6c1f72 | ||
|
|
09b3a0b5ec | ||
|
|
0150068e07 | ||
|
|
c9eb7339bf | ||
|
|
eb7738eb13 | ||
|
|
edf646a702 | ||
|
|
6ca3c42800 | ||
|
|
63636bff39 | ||
|
|
d5e73f895c | ||
|
|
386c304b9d | ||
|
|
9d5057fa34 | ||
|
|
467aec7864 | ||
|
|
470ab06c75 | ||
|
|
d8532d2e7a | ||
|
|
49d69c8d16 | ||
|
|
5940ac9bcb | ||
|
|
42f7e3a13a | ||
|
|
5322e83f90 | ||
|
|
8d8ea6a2b4 | ||
|
|
a50acda1bb | ||
|
|
9f7e56872c | ||
|
|
223c5a85ac | ||
|
|
f7fe709091 | ||
|
|
9aa02f9a9d | ||
|
|
e35a081c10 | ||
|
|
30541c551b | ||
|
|
6fb03409fc | ||
|
|
6f9af25676 | ||
|
|
5ba8026698 | ||
|
|
9cca9e5101 | ||
|
|
6950fad830 | ||
|
|
e187b8c75b | ||
|
|
20bc8ba30a | ||
|
|
744dbc1819 | ||
|
|
6c0837fe6c | ||
|
|
100a3efa53 | ||
|
|
9f2dc342ef | ||
|
|
963485951d | ||
|
|
d5b734665b | ||
|
|
2f47d84d44 | ||
|
|
ca7787327a | ||
|
|
4e6213eb07 | ||
|
|
fd4eb5271f | ||
|
|
a2b9bc10eb | ||
|
|
1077348ee5 | ||
|
|
218d63337f | ||
|
|
f3ce80c20e | ||
|
|
612ed6c4a0 | ||
|
|
1a3c2ab3b9 | ||
|
|
ae372d8089 | ||
|
|
aac2b681aa | ||
|
|
8a6d5ff056 | ||
|
|
9f9ab82f08 | ||
|
|
a03ac4995c | ||
|
|
c68f6c441e | ||
|
|
94d61a6ca7 | ||
|
|
b2c4134610 | ||
|
|
5559504d21 | ||
|
|
20f1b0ff94 | ||
|
|
49172708ec | ||
|
|
5eeec4047c | ||
|
|
8b16025f9e | ||
|
|
4527884406 | ||
|
|
8eacee8f31 | ||
|
|
deaf0fa0cb | ||
|
|
b35628731c | ||
|
|
dddda17ba8 | ||
|
|
5074caab21 | ||
|
|
c3b6cf6450 | ||
|
|
b685899e7b | ||
|
|
36e5dc213c | ||
|
|
049f33576d | ||
|
|
1d60fc671e | ||
|
|
3336d5187c | ||
|
|
fb40dd5591 | ||
|
|
494286bb15 | ||
|
|
3baf48fc6a | ||
|
|
be7a291301 | ||
|
|
33aec2d574 | ||
|
|
1173a80423 | ||
|
|
f4a40bf9b7 | ||
|
|
b07a1f0226 | ||
|
|
11c9915045 | ||
|
|
d4d5db467e | ||
|
|
052356a82e | ||
|
|
903a20d6af | ||
|
|
3dd2239daa | ||
|
|
8c1991b930 | ||
|
|
fa58dcbaaa | ||
|
|
0ee5d08f01 | ||
|
|
d4d21adc44 | ||
|
|
39c89b2028 | ||
|
|
9cf969ff50 | ||
|
|
4d00612d0e | ||
|
|
be7d498123 | ||
|
|
24e206bb2b | ||
|
|
aa7b199969 | ||
|
|
66ad7e5dc2 | ||
|
|
2c801b9cde | ||
|
|
4e7bcbf13c | ||
|
|
80f4469beb | ||
|
|
92cea5b5f4 | ||
|
|
d7db49dbc5 | ||
|
|
2888a7f262 | ||
|
|
942a1cb62c | ||
|
|
5ae1af75e6 | ||
|
|
fd43dc7a5f | ||
|
|
426141223f | ||
|
|
6ebc47d79c | ||
|
|
ce16e897f6 | ||
|
|
844d60663d | ||
|
|
42f139d640 | ||
|
|
cb2fb2784b | ||
|
|
e5b5042fd5 | ||
|
|
3f8c1e1048 | ||
|
|
87b1522258 | ||
|
|
6406095b6c | ||
|
|
1d2bd4c973 | ||
|
|
d9666aaf86 | ||
|
|
4bfc6d5eab | ||
|
|
4e31d16813 | ||
|
|
bf3c249f12 | ||
|
|
9c61300f66 | ||
|
|
4d4fbfcdf7 | ||
|
|
5d25d31e82 | ||
|
|
695bf7e00f | ||
|
|
f61f1ae0d9 | ||
|
|
187d6ce00f | ||
|
|
dec5a92a03 | ||
|
|
f8cde482f4 | ||
|
|
0c924341cb | ||
|
|
c32ac0d7c5 | ||
|
|
a33a5b0891 | ||
|
|
45767fd574 | ||
|
|
885336dd1c | ||
|
|
4b0ba22542 | ||
|
|
f8056a1046 | ||
|
|
db186ceeb1 | ||
|
|
e14e36279b | ||
|
|
8893f10132 | ||
|
|
f8763091bc | ||
|
|
1321777b01 | ||
|
|
9ec54c1e17 | ||
|
|
01bad479d7 | ||
|
|
b96782e388 | ||
|
|
3cbbbfbc50 | ||
|
|
d0c485120a | ||
|
|
cbd7f25ea4 | ||
|
|
819cae5c48 | ||
|
|
b0b375efec | ||
|
|
fb81e110fa | ||
|
|
6a6fd6c3f4 | ||
|
|
5d5cb5f61f | ||
|
|
bcb1ce322e | ||
|
|
466f896218 | ||
|
|
8f290b5c90 | ||
|
|
64f2314f65 | ||
|
|
607032e748 | ||
|
|
d4632552e6 | ||
|
|
b6ea953092 | ||
|
|
4f6f2027c0 | ||
|
|
ff7faac743 | ||
|
|
927984bcdc | ||
|
|
b64f2bfbc9 | ||
|
|
c02e053f51 | ||
|
|
aeee3b3a38 | ||
|
|
31b2a63176 | ||
|
|
709b41b314 | ||
|
|
8bf257a01c | ||
|
|
f63454be47 | ||
|
|
50d530128f | ||
|
|
ca05e49d9b | ||
|
|
0ef0a44b2e | ||
|
|
07af3d0f12 | ||
|
|
f301127035 | ||
|
|
69514a9ac1 | ||
|
|
d55380ba55 | ||
|
|
e4a11beab5 | ||
|
|
8c362de82f | ||
|
|
c77284ae02 | ||
|
|
e144cabc16 | ||
|
|
3de169ec37 | ||
|
|
fe195af30b | ||
|
|
ca954d567a | ||
|
|
101988e1d8 | ||
|
|
449df5adb2 | ||
|
|
764662f044 | ||
|
|
b3c3d17ee4 | ||
|
|
dee760871d | ||
|
|
799cedf73e | ||
|
|
af5b08bee8 | ||
|
|
0177c6beb0 | ||
|
|
6ec3ef0e96 | ||
|
|
4f2f1a9c67 | ||
|
|
6c05d076c9 | ||
|
|
b9b8550fe5 | ||
|
|
e804970a4d | ||
|
|
5172947ab4 | ||
|
|
6f3f6f06bb | ||
|
|
82fce1b475 | ||
|
|
08e225946d | ||
|
|
b0f327ddc2 | ||
|
|
a2428bfba1 | ||
|
|
ccb1931845 | ||
|
|
8be5251091 | ||
|
|
40f82f5dd8 | ||
|
|
2d365b0226 | ||
|
|
35f9be8f8a | ||
|
|
e88366f8f1 | ||
|
|
48a57c7afc | ||
|
|
55a1867e7c | ||
|
|
41bc2e9d74 | ||
|
|
4199208690 | ||
|
|
948e20ae81 | ||
|
|
0d52672dda | ||
|
|
61ab3bf3cc | ||
|
|
f18ff1aed6 | ||
|
|
72b4c85508 | ||
|
|
d35d797afe | ||
|
|
8613a5c679 | ||
|
|
ec4dd32618 | ||
|
|
e6c341336c | ||
|
|
794ad78927 | ||
|
|
449caae4c8 | ||
|
|
683e269e6b | ||
|
|
381407fa08 | ||
|
|
09899a55fc | ||
|
|
8262ec791a | ||
|
|
5e247c57ee | ||
|
|
1b44a0ffc2 | ||
|
|
2d9e2f108d | ||
|
|
dc6600d912 | ||
|
|
7863e81325 | ||
|
|
14c4c020d6 | ||
|
|
8ee221e165 | ||
|
|
3d945f8d54 | ||
|
|
00a08f7122 | ||
|
|
8716d89c7c | ||
|
|
55c4080ed2 | ||
|
|
c5d2c2e652 | ||
|
|
c7050c4021 | ||
|
|
f796a1f6ef | ||
|
|
30f68b21f5 | ||
|
|
c0980fb3fc | ||
|
|
a5f7c153ad | ||
|
|
acd2b4aaa5 | ||
|
|
e86283ebb9 | ||
|
|
32a327cbf9 | ||
|
|
b866df2462 | ||
|
|
522d58956b | ||
|
|
7494967b3e | ||
|
|
d2a0474aff | ||
|
|
56ded3e018 | ||
|
|
1b8686db11 | ||
|
|
2f5ad60647 | ||
|
|
171b260b28 | ||
|
|
3cf349649e | ||
|
|
718568be36 | ||
|
|
70bf186823 | ||
|
|
644ff25af1 | ||
|
|
019dd853d5 | ||
|
|
1daa75851c | ||
|
|
f6bbf9ee29 | ||
|
|
020608daf4 | ||
|
|
6bb9fb6830 | ||
|
|
18cbfa8097 | ||
|
|
80bb7c9ada | ||
|
|
1008ef0dca | ||
|
|
91e265a41a | ||
|
|
7c1114525a | ||
|
|
4b061aed62 | ||
|
|
acf02b0eec | ||
|
|
9ddbe7a0dc | ||
|
|
ec0f1d0577 | ||
|
|
a568a908cd | ||
|
|
7ec562b49f | ||
|
|
ec2f0518ab | ||
|
|
3e851262c5 | ||
|
|
a189c6cfb3 | ||
|
|
7a6d41114b | ||
|
|
5dcae5dc92 | ||
|
|
150c18b5f3 | ||
|
|
76f78e77fd | ||
|
|
1806e90e8a | ||
|
|
780b2ca674 | ||
|
|
64454c7e05 | ||
|
|
8c4a16d71d | ||
|
|
06819b8c64 | ||
|
|
643cdc49b7 | ||
|
|
d07339470f | ||
|
|
fd6de88570 | ||
|
|
ca07fe7d6d | ||
|
|
ac5e3d4b89 | ||
|
|
a4932364a0 | ||
|
|
39cd8ae64c | ||
|
|
0f99aaeff6 | ||
|
|
3a1378c7bc |
22
.babelrc
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"targets": {
|
||||
"chrome": "49",
|
||||
"firefox": "52",
|
||||
"opera": "36"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": [
|
||||
[
|
||||
"@babel/plugin-transform-runtime",
|
||||
{
|
||||
"regenerator": true
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
30
.eslintrc.js
@@ -1,30 +0,0 @@
|
||||
module.exports = {
|
||||
"env": {
|
||||
"browser": true,
|
||||
"commonjs": true,
|
||||
"es6": true
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2018,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"indent": [
|
||||
"error",
|
||||
4
|
||||
],
|
||||
"linebreak-style": [
|
||||
"error",
|
||||
"windows"
|
||||
],
|
||||
"quotes": [
|
||||
"error",
|
||||
"single"
|
||||
],
|
||||
"semi": [
|
||||
"error",
|
||||
"always"
|
||||
]
|
||||
}
|
||||
};
|
||||
12
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [abhijithvijayan]
|
||||
patreon: abhijithvijayan
|
||||
open_collective: abhijithvijayan
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: abhijithvijayan
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: ['https://www.buymeacoffee.com/abhijithvijayan', 'https://www.paypal.me/iamabhijithvijayan']
|
||||
BIN
.github/assets/options-v4-1.png
vendored
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
.github/assets/popup-v4-1.png
vendored
Normal file
|
After Width: | Height: | Size: 28 KiB |
82
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
name: Build and Deploy
|
||||
run-name: ${{ github.actor }} started build action
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
# Allows to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.ref }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Build for all browsers
|
||||
run: npm run build
|
||||
|
||||
- name: Upload Chrome extension artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: chrome-extension
|
||||
path: extension/chrome.zip
|
||||
|
||||
- name: Upload Firefox extension artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: firefox-extension
|
||||
path: extension/firefox.xpi
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
# Only deploy when a tag is pushed (e.g., v1.0.0)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Build for all browsers
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy to extension branch
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./extension
|
||||
publish_branch: extension
|
||||
keep_files: false
|
||||
105
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
# ignore all haters
|
||||
haters/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
@@ -62,4 +65,104 @@ typings/
|
||||
.DS_Store
|
||||
|
||||
## scripts build
|
||||
extension/
|
||||
extension/
|
||||
dist/
|
||||
|
||||
# awesome-ts-loader cache
|
||||
.awcache
|
||||
|
||||
# yarn 2
|
||||
# https://github.com/yarnpkg/berry/issues/454#issuecomment-530312089
|
||||
.yarn/*
|
||||
!.yarn/releases
|
||||
!.yarn/plugins
|
||||
.pnp.*
|
||||
|
||||
### WebStorm+all ###
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
### WebStorm+all Patch ###
|
||||
# Ignores the whole .idea folder and all .iml files
|
||||
# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
|
||||
|
||||
.idea/
|
||||
|
||||
# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
|
||||
|
||||
*.iml
|
||||
modules.xml
|
||||
.idea/misc.xml
|
||||
*.ipr
|
||||
|
||||
# Sonarlint plugin
|
||||
.idea/sonarlint
|
||||
|
||||
19
.travis.yml
@@ -1,19 +0,0 @@
|
||||
language: node_js
|
||||
cache:
|
||||
directories:
|
||||
- ~/.npm
|
||||
node_js:
|
||||
- stable
|
||||
git:
|
||||
depth: 3
|
||||
script:
|
||||
- npm run build
|
||||
deploy:
|
||||
provider: pages
|
||||
skip-cleanup: true
|
||||
keep-history: true
|
||||
github-token: $GITHUB_TOKEN
|
||||
local-dir: extension
|
||||
target-branch: extension
|
||||
on:
|
||||
branch: master
|
||||
@@ -1,40 +1,38 @@
|
||||
## Contributing Guidelines
|
||||
|
||||
## Assets
|
||||
|
||||
- [kutt.it API](https://github.com/thedevs-network/kutt#api) is used to retreive shortened URLs.
|
||||
- [node-kutt](https://github.com/ardalanamini/node-kutt) is used for API calls
|
||||
|
||||
## Development
|
||||
|
||||
- `npm install` to install dependencies.
|
||||
- To watch file changes in developement
|
||||
- Chrome
|
||||
- `npm run dev-chrome`
|
||||
- Firefox
|
||||
- `npm run dev-firefox`
|
||||
- Opera
|
||||
- `npm run dev-opera`
|
||||
|
||||
(Reload Extension Manually in the browser)
|
||||
- Chrome
|
||||
- `npm run dev:chrome`
|
||||
- Firefox
|
||||
- `npm run dev:firefox`
|
||||
|
||||
(Reload Extension Manually in the browser)
|
||||
|
||||
- Load extension in browser
|
||||
- ### Chrome
|
||||
- Go to the browser address bar and type `chrome://extensions`
|
||||
- Check the `Developer Mode` button to enable it.
|
||||
- Click on the `Load Unpacked Extension…` button.
|
||||
- Select your extension’s extracted directory.
|
||||
|
||||
<img width="400" src="https://i.imgur.com/dJRL7By.png" />
|
||||
- ### Chrome
|
||||
|
||||
- ### Firefox
|
||||
- Load the Add-on via `about:debugging` as temporary Add-on.
|
||||
- Choose the `manifest.json` file in the extracted directory
|
||||
- Go to the browser address bar and type `chrome://extensions`
|
||||
- Check the `Developer Mode` button to enable it.
|
||||
- Click on the `Load Unpacked Extension…` button.
|
||||
- Select your extension’s extracted directory.
|
||||
|
||||
<img width="400" src="https://i.imgur.com/aAL5dQg.png" />
|
||||
<img width="400" src="https://i.imgur.com/dJRL7By.png" />
|
||||
|
||||
- ### Opera
|
||||
- Load the extension via `opera:extensions`
|
||||
- Check the `Developer Mode` and load as unpacked from extension’s extracted directory.
|
||||
- ### Firefox
|
||||
|
||||
<img width="400" src="https://i.imgur.com/qUwfSNJ.png" />
|
||||
- Load the Add-on via `about:debugging` as temporary Add-on.
|
||||
- Choose the `manifest.json` file in the extracted directory
|
||||
|
||||
<img width="400" src="https://i.imgur.com/aAL5dQg.png" />
|
||||
|
||||
- Generate an API Key from <a href="https://kutt.it">`https://kutt.it/`</a> (Settings page)
|
||||
- Paste and Save the `Key` in extension's `options page`.
|
||||
@@ -42,31 +40,17 @@
|
||||
`npm run build` builds the extension for all the browsers to `extension/(browser)` directory respectively.
|
||||
|
||||
## Testing
|
||||
|
||||
Download latest `Release`
|
||||
|
||||
[<img src=".github/assets/direct-download.png"
|
||||
alt="Direct download"
|
||||
height="50">](https://github.com/abhijithvijayan/kutt-extension/releases)
|
||||
height="50">](https://github.com/thedevs-network/kutt-extension/releases)
|
||||
|
||||
## ToDo
|
||||
<hr />
|
||||
|
||||
- [x] Switch to Promise return Method
|
||||
- [x] Fix UI issues in Firefox
|
||||
- [x] Using Node-Kutt package(feature request)
|
||||
- [ ] History Feature
|
||||
- [ ] Context Menu
|
||||
- [ ] Auto Copy Clipboard
|
||||
- [ ] Toggleable Options
|
||||
## Self-hosted Kutt
|
||||
|
||||
|
||||
## Note:
|
||||
Shortening might take a while, it's not the issue with the extension but with <a href="https://github.com/thedevs-network/kutt">Kutt.it's API</a>.
|
||||
|
||||
### For Opera Users
|
||||
In order to install this extension from Chrome Web Store, another opera extension called **Install Chrome Extension** should be installed first.
|
||||
|
||||
- [Opera addon :: Install Chrome Extension](https://addons.opera.com/en/extensions/details/install-chrome-extensions/)
|
||||
- [Opera addon :: Kutt](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd)
|
||||
|
||||

|
||||
|
||||
- **Enable Developer Options** to use with self-hosted kutt
|
||||
- Save the self hosted domain in the input (eg: https://mykutt.it)
|
||||
- **Note**: the api endpoint is automatically appended during the api call.
|
||||
|
||||
138
README.md
@@ -1,78 +1,128 @@
|
||||
<div align="center"><img width="150" src="src/assets/logo.png" /></div>
|
||||
<div align="center"><img width="150" src="source/public/assets/logo.png" /></div>
|
||||
<h1 align="center">kutt-extension</h1>
|
||||
<p align="center">Browser extension to to shorten URLs for <a href="https://kutt.it">Kutt.it</a></p>
|
||||
|
||||
<p align="center">Browser extension for <a href="https://kutt.it">Kutt.it</a> URL shortener</p>
|
||||
<div align="center">
|
||||
<a href="https://travis-ci.org/abhijithvijayan/kutt-extension">
|
||||
<img src="https://travis-ci.org/abhijithvijayan/kutt-extension.svg?branch=master" alt="Travis Build" />
|
||||
<a href="https://github.com/thedevs-network/kutt-extension/actions/workflows/build.yml">
|
||||
<img src="https://github.com/thedevs-network/kutt-extension/actions/workflows/build.yml/badge.svg?branch=master" alt="Build" />
|
||||
</a>
|
||||
<a href="https://github.com/abhijithvijayan/kutt-extension/releases/latest">
|
||||
<img src="https://img.shields.io/github/release/abhijithvijayan/kutt-extension.svg?colorB=blue" alt="Releases" />
|
||||
<a href="https://github.com/thedevs-network/kutt-extension/releases/latest">
|
||||
<img src="https://img.shields.io/github/release/thedevs-network/kutt-extension.svg?colorB=blue" alt="Releases" />
|
||||
</a>
|
||||
<a href="https://github.com/abhijithvijayan/kutt-extension/issues?q=is%3Aopen+is%3Aissue">
|
||||
<img src="https://img.shields.io/github/issues-raw/abhijithvijayan/kutt-extension.svg?colorB=lightgrey" alt="Open Issues" />
|
||||
<a href="https://github.com/thedevs-network/kutt-extension/issues?q=is%3Aopen+is%3Aissue">
|
||||
<img src="https://img.shields.io/github/issues-raw/thedevs-network/kutt-extension.svg?colorB=lightgrey" alt="Open Issues" />
|
||||
</a>
|
||||
<a href="https://github.com/abhijithvijayan/kutt-extension/issues?q=is%3Aissue+is%3Aclosed">
|
||||
<img src="https://img.shields.io/github/issues-closed-raw/abhijithvijayan/kutt-extension.svg?colorB=red" alt="Closed Issues" />
|
||||
<a href="https://github.com/thedevs-network/kutt-extension/issues?q=is%3Aissue+is%3Aclosed">
|
||||
<img src="https://img.shields.io/github/issues-closed-raw/thedevs-network/kutt-extension.svg?colorB=red" alt="Closed Issues" />
|
||||
</a>
|
||||
<a href="https://david-dm.org/abhijithvijayan/kutt-extension">
|
||||
<img src="https://img.shields.io/david/abhijithvijayan/kutt-extension.svg?colorB=orange" alt="DEPENDENCIES" />
|
||||
</a>
|
||||
<a href="https://github.com/abhijithvijayan/kutt-extension/blob/master/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/abhijithvijayan/kutt-extension.svg" alt="LICENSE" />
|
||||
<a href="https://github.com/thedevs-network/kutt-extension/blob/master/license">
|
||||
<img src="https://img.shields.io/github/license/thedevs-network/kutt-extension.svg" alt="LICENSE" />
|
||||
</a>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
❤️ it? ⭐️ it on [GitHub](https://github.com/thedevs-network/kutt-extension/stargazers)
|
||||
|
||||
## Features
|
||||
- Cross Browser Support
|
||||
|
||||
- Minimal UI
|
||||
- Instant QR Code
|
||||
- Supports Password for URLs
|
||||
- Cross Browser Support
|
||||
- Supports Secure Passwords for URLs
|
||||
- History & Incognito Feature
|
||||
- Auto Copy Feature
|
||||
- Free and Open Source
|
||||
- WebExtensions API
|
||||
- Uses WebExtensions API
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Bundler**: [Vite](https://vitejs.dev/) 6
|
||||
- **UI**: [React](https://react.dev/) 19
|
||||
- **Language**: [TypeScript](https://www.typescriptlang.org/) 5.7
|
||||
- **Styling**: SCSS with CSS Modules
|
||||
- **Linting**: ESLint 9 (flat config) + Prettier
|
||||
|
||||
## Browser Support
|
||||
|
||||
[](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd) | [](https://addons.mozilla.org/firefox/addon/kutt/) | [](CONTRIBUTING.md#for-opera-users) | [](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd) | [](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd) | [](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd) |
|
||||
--- | --- | --- | --- | --- | --- |
|
||||
49 & later ✔ | 52 & later ✔ | 36 & later ✔ | Latest ✔ | Latest ✔ | Latest ✔ |
|
||||
This extension uses **Manifest V3**.
|
||||
|
||||
<!--  | -->
|
||||
| [](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd) | [](https://addons.mozilla.org/firefox/addon/kutt/) | [](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd) | [](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd) | [](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd) |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 88+ | 109+ | 74+ | 88+ | 1.21+ |
|
||||
|
||||
## How to use
|
||||
## Installation
|
||||
|
||||
- Download for browser(s)
|
||||
- Chrome: [Kutt :: Chrome Web Store](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd)
|
||||
- Firefox: [Kutt :: Add-ons for Firefox](https://addons.mozilla.org/firefox/addon/kutt/)
|
||||
- Opera [Kutt :: Opera addons](CONTRIBUTING.md#for-opera-users)
|
||||
- **Chrome**: [Kutt :: Chrome Web Store](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd)
|
||||
- **Firefox**: [Kutt :: Add-ons for Firefox](https://addons.mozilla.org/firefox/addon/kutt/)
|
||||
- **Edge**: [Kutt :: Chrome Web Store](https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd)
|
||||
|
||||
- Generate an API Key from <a href="https://kutt.it">`https://kutt.it/`</a> after signing up. (Settings page)
|
||||
## How to Use
|
||||
|
||||
<img width="400" src="https://i.imgur.com/qQwqeH5.png" />
|
||||
1. Generate an API Key from <a href="https://kutt.it">`https://kutt.it/`</a> after signing up (Settings page)
|
||||
|
||||
- Paste and Save this `Key` in extension's `options page` when asked.
|
||||
<img width="400" src="https://i.imgur.com/qQwqeH5.png" />
|
||||
|
||||
<img width="250" src="https://i.imgur.com/fJasvmv.png" alt="image2" />
|
||||
|
||||
<hr />
|
||||
2. Paste and Save this `Key` in extension's `options page` when asked
|
||||
|
||||
## Screenshots
|
||||
|
||||
<div align="center">
|
||||
<img width="350" src="https://i.imgur.com/n44Eytz.gif" alt="image1" />
|
||||
<img width="350" src="https://i.imgur.com/FJJ2FPU.gif" alt="image2" />
|
||||
<div>
|
||||
<img width="250" src="./.github/assets/popup-v4-1.png" alt="popup" />
|
||||
<div>_</div>
|
||||
<img width="330" src="./.github/assets/options-v4-1.png" alt="options" />
|
||||
</div>
|
||||
|
||||
## Note:
|
||||
- <a href="https://kutt.it">Kutt.it</a> API permits **50** URLs shortening per day using the API Key.
|
||||
- Delay in the shortening might be the issue with Kutt.it API and not with the extension's.
|
||||
## Development
|
||||
|
||||
Ensure you have [Node.js](https://nodejs.org) 20 or later installed.
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev:chrome # For Chrome
|
||||
npm run dev:firefox # For Firefox
|
||||
|
||||
# Build for production
|
||||
npm run build:chrome # Build Chrome extension
|
||||
npm run build:firefox # Build Firefox addon
|
||||
npm run build # Build for all browsers
|
||||
|
||||
# Linting
|
||||
npm run lint # Run ESLint
|
||||
npm run lint:fix # Run ESLint with auto-fix
|
||||
```
|
||||
|
||||
### Loading the Extension
|
||||
|
||||
#### Chrome
|
||||
|
||||
1. Navigate to `chrome://extensions`
|
||||
2. Enable "Developer mode"
|
||||
3. Click "Load unpacked"
|
||||
4. Select `extension/chrome` directory
|
||||
|
||||
#### Firefox
|
||||
|
||||
1. Navigate to `about:debugging`
|
||||
2. Click "This Firefox"
|
||||
3. Click "Load Temporary Add-on"
|
||||
4. Select `extension/firefox/manifest.json`
|
||||
|
||||
## Note
|
||||
|
||||
- <a href="https://kutt.it">Kutt.it</a> API permits **50** URLs shortening per day using the API Key
|
||||
- **Enable Custom Host** option to use with self-hosted kutt
|
||||
- Save the self hosted domain in the input (eg: `https://mykutt.it`)
|
||||
- **Note**: the api endpoint is automatically appended during the api call
|
||||
- _Delay at times while shortening might be the issue with Kutt.it API and not with the extension's_
|
||||
|
||||
## Contributing and Support
|
||||
|
||||
View the Contributing guidelines [here](CONTRIBUTING.md).
|
||||
|
||||
If you like my work, you can
|
||||
Original Repo: [thedevs-network/kutt](https://github.com/thedevs-network/kutt)
|
||||
|
||||
<a href="https://www.buymeacoffee.com/abhijithvijayan" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/purple_img.png" alt="Buy Me A Coffee" style="height: auto !important;width: auto !important;" ></a>
|
||||
## License
|
||||
|
||||
## Licence
|
||||
Code released under the [MIT License](LICENSE).
|
||||
Code released under the [MIT License](license).
|
||||
|
||||
48
eslint.config.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
import nodeConfig from '@abhijithvijayan/eslint-config/node';
|
||||
import tsConfig from '@abhijithvijayan/eslint-config/typescript';
|
||||
import reactConfig from '@abhijithvijayan/eslint-config/react';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'dist/**',
|
||||
'extension/**',
|
||||
'.yarn/**',
|
||||
'.pnp.js',
|
||||
'*.js',
|
||||
'*.mjs',
|
||||
'vite.config.ts',
|
||||
],
|
||||
},
|
||||
...nodeConfig({
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
}),
|
||||
...tsConfig({
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
}),
|
||||
...reactConfig({
|
||||
files: ['**/*.tsx'],
|
||||
}),
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
'@typescript-eslint/no-use-before-define': 'warn',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
// Disable due to resolver issues in ESM
|
||||
'import-x/no-duplicates': 'off',
|
||||
// Browser extension code uses browser APIs, not Node.js
|
||||
'n/no-unsupported-features/node-builtins': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.tsx'],
|
||||
rules: {
|
||||
'react/jsx-props-no-spreading': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/no-array-index-key': 'warn',
|
||||
'jsx-a11y/label-has-associated-control': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Abhijith V
|
||||
Copyright (c) Abhijith Vijayan <email@abhijithvijayan.in> (https://abhijithvijayan.in)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
17254
package-lock.json
generated
124
package.json
@@ -1,64 +1,76 @@
|
||||
{
|
||||
"name": "kutturl-extension",
|
||||
"version": "1.1.0",
|
||||
"main": "background.js",
|
||||
"scripts": {
|
||||
"dev-chrome": "cross-env NODE_ENV=development cross-env TARGET=chrome webpack --watch --mode=development",
|
||||
"dev-firefox": "cross-env NODE_ENV=development cross-env TARGET=firefox webpack --watch --mode=development",
|
||||
"dev-opera": "cross-env NODE_ENV=development cross-env TARGET=opera webpack --watch --mode=development",
|
||||
"build-chrome": "cross-env NODE_ENV=production cross-env TARGET=chrome webpack --mode=production",
|
||||
"build-firefox": "cross-env NODE_ENV=production cross-env TARGET=firefox webpack --mode=production",
|
||||
"build-opera": "cross-env NODE_ENV=production cross-env TARGET=opera webpack --mode=production",
|
||||
"build": "npm run build-chrome && npm run build-firefox && npm run build-opera"
|
||||
},
|
||||
"author": "abhijithvijayan",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.2.2",
|
||||
"@babel/plugin-transform-runtime": "^7.2.0",
|
||||
"@babel/preset-env": "^7.3.1",
|
||||
"autoprefixer": "^9.4.7",
|
||||
"babel-loader": "^8.0.5",
|
||||
"clean-webpack-plugin": "^1.0.1",
|
||||
"copy-webpack-plugin": "^4.6.0",
|
||||
"cross-env": "^5.2.0",
|
||||
"css-loader": "^2.1.0",
|
||||
"eslint": "^5.13.0",
|
||||
"extract-loader": "^3.1.0",
|
||||
"file-loader": "^3.0.1",
|
||||
"html-loader": "^0.5.5",
|
||||
"html-webpack-plugin": "^4.0.0-beta.5",
|
||||
"node-sass": "^4.11.0",
|
||||
"optimize-css-assets-webpack-plugin": "^5.0.1",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"precss": "^4.0.0",
|
||||
"resolve-url-loader": "^3.0.0",
|
||||
"sass-loader": "^7.1.0",
|
||||
"terser-webpack-plugin": "^1.2.2",
|
||||
"url-loader": "^1.1.2",
|
||||
"webpack": "^4.29.0",
|
||||
"webpack-cli": "^3.2.1",
|
||||
"webpack-dev-server": "^3.1.14",
|
||||
"zip-webpack-plugin": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.3.1",
|
||||
"axios": "^0.18.0",
|
||||
"kutt": "^1.0.3",
|
||||
"qrcode": "^1.3.3",
|
||||
"webextension-polyfill": "^0.3.1"
|
||||
},
|
||||
"name": "kutt-extension",
|
||||
"version": "4.4.2",
|
||||
"description": "Kutt.it extension for browsers.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/abhijithvijayan/kutt-extension.git"
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/thedevs-network/kutt-extension.git",
|
||||
"author": {
|
||||
"name": "abhijithvijayan",
|
||||
"email": "email@abhijithvijayan.in",
|
||||
"url": "https://abhijithvijayan.in"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:chrome": "cross-env TARGET_BROWSER=chrome vite build --config vite.config.ts --mode development --watch",
|
||||
"dev:firefox": "cross-env TARGET_BROWSER=firefox vite build --config vite.config.ts --mode development --watch",
|
||||
"build:chrome": "cross-env TARGET_BROWSER=chrome vite build --config vite.config.ts",
|
||||
"build:firefox": "cross-env TARGET_BROWSER=firefox vite build --config vite.config.ts",
|
||||
"build": "npm run build:chrome && npm run build:firefox",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"keywords": [
|
||||
"url",
|
||||
"shortener"
|
||||
"shortener",
|
||||
"browser",
|
||||
"extension",
|
||||
"addon",
|
||||
"kutt"
|
||||
],
|
||||
"bugs": {
|
||||
"url": "https://github.com/abhijithvijayan/kutt-extension/issues"
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@abhijithvijayan/ts-utils": "^1.2.2",
|
||||
"advanced-css-reset": "^2.1.3",
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "^2.1.1",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"webextension-polyfill": "^0.12.0"
|
||||
},
|
||||
"homepage": "https://github.com/abhijithvijayan/kutt-extension#readme"
|
||||
"devDependencies": {
|
||||
"@abhijithvijayan/eslint-config": "^3.0.1",
|
||||
"@abhijithvijayan/tsconfig": "^1.5.1",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-copy-to-clipboard": "^5.0.7",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/webextension-polyfill": "^0.12.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.51.0",
|
||||
"@typescript-eslint/parser": "^8.51.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import-x": "^4.16.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-n": "^17.23.1",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"globals": "^17.0.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.7.4",
|
||||
"sass": "^1.83.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.3.0",
|
||||
"vite-plugin-checker": "^0.12.0",
|
||||
"vite-plugin-wext-manifest": "^1.2.2",
|
||||
"vite-plugin-zip-pack": "^1.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
5
postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import autoprefixer from 'autoprefixer';
|
||||
|
||||
export default {
|
||||
plugins: [autoprefixer()],
|
||||
};
|
||||
8
source/Background/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const CHECK_API_KEY = 'api.checkApiKey';
|
||||
export const CHECK_API_KEY_TIMEOUT = 8000; // 8secs
|
||||
|
||||
export const SHORTEN_URL = 'api.shortenUrl';
|
||||
export const SHORTEN_URL_TIMEOUT = 20000; // 20secs
|
||||
|
||||
export const FETCH_URLS_HISTORY = 'api.fetchUrlsHistory';
|
||||
export const MAX_HISTORY_ITEMS = 15;
|
||||
358
source/Background/index.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* kutt-extension
|
||||
*
|
||||
* @author abhijithvijayan <abhijithvijayan.in>
|
||||
* @license MIT License
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import browser, {Runtime} from 'webextension-polyfill';
|
||||
import axios, {AxiosPromise, AxiosError} from 'axios';
|
||||
import * as constants from './constants';
|
||||
|
||||
export enum Kutt {
|
||||
hostDomain = 'kutt.it',
|
||||
hostUrl = 'https://kutt.it',
|
||||
}
|
||||
|
||||
export enum StoreLinks {
|
||||
chrome = 'https://chrome.google.com/webstore/detail/kutt/pklakpjfiegjacoppcodencchehlfnpd/reviews',
|
||||
firefox = 'https://addons.mozilla.org/en-US/firefox/addon/kutt/reviews/',
|
||||
}
|
||||
|
||||
// **** ------------------ **** //
|
||||
|
||||
export type ErrorStateProperties = {
|
||||
error: boolean | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ApiErroredProperties = {
|
||||
error: true;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type AuthRequestBodyProperties = {
|
||||
apikey: string;
|
||||
hostUrl: HostUrlProperties;
|
||||
};
|
||||
|
||||
// **** ------------------ **** //
|
||||
|
||||
type HostUrlProperties = string;
|
||||
|
||||
export type DomainEntryProperties = {
|
||||
address: string;
|
||||
banned: boolean;
|
||||
created_at: string;
|
||||
id: string;
|
||||
homepage: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type ShortenUrlBodyProperties = {
|
||||
target: string;
|
||||
password?: string;
|
||||
customurl?: string;
|
||||
reuse: boolean;
|
||||
domain?: string;
|
||||
};
|
||||
|
||||
type ShortenLinkResponseProperties = {
|
||||
id: string;
|
||||
address: string;
|
||||
banned: boolean;
|
||||
password: boolean;
|
||||
target: string;
|
||||
visit_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
link: string;
|
||||
};
|
||||
|
||||
export interface ApiBodyProperties extends ShortenUrlBodyProperties {
|
||||
apikey: string;
|
||||
}
|
||||
|
||||
export type ShortUrlActionBodyProperties = {
|
||||
apiBody: ApiBodyProperties;
|
||||
hostUrl: HostUrlProperties;
|
||||
};
|
||||
|
||||
export type SuccessfulShortenStatusProperties = {
|
||||
error: false;
|
||||
data: ShortenLinkResponseProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shorten URL using v2 API
|
||||
*/
|
||||
async function shortenUrl({
|
||||
apiBody,
|
||||
hostUrl,
|
||||
}: ShortUrlActionBodyProperties): Promise<
|
||||
SuccessfulShortenStatusProperties | ApiErroredProperties
|
||||
> {
|
||||
try {
|
||||
const {apikey, ...otherParams} = apiBody;
|
||||
|
||||
const {data}: {data: ShortenLinkResponseProperties} = await axios({
|
||||
method: 'POST',
|
||||
timeout: constants.SHORTEN_URL_TIMEOUT,
|
||||
url: `${hostUrl}/api/v2/links`,
|
||||
headers: {
|
||||
'X-API-Key': apikey,
|
||||
},
|
||||
data: {
|
||||
...otherParams,
|
||||
},
|
||||
// Prevent cookies from being sent to avoid session-based auth conflicts
|
||||
withCredentials: false,
|
||||
});
|
||||
|
||||
return {
|
||||
error: false,
|
||||
data,
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as AxiosError<{error?: string}>;
|
||||
if (err.response) {
|
||||
if (err.response.status === 401) {
|
||||
return {
|
||||
error: true,
|
||||
message: 'Error: Invalid API Key',
|
||||
};
|
||||
}
|
||||
|
||||
// server request validation errors
|
||||
if (
|
||||
err.response.status === 400 &&
|
||||
Object.prototype.hasOwnProperty.call(err.response.data, 'error')
|
||||
) {
|
||||
return {
|
||||
error: true,
|
||||
message: `Error: ${err.response.data?.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ToDo: remove in the next major update
|
||||
if (err.response.status === 404) {
|
||||
return {
|
||||
error: true,
|
||||
message:
|
||||
'Error: This extension now uses API v2, please update your kutt.it instance.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (err.code === 'ECONNABORTED') {
|
||||
return {
|
||||
error: true,
|
||||
message: 'Error: Timed out',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: true,
|
||||
message: 'Error: Something went wrong',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// **** ------------------ **** //
|
||||
|
||||
export type UserSettingsResponseProperties = {
|
||||
apikey: string;
|
||||
email: string;
|
||||
domains: DomainEntryProperties[];
|
||||
};
|
||||
|
||||
export type SuccessfulApiKeyCheckProperties = {
|
||||
error: false;
|
||||
data: UserSettingsResponseProperties;
|
||||
};
|
||||
|
||||
function getUserSettings({
|
||||
apikey,
|
||||
hostUrl,
|
||||
}: AuthRequestBodyProperties): AxiosPromise<any> {
|
||||
return axios({
|
||||
method: 'GET',
|
||||
url: `${hostUrl}/api/v2/users`,
|
||||
timeout: constants.CHECK_API_KEY_TIMEOUT,
|
||||
headers: {
|
||||
'X-API-Key': apikey,
|
||||
},
|
||||
// Prevent cookies from being sent to avoid session-based auth conflicts
|
||||
withCredentials: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function checkApiKey({
|
||||
apikey,
|
||||
hostUrl,
|
||||
}: AuthRequestBodyProperties): Promise<
|
||||
SuccessfulApiKeyCheckProperties | ApiErroredProperties
|
||||
> {
|
||||
try {
|
||||
const {data}: {data: UserSettingsResponseProperties} =
|
||||
await getUserSettings({
|
||||
apikey,
|
||||
hostUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
error: false,
|
||||
data,
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as AxiosError;
|
||||
if (err.response) {
|
||||
if (err.response.status === 401) {
|
||||
return {
|
||||
error: true,
|
||||
message: 'Error: Invalid API Key',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: true,
|
||||
message: 'Error: Something went wrong.',
|
||||
};
|
||||
}
|
||||
|
||||
if (err.code === 'ECONNABORTED') {
|
||||
return {
|
||||
error: true,
|
||||
message: 'Error: Timed out',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: true,
|
||||
message: 'Error: Requesting to server failed.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// **** ------------------ **** //
|
||||
|
||||
export type SuccessfulUrlsHistoryFetchProperties = {
|
||||
error: false;
|
||||
data: UserShortenedLinksHistoryResponseBody;
|
||||
};
|
||||
|
||||
type UserShortenedLinksHistoryResponseBody = {
|
||||
limit: number;
|
||||
skip: number;
|
||||
total: number;
|
||||
data: UserShortenedLinkStats[];
|
||||
};
|
||||
|
||||
export type UserShortenedLinkStats = {
|
||||
address: string;
|
||||
banned: boolean;
|
||||
created_at: string;
|
||||
id: string;
|
||||
link: string;
|
||||
password: boolean;
|
||||
target: string;
|
||||
updated_at: string;
|
||||
visit_count: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch User's recent 15 shortened urls
|
||||
*/
|
||||
|
||||
async function fetchUrlsHistory({
|
||||
apikey,
|
||||
hostUrl,
|
||||
}: AuthRequestBodyProperties): Promise<
|
||||
SuccessfulUrlsHistoryFetchProperties | ApiErroredProperties
|
||||
> {
|
||||
try {
|
||||
const {data}: {data: UserShortenedLinksHistoryResponseBody} = await axios({
|
||||
method: 'GET',
|
||||
timeout: constants.SHORTEN_URL_TIMEOUT,
|
||||
url: `${hostUrl}/api/v2/links`,
|
||||
params: {
|
||||
limit: constants.MAX_HISTORY_ITEMS,
|
||||
},
|
||||
headers: {
|
||||
'X-API-Key': apikey,
|
||||
},
|
||||
// Prevent cookies from being sent to avoid session-based auth conflicts
|
||||
withCredentials: false,
|
||||
});
|
||||
|
||||
return {
|
||||
error: false,
|
||||
data,
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as AxiosError;
|
||||
if (err.response) {
|
||||
if (err.response.status === 401) {
|
||||
return {
|
||||
error: true,
|
||||
message: 'Error: Invalid API Key',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: true,
|
||||
message: 'Error: Something went wrong.',
|
||||
};
|
||||
}
|
||||
|
||||
if (err.code === 'ECONNABORTED') {
|
||||
return {
|
||||
error: true,
|
||||
message: 'Error: Timed out',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: true,
|
||||
message: 'Error: Requesting to server failed.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// **** ------------------ **** //
|
||||
|
||||
/**
|
||||
* Service worker installation listener (MV3)
|
||||
*/
|
||||
browser.runtime.onInstalled.addListener((): void => {
|
||||
console.log('Kutt extension installed');
|
||||
});
|
||||
|
||||
type MessageRequest = {
|
||||
action: string;
|
||||
params: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* Listen for messages from UI pages
|
||||
*/
|
||||
browser.runtime.onMessage.addListener(
|
||||
(message: unknown, _sender: Runtime.MessageSender): void | Promise<any> => {
|
||||
const request = message as MessageRequest;
|
||||
|
||||
switch (request.action) {
|
||||
case constants.CHECK_API_KEY: {
|
||||
return checkApiKey(request.params);
|
||||
}
|
||||
|
||||
case constants.SHORTEN_URL: {
|
||||
return shortenUrl(request.params);
|
||||
}
|
||||
|
||||
case constants.FETCH_URLS_HISTORY: {
|
||||
return fetchUrlsHistory(request.params);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
36
source/History/History.module.scss
Normal file
@@ -0,0 +1,36 @@
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
.historyPage {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%);
|
||||
}
|
||||
|
||||
.historyContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 2rem 1.5rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
font-weight: $medium;
|
||||
color: $gray-600;
|
||||
padding: 2rem;
|
||||
background-color: $white;
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.loaderContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 10rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
160
source/History/History.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import type {JSX} from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
import {
|
||||
useShortenedLinks,
|
||||
ShortenedLinksActionTypes,
|
||||
} from '../contexts/shortened-links-context';
|
||||
import {
|
||||
HostProperties,
|
||||
useExtensionSettings,
|
||||
} from '../contexts/extension-settings-context';
|
||||
import {
|
||||
useRequestStatus,
|
||||
RequestStatusActionTypes,
|
||||
} from '../contexts/request-status-context';
|
||||
import messageUtil from '../util/messageUtil';
|
||||
import {FETCH_URLS_HISTORY} from '../Background/constants';
|
||||
import {getExtensionSettings} from '../util/settings';
|
||||
import {
|
||||
SuccessfulUrlsHistoryFetchProperties,
|
||||
AuthRequestBodyProperties,
|
||||
ApiErroredProperties,
|
||||
ErrorStateProperties,
|
||||
Kutt,
|
||||
} from '../Background';
|
||||
import {isValidUrl} from '../util/link';
|
||||
|
||||
import BodyWrapper from '../components/BodyWrapper';
|
||||
import Loader from '../components/Loader';
|
||||
import Header from '../Options/Header';
|
||||
import Table from './Table';
|
||||
|
||||
import styles from './History.module.scss';
|
||||
|
||||
function History(): JSX.Element {
|
||||
const [, shortenedLinksDispatch] = useShortenedLinks();
|
||||
const [, extensionSettingsDispatch] = useExtensionSettings();
|
||||
const [requestStatusState, requestStatusDispatch] = useRequestStatus();
|
||||
const [errored, setErrored] = useState<ErrorStateProperties>({
|
||||
error: null,
|
||||
message: '',
|
||||
});
|
||||
const [hostUrl, setHostUrl] = useState<string>(Kutt.hostUrl);
|
||||
|
||||
useEffect(() => {
|
||||
async function getUrlsHistoryStats(): Promise<void> {
|
||||
// ********************************* //
|
||||
// **** GET EXTENSIONS SETTINGS **** //
|
||||
// ********************************* //
|
||||
const {settings = {}} = await getExtensionSettings();
|
||||
const advancedSettings: boolean =
|
||||
(settings?.advanced as boolean) || false;
|
||||
|
||||
const defaultHost: HostProperties =
|
||||
(advancedSettings &&
|
||||
(settings?.host as string) &&
|
||||
isValidUrl(settings.host as string) && {
|
||||
hostDomain:
|
||||
(settings.host as string)
|
||||
.replace('http://', '')
|
||||
.replace('https://', '')
|
||||
.replace('www.', '')
|
||||
.split(/[/?#]/)[0] || '', // extract domain
|
||||
hostUrl: (settings.host as string).endsWith('/')
|
||||
? (settings.host as string).slice(0, -1)
|
||||
: (settings.host as string), // slice `/` at the end
|
||||
}) ||
|
||||
Kutt;
|
||||
|
||||
// inject existing keys (if field doesn't exist, use default)
|
||||
const defaultExtensionConfig = {
|
||||
apikey: (settings?.apikey as string)?.trim() || '',
|
||||
history: (settings?.history as boolean) || false,
|
||||
advanced:
|
||||
defaultHost.hostUrl.trim() !== Kutt.hostUrl && advancedSettings, // disable `advanced` if customhost is not set
|
||||
host: defaultHost,
|
||||
};
|
||||
|
||||
setHostUrl(defaultExtensionConfig.host.hostUrl);
|
||||
|
||||
// extensionSettingsDispatch({
|
||||
// type: ExtensionSettingsActionTypes.HYDRATE_EXTENSION_SETTINGS,
|
||||
// payload: defaultExtensionConfig,
|
||||
// });
|
||||
|
||||
if (defaultExtensionConfig.history) {
|
||||
// ****************************************************** //
|
||||
// **************** FETCH URLS HISTORY ****************** //
|
||||
// ****************************************************** //
|
||||
const urlsHistoryFetchRequetBody: AuthRequestBodyProperties = {
|
||||
apikey: defaultExtensionConfig.apikey,
|
||||
hostUrl: defaultExtensionConfig.host.hostUrl,
|
||||
};
|
||||
|
||||
// call api
|
||||
const response:
|
||||
| SuccessfulUrlsHistoryFetchProperties
|
||||
| ApiErroredProperties = await messageUtil.send(
|
||||
FETCH_URLS_HISTORY,
|
||||
urlsHistoryFetchRequetBody
|
||||
);
|
||||
|
||||
if (!response.error) {
|
||||
setErrored({error: false, message: 'Fetch successful'});
|
||||
|
||||
shortenedLinksDispatch({
|
||||
type: ShortenedLinksActionTypes.HYDRATE_SHORTENED_LINKS,
|
||||
payload: {
|
||||
items: response.data.data,
|
||||
total: response.data.total,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setErrored({error: true, message: response.message});
|
||||
}
|
||||
} else {
|
||||
setErrored({
|
||||
error: true,
|
||||
message: 'History page disabled. Please enable it from settings.',
|
||||
});
|
||||
}
|
||||
|
||||
requestStatusDispatch({
|
||||
type: RequestStatusActionTypes.SET_LOADING,
|
||||
payload: false,
|
||||
});
|
||||
}
|
||||
|
||||
getUrlsHistoryStats();
|
||||
}, [
|
||||
extensionSettingsDispatch,
|
||||
requestStatusDispatch,
|
||||
shortenedLinksDispatch,
|
||||
]);
|
||||
|
||||
return (
|
||||
<BodyWrapper>
|
||||
<div id="history" className={styles.historyPage}>
|
||||
<div className={styles.historyContent}>
|
||||
<Header subtitle="Recent Links" hostUrl={hostUrl} />
|
||||
|
||||
{}
|
||||
{!requestStatusState.loading ? (
|
||||
!errored.error ? (
|
||||
<Table />
|
||||
) : (
|
||||
<h2 className={styles.errorMessage}>{errored.message}</h2>
|
||||
)
|
||||
) : (
|
||||
<div className={styles.loaderContainer}>
|
||||
<Loader />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</BodyWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default History;
|
||||
63
source/History/Modal.module.scss
Normal file
@@ -0,0 +1,63 @@
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
padding: 2.5rem 3rem;
|
||||
text-align: center;
|
||||
background-color: $white;
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.qrCodeWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background-color: $white;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
.buttonWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 2.5rem;
|
||||
padding: 0 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: $medium;
|
||||
color: $white;
|
||||
cursor: pointer;
|
||||
border-radius: $radius-md;
|
||||
border: none;
|
||||
background: $primary-gradient;
|
||||
box-shadow: 0 4px 6px -1px rgba(126, 87, 194, 0.3);
|
||||
transition: transform $transition-fast, box-shadow $transition-fast;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 10px -1px rgba(126, 87, 194, 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
50
source/History/Modal.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import {QRCodeSVG} from 'qrcode.react';
|
||||
import type {JSX} from 'react';
|
||||
import {Dispatch, SetStateAction} from 'react';
|
||||
|
||||
import styles from './Modal.module.scss';
|
||||
|
||||
type Props = {
|
||||
link: string;
|
||||
setModalView: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
function Modal({link, setModalView}: Props): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={styles.modalOverlay}
|
||||
onClick={(): void => setModalView(false)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Escape') setModalView(false);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
className={styles.modalContent}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
onKeyDown={(e): void => e.stopPropagation()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className={styles.qrCodeWrapper}>
|
||||
<QRCodeSVG size={196} value={link} />
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonWrapper}>
|
||||
<button
|
||||
onClick={(): void => setModalView(false)}
|
||||
className={styles.closeButton}
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Modal;
|
||||
227
source/History/Table.module.scss
Normal file
@@ -0,0 +1,227 @@
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
.tableContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tableTitle {
|
||||
font-size: 0.875rem;
|
||||
font-weight: $medium;
|
||||
color: $gray-600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background-color: $white;
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thead {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: $gray-200;
|
||||
}
|
||||
|
||||
.theadRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.5rem;
|
||||
border-bottom: 1px solid $gray-300;
|
||||
}
|
||||
|
||||
.th {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0.875rem 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: $semibold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: $gray-600;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.thOriginal {
|
||||
flex: 2 2 0px;
|
||||
}
|
||||
|
||||
.thShort {
|
||||
flex: 1 1 0px;
|
||||
}
|
||||
|
||||
.tbody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tr {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.5rem;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
transition: background-color $transition-fast;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($gray-200, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.td {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.tdOriginal {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
flex: 2 2 0px;
|
||||
padding-right: 1rem;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
height: 100%;
|
||||
width: 48px;
|
||||
background: linear-gradient(to left, $white, $white, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tr:hover &::after {
|
||||
background: linear-gradient(to left, rgba($gray-200, 0.3), rgba($gray-200, 0.3), transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.tdShort {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
flex: 1 1 0px;
|
||||
padding-right: 1rem;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
height: 100%;
|
||||
width: 48px;
|
||||
background: linear-gradient(to left, $white, $white, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tr:hover &::after {
|
||||
background: linear-gradient(to left, rgba($gray-200, 0.3), rgba($gray-200, 0.3), transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
font-size: 0.875rem;
|
||||
line-height: normal;
|
||||
text-decoration: none;
|
||||
color: $blue-500;
|
||||
transition: color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: darken($blue-500, 10%);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.copiedNotification {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
left: 0;
|
||||
font-size: 0.625rem;
|
||||
font-weight: $medium;
|
||||
color: $green-500;
|
||||
background-color: rgba($green-500, 0.1);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.shortUrlWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actionsWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background-color: $gray-200;
|
||||
border-radius: $radius-full;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: transform $transition-fast, background-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
background-color: $gray-300;
|
||||
}
|
||||
|
||||
svg {
|
||||
stroke: $gray-600;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
&:first-child svg {
|
||||
stroke: $green-500;
|
||||
}
|
||||
}
|
||||
|
||||
.emptyRow {
|
||||
padding: 2rem;
|
||||
color: $gray-500;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
153
source/History/Table.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import type {JSX} from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import {
|
||||
useShortenedLinks,
|
||||
ShortenedLinksActionTypes,
|
||||
} from '../contexts/shortened-links-context';
|
||||
import {MAX_HISTORY_ITEMS} from '../Background/constants';
|
||||
|
||||
import Icon from '../components/Icon';
|
||||
import Modal from './Modal';
|
||||
|
||||
import styles from './Table.module.scss';
|
||||
|
||||
function Table(): JSX.Element {
|
||||
const [shortenedLinksState, shortenedLinksDispatch] = useShortenedLinks();
|
||||
const [QRView, setQRView] = useState<boolean>(false);
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
|
||||
// reset copy message
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
timer = setTimeout(() => {
|
||||
setCopied(false);
|
||||
// reset selected id from context
|
||||
}, 1300);
|
||||
|
||||
return (): void => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [copied]);
|
||||
|
||||
function handleCopyToClipboard(selectedItemId: string): void {
|
||||
shortenedLinksDispatch({
|
||||
type: ShortenedLinksActionTypes.SET_CURRENT_SELECTED,
|
||||
payload: selectedItemId,
|
||||
});
|
||||
|
||||
setCopied(true);
|
||||
}
|
||||
|
||||
function handleQRCodeViewToggle(selectedItemId: string): void {
|
||||
shortenedLinksDispatch({
|
||||
type: ShortenedLinksActionTypes.SET_CURRENT_SELECTED,
|
||||
payload: selectedItemId,
|
||||
});
|
||||
|
||||
setQRView(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.tableContainer}>
|
||||
<div className={styles.tableWrapper}>
|
||||
<div className={styles.tableHeader}>
|
||||
<h2 className={styles.tableTitle}>
|
||||
Recent shortened links. (last {MAX_HISTORY_ITEMS} results)
|
||||
</h2>
|
||||
</div>
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.thead}>
|
||||
<tr className={styles.theadRow}>
|
||||
<th className={clsx(styles.th, styles.thOriginal)}>
|
||||
Original URL
|
||||
</th>
|
||||
<th className={clsx(styles.th, styles.thShort)}>Short URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={styles.tbody}>
|
||||
{!(shortenedLinksState.total === 0) ? (
|
||||
shortenedLinksState.items.map((item) => (
|
||||
<tr key={item.id} className={styles.tr}>
|
||||
<td className={clsx(styles.td, styles.tdOriginal)}>
|
||||
<a
|
||||
className={styles.link}
|
||||
href={item.target}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
{item.target}
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<td className={clsx(styles.td, styles.tdShort)}>
|
||||
{copied &&
|
||||
shortenedLinksState.selected?.id === item.id && (
|
||||
<div className={styles.copiedNotification}>
|
||||
Copied to clipboard!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.shortUrlWrapper}>
|
||||
<a
|
||||
className={styles.link}
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
{item.link}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className={styles.td}>
|
||||
<div className={styles.actionsWrapper}>
|
||||
{/* // **** COPY TO CLIPBOARD **** // */}
|
||||
|
||||
{copied &&
|
||||
shortenedLinksState.selected?.id === item.id ? (
|
||||
<Icon name="tick" className={styles.actionIcon} />
|
||||
) : (
|
||||
<CopyToClipboard
|
||||
text={item.link}
|
||||
onCopy={(): void => handleCopyToClipboard(item.id)}
|
||||
>
|
||||
<Icon name="copy" className={styles.actionIcon} />
|
||||
</CopyToClipboard>
|
||||
)}
|
||||
|
||||
<Icon
|
||||
onClick={(): void => handleQRCodeViewToggle(item.id)}
|
||||
className={styles.actionIcon}
|
||||
name="qrcode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* // **** QR CODE MODAL **** // */}
|
||||
{QRView &&
|
||||
shortenedLinksState.selected?.id === item.id && (
|
||||
<Modal link={item.link} setModalView={setQRView} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className={styles.emptyRow}>No URLs History</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Table;
|
||||
12
source/History/history.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>History: Kutt</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="history-root"></div>
|
||||
<script type="module" src="./index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
27
source/History/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
|
||||
import {ExtensionSettingsProvider} from '../contexts/extension-settings-context';
|
||||
import {RequestStatusProvider} from '../contexts/request-status-context';
|
||||
import {ShortenedLinksProvider} from '../contexts/shortened-links-context';
|
||||
import History from './History';
|
||||
|
||||
import './styles.scss';
|
||||
|
||||
const container = document.getElementById('history-root');
|
||||
if (!container) {
|
||||
throw new Error('Could not find history-root container');
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<ExtensionSettingsProvider>
|
||||
<RequestStatusProvider>
|
||||
<ShortenedLinksProvider>
|
||||
<History />
|
||||
</ShortenedLinksProvider>
|
||||
</RequestStatusProvider>
|
||||
</ExtensionSettingsProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
5
source/History/styles.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
@use '../styles/main.scss';
|
||||
|
||||
body {
|
||||
background-color: #edf2f7;
|
||||
}
|
||||
108
source/Options/Footer.module.scss
Normal file
@@ -0,0 +1,108 @@
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
.footer {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
font-weight: $regular;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.ratingSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: $gray-800;
|
||||
}
|
||||
|
||||
.dividerLine {
|
||||
display: block;
|
||||
width: 33.333%;
|
||||
border: 1px solid $gray-200;
|
||||
|
||||
&.left {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
&.right {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ratingLink {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.starsContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 0.25rem;
|
||||
|
||||
&:hover .starIcon {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.starIcon {
|
||||
margin-right: 0.25rem;
|
||||
fill: currentColor;
|
||||
transition: transform $transition-normal, color $transition-normal;
|
||||
|
||||
&.yellow {
|
||||
color: $yellow-500;
|
||||
}
|
||||
|
||||
&.gray {
|
||||
color: $gray-400;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ratingLink:hover &.gray {
|
||||
color: $yellow-500;
|
||||
}
|
||||
}
|
||||
|
||||
.ratingText {
|
||||
margin-bottom: 0;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.linksSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.linkItem {
|
||||
padding: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: $gray-500;
|
||||
text-decoration: none;
|
||||
transition: color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $blue-500;
|
||||
}
|
||||
|
||||
&.narrow {
|
||||
width: 33.333%;
|
||||
}
|
||||
|
||||
&.wide {
|
||||
width: 66.666%;
|
||||
}
|
||||
}
|
||||
|
||||
.linkDivider {
|
||||
width: 1px;
|
||||
height: 1rem;
|
||||
background-color: $gray-300;
|
||||
}
|
||||
88
source/Options/Footer.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import type {JSX} from 'react';
|
||||
import {memo} from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import {detectBrowser} from '../util/browser';
|
||||
import {StoreLinks} from '../Background';
|
||||
|
||||
import Icon from '../components/Icon';
|
||||
|
||||
import styles from './Footer.module.scss';
|
||||
|
||||
function Footer(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<footer className={styles.footer}>
|
||||
<div className={styles.ratingSection}>
|
||||
<span className={clsx(styles.dividerLine, styles.left)} />
|
||||
<a
|
||||
href={
|
||||
detectBrowser() === 'firefox'
|
||||
? StoreLinks.firefox
|
||||
: StoreLinks.chrome
|
||||
}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className={styles.ratingLink}
|
||||
>
|
||||
<div className={styles.starsContainer}>
|
||||
<Icon
|
||||
className={clsx(styles.starIcon, styles.gray)}
|
||||
name="star-white"
|
||||
/>
|
||||
<Icon
|
||||
className={clsx(styles.starIcon, styles.gray)}
|
||||
name="star-white"
|
||||
/>
|
||||
<Icon
|
||||
className={clsx(styles.starIcon, styles.gray)}
|
||||
name="star-white"
|
||||
/>
|
||||
<Icon
|
||||
className={clsx(styles.starIcon, styles.gray)}
|
||||
name="star-white"
|
||||
/>
|
||||
<Icon
|
||||
className={clsx(styles.starIcon, styles.gray)}
|
||||
name="star-white"
|
||||
/>
|
||||
</div>
|
||||
<p className={styles.ratingText}>Rate on Store</p>
|
||||
</a>
|
||||
<span className={clsx(styles.dividerLine, styles.right)} />
|
||||
</div>
|
||||
|
||||
<div className={styles.linksSection}>
|
||||
<a
|
||||
href="https://kutt.it"
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className={clsx(styles.linkItem, styles.narrow)}
|
||||
>
|
||||
Kutt.it
|
||||
</a>
|
||||
<span className={styles.linkDivider} />
|
||||
<a
|
||||
href="https://git.io/Jn5hS"
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className={clsx(styles.linkItem, styles.wide)}
|
||||
>
|
||||
Report an issue
|
||||
</a>
|
||||
<span className={styles.linkDivider} />
|
||||
<a
|
||||
href="https://github.com/thedevs-network/kutt-extension"
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className={clsx(styles.linkItem, styles.narrow)}
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Footer);
|
||||
540
source/Options/Form.module.scss
Normal file
@@ -0,0 +1,540 @@
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
.formSection {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.inputGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: $semibold;
|
||||
color: $gray-700;
|
||||
font-size: 0.8125rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.labelLink {
|
||||
margin-left: 0.5rem;
|
||||
color: $blue-500;
|
||||
text-decoration: none;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: normal;
|
||||
font-weight: $medium;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.labelLinkWrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover .tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.labelWithInfo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.inputIconWrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
width: 2.5rem;
|
||||
height: 100%;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.inputIcon {
|
||||
z-index: 10;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: $gray-600;
|
||||
border-radius: $radius-sm 0 0 $radius-sm;
|
||||
}
|
||||
|
||||
.input {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
padding-right: 3rem;
|
||||
font-size: 0.875rem;
|
||||
background-color: $white;
|
||||
border: 1.5px solid $gray-300;
|
||||
border-radius: $radius-md;
|
||||
transition: border-color $transition-fast, box-shadow $transition-fast;
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: $gray-500;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $indigo-400;
|
||||
box-shadow: 0 0 0 3px rgba(129, 140, 248, 0.2);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.inputError {
|
||||
border-color: $red-500;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 0.25rem;
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: $medium;
|
||||
letter-spacing: 0.025em;
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.validateSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.validateButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: $semibold;
|
||||
text-align: center;
|
||||
color: $white;
|
||||
background: $primary-gradient;
|
||||
border: none;
|
||||
border-radius: $radius-md;
|
||||
box-shadow: 0 4px 6px -1px rgba(126, 87, 194, 0.3);
|
||||
cursor: pointer;
|
||||
transition: transform $transition-fast, box-shadow $transition-fast, opacity $transition-fast;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 10px -1px rgba(126, 87, 194, 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(126, 87, 194, 0.3);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.validateText {}
|
||||
|
||||
.validateIcon {
|
||||
display: inline-flex;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
|
||||
svg {
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
transition: transform $transition-normal;
|
||||
}
|
||||
}
|
||||
|
||||
.validationFeedback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: $radius-md;
|
||||
animation: slideIn $transition-normal ease-out;
|
||||
|
||||
&.error {
|
||||
background-color: rgba($red-500, 0.1);
|
||||
border: 1px solid rgba($red-500, 0.2);
|
||||
}
|
||||
|
||||
&.success {
|
||||
background-color: rgba($green-500, 0.1);
|
||||
border: 1px solid rgba($green-500, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.feedbackIcon {
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
stroke-width: 2;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.error & svg {
|
||||
stroke: $red-500;
|
||||
}
|
||||
|
||||
.success & svg {
|
||||
stroke: $green-500;
|
||||
}
|
||||
}
|
||||
|
||||
.feedbackMessage {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: $medium;
|
||||
line-height: 1.4;
|
||||
|
||||
.error & {
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.success & {
|
||||
color: $green-500;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.5rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.toggleSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
.toggleLabel {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.625rem 0;
|
||||
cursor: pointer;
|
||||
border-radius: $radius-sm;
|
||||
transition: background-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($gray-200, 0.5);
|
||||
margin: 0 -0.5rem;
|
||||
padding: 0.625rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.toggleText {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.toggleTextWithInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
color: $gray-500;
|
||||
cursor: help;
|
||||
|
||||
&:hover {
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
&:hover .tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 0.5rem);
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: 200px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: $regular;
|
||||
line-height: 1.4;
|
||||
color: $white;
|
||||
background-color: $gray-800;
|
||||
border-radius: $radius-sm;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
transition: opacity $transition-normal, visibility $transition-normal;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 0.375rem;
|
||||
border-style: solid;
|
||||
border-color: $gray-800 transparent transparent transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.toggleWrapper {
|
||||
position: relative;
|
||||
width: 2.5rem;
|
||||
height: 1.5rem;
|
||||
margin-left: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggleTrack {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: $gray-400;
|
||||
border-radius: 9999px;
|
||||
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.06);
|
||||
transition: background-color $transition-normal;
|
||||
}
|
||||
|
||||
.toggleKnob {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
left: 0.25rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||
transition: transform $transition-normal;
|
||||
background-color: $white;
|
||||
|
||||
&.active {
|
||||
transform: translateX(1rem);
|
||||
}
|
||||
}
|
||||
|
||||
.toggleWrapper:has(.toggleKnob.active) .toggleTrack {
|
||||
background-color: $purple-600;
|
||||
}
|
||||
|
||||
.toggleInput {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.advancedSection {
|
||||
margin-top: 0.75rem;
|
||||
padding: 1rem;
|
||||
background-color: rgba($gray-200, 0.5);
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid $gray-200;
|
||||
max-height: 200px;
|
||||
opacity: 1;
|
||||
overflow: visible;
|
||||
transition: max-height $transition-normal, opacity $transition-normal, margin-top $transition-normal, padding $transition-normal;
|
||||
|
||||
&.hidden {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
margin-top: 0;
|
||||
padding: 0 1rem;
|
||||
border-color: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.resetSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
.resetButton {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: $medium;
|
||||
color: $red-500;
|
||||
background-color: transparent;
|
||||
border: 1.5px solid $red-500;
|
||||
border-radius: $radius-md;
|
||||
cursor: pointer;
|
||||
transition: background-color $transition-fast, color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background-color: $red-500;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
.resetHint {
|
||||
font-size: 0.75rem;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn $transition-fast ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
background-color: $white;
|
||||
border-radius: $radius-lg;
|
||||
padding: 1.5rem;
|
||||
max-width: 320px;
|
||||
width: 90%;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
animation: modalSlideIn $transition-fast ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.modalIcon {
|
||||
color: $red-500;
|
||||
|
||||
svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
stroke-width: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
font-size: 1rem;
|
||||
font-weight: $semibold;
|
||||
color: $gray-800;
|
||||
}
|
||||
|
||||
.modalText {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: $gray-600;
|
||||
margin: 0 0 1.25rem 0;
|
||||
}
|
||||
|
||||
.modalActions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.modalCancelButton {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: $medium;
|
||||
color: $gray-600;
|
||||
background-color: $gray-200;
|
||||
border: none;
|
||||
border-radius: $radius-md;
|
||||
cursor: pointer;
|
||||
transition: background-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-300;
|
||||
}
|
||||
}
|
||||
|
||||
.modalConfirmButton {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: $medium;
|
||||
color: $white;
|
||||
background-color: $red-500;
|
||||
border: none;
|
||||
border-radius: $radius-md;
|
||||
cursor: pointer;
|
||||
transition: background-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background-color: darken($red-500, 10%);
|
||||
}
|
||||
}
|
||||
525
source/Options/Form.tsx
Normal file
@@ -0,0 +1,525 @@
|
||||
import {isNull, isUndefined} from '@abhijithvijayan/ts-utils';
|
||||
import type {JSX} from 'react';
|
||||
import {useState, useEffect, useRef, ChangeEvent} from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import {useExtensionSettings} from '../contexts/extension-settings-context';
|
||||
import {
|
||||
updateExtensionSettings,
|
||||
clearExtensionSettings,
|
||||
} from '../util/settings';
|
||||
import {CHECK_API_KEY} from '../Background/constants';
|
||||
import messageUtil from '../util/messageUtil';
|
||||
import {isValidUrl} from '../util/link';
|
||||
import {
|
||||
SuccessfulApiKeyCheckProperties,
|
||||
AuthRequestBodyProperties,
|
||||
ApiErroredProperties,
|
||||
ErrorStateProperties,
|
||||
Kutt,
|
||||
} from '../Background';
|
||||
|
||||
import Icon from '../components/Icon';
|
||||
|
||||
import styles from './Form.module.scss';
|
||||
|
||||
type OptionsFormValuesProperties = {
|
||||
apikey: string;
|
||||
history: boolean;
|
||||
advanced: boolean;
|
||||
host: string;
|
||||
reuse: boolean;
|
||||
};
|
||||
|
||||
type FormErrors = {
|
||||
apikey?: string;
|
||||
host?: string;
|
||||
};
|
||||
|
||||
type FormValidity = {
|
||||
apikey?: boolean;
|
||||
host?: boolean;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const onSave = (values: OptionsFormValuesProperties): Promise<any> =>
|
||||
// should always return a Promise
|
||||
updateExtensionSettings(values); // update local settings
|
||||
function Form(): JSX.Element {
|
||||
const extensionSettingsState = useExtensionSettings()[0];
|
||||
const hostInputRef = useRef<HTMLInputElement>(null);
|
||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||
const [showApiKey, setShowApiKey] = useState<boolean>(false);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState<boolean>(false);
|
||||
const [errored, setErrored] = useState<ErrorStateProperties>({
|
||||
error: null,
|
||||
message: '',
|
||||
});
|
||||
|
||||
const [formValues, setFormValues] = useState<OptionsFormValuesProperties>({
|
||||
apikey: extensionSettingsState.apikey,
|
||||
history: extensionSettingsState.history,
|
||||
advanced: extensionSettingsState.advanced,
|
||||
host:
|
||||
(extensionSettingsState.advanced &&
|
||||
extensionSettingsState.host.hostUrl) ||
|
||||
'',
|
||||
reuse: extensionSettingsState.reuse,
|
||||
});
|
||||
|
||||
const [formErrors, setFormErrors] = useState<FormErrors>({});
|
||||
const [formValidity, setFormValidity] = useState<FormValidity>({});
|
||||
|
||||
const isFormValid: boolean =
|
||||
((isUndefined(formValidity.apikey) || formValidity.apikey) &&
|
||||
formValues.apikey.trim().length === 40 &&
|
||||
isUndefined(formErrors.apikey) &&
|
||||
(((isUndefined(formValidity.host) || formValidity.host) &&
|
||||
isUndefined(formErrors.host)) ||
|
||||
!formValues.advanced)) ||
|
||||
false;
|
||||
|
||||
// on component mount -> save `settings` object
|
||||
useEffect(() => {
|
||||
onSave({
|
||||
...formValues,
|
||||
...(formValues.advanced === false && {host: ''}),
|
||||
});
|
||||
}, [formValues]);
|
||||
|
||||
function handleApiKeyInputChange(apikey: string): void {
|
||||
setFormValues((prev) => {
|
||||
return {...prev, apikey};
|
||||
});
|
||||
// ToDo: Remove special symbols
|
||||
|
||||
if (!(apikey.trim().length > 0)) {
|
||||
setFormErrors((prev) => {
|
||||
return {...prev, apikey: 'API key missing'};
|
||||
});
|
||||
setFormValidity((prev) => {
|
||||
return {...prev, apikey: false};
|
||||
});
|
||||
} else if (apikey && apikey.trim().length < 40) {
|
||||
setFormErrors((prev) => {
|
||||
return {...prev, apikey: 'API key must be 40 characters'};
|
||||
});
|
||||
setFormValidity((prev) => {
|
||||
return {...prev, apikey: false};
|
||||
});
|
||||
} else if (apikey && apikey.trim().length > 40) {
|
||||
setFormErrors((prev) => {
|
||||
return {...prev, apikey: 'API key cannot exceed 40 characters'};
|
||||
});
|
||||
setFormValidity((prev) => {
|
||||
return {...prev, apikey: false};
|
||||
});
|
||||
} else {
|
||||
setFormErrors((prev) => {
|
||||
const {apikey: _, ...rest} = prev;
|
||||
return rest;
|
||||
});
|
||||
setFormValidity((prev) => {
|
||||
return {...prev, apikey: true};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleHostUrlInputChange(host: string): void {
|
||||
if (!formValues.advanced) {
|
||||
setFormErrors((prev) => {
|
||||
return {...prev, host: 'Enable Advanced Options first'};
|
||||
});
|
||||
setFormValidity((prev) => {
|
||||
return {...prev, host: false};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setFormValues((prev) => {
|
||||
return {...prev, host};
|
||||
});
|
||||
|
||||
if (!(host.trim().length > 0)) {
|
||||
setFormErrors((prev) => {
|
||||
return {...prev, host: 'Custom URL cannot be empty'};
|
||||
});
|
||||
setFormValidity((prev) => {
|
||||
return {...prev, host: false};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidUrl(host.trim()) || host.trim().length < 10) {
|
||||
setFormErrors((prev) => {
|
||||
return {...prev, host: 'Please enter a valid url'};
|
||||
});
|
||||
setFormValidity((prev) => {
|
||||
return {...prev, host: false};
|
||||
});
|
||||
} else {
|
||||
setFormErrors((prev) => {
|
||||
const {host: _, ...rest} = prev;
|
||||
return rest;
|
||||
});
|
||||
setFormValidity((prev) => {
|
||||
return {...prev, host: true};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApiKeyVerification(): Promise<void> {
|
||||
setSubmitting(true);
|
||||
// request API validation request
|
||||
const apiKeyValidationBody: AuthRequestBodyProperties = {
|
||||
apikey: formValues.apikey.trim(),
|
||||
hostUrl:
|
||||
(formValues.advanced &&
|
||||
formValues.host.trim().length > 0 &&
|
||||
formValues.host.trim()) ||
|
||||
Kutt.hostUrl,
|
||||
};
|
||||
|
||||
// API call
|
||||
const response: SuccessfulApiKeyCheckProperties | ApiErroredProperties =
|
||||
await messageUtil.send(CHECK_API_KEY, apiKeyValidationBody);
|
||||
|
||||
if (!response.error) {
|
||||
// set top-level status
|
||||
setErrored({error: false, message: 'Valid API Key'});
|
||||
|
||||
// Store user account information
|
||||
const {domains, email} = response.data;
|
||||
await updateExtensionSettings({user: {domains, email}});
|
||||
} else {
|
||||
// ---- errored ---- //
|
||||
setErrored({error: true, message: response.message});
|
||||
|
||||
// Delete `user` field from settings
|
||||
await updateExtensionSettings({user: null});
|
||||
}
|
||||
|
||||
// enable validate button
|
||||
setSubmitting(false);
|
||||
|
||||
setTimeout(() => {
|
||||
// Reset status
|
||||
setErrored({error: null, message: ''});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function handleResetSettings(): Promise<void> {
|
||||
await clearExtensionSettings();
|
||||
setShowResetConfirm(false);
|
||||
// Reload the page to reflect cleared settings
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.formSection}>
|
||||
<div className={styles.inputGroup}>
|
||||
<label htmlFor="apikey" className={styles.label}>
|
||||
API Key
|
||||
<span className={styles.labelLinkWrapper}>
|
||||
<a
|
||||
href={`${
|
||||
(formValues.advanced && formValues.host) || Kutt.hostUrl
|
||||
}/login`}
|
||||
target="blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
className={styles.labelLink}
|
||||
>
|
||||
get one?
|
||||
</a>
|
||||
<span className={styles.tooltip}>
|
||||
Get your API key from your Kutt account settings page
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className={styles.inputWrapper}>
|
||||
<div className={styles.inputIconWrapper}>
|
||||
<Icon
|
||||
className={styles.inputIcon}
|
||||
onClick={(): void => setShowApiKey(!showApiKey)}
|
||||
name={!showApiKey ? 'eye-closed' : 'eye'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="apikey"
|
||||
name="apikey"
|
||||
type={!showApiKey ? 'password' : 'text'}
|
||||
value={formValues.apikey}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
handleApiKeyInputChange(e.target.value.trim());
|
||||
}}
|
||||
spellCheck="false"
|
||||
className={clsx(
|
||||
styles.input,
|
||||
!isUndefined(formValidity.apikey) &&
|
||||
!formValidity.apikey &&
|
||||
styles.inputError
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className={styles.errorText}>{formErrors.apikey}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.validateSection}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting || !isFormValid}
|
||||
onClick={handleApiKeyVerification}
|
||||
className={styles.validateButton}
|
||||
>
|
||||
<span className={styles.validateText}>Validate</span>
|
||||
|
||||
<Icon
|
||||
name={
|
||||
submitting
|
||||
? 'spinner'
|
||||
: (!isNull(errored.error) &&
|
||||
((!errored.error && 'tick') || 'cross')) ||
|
||||
'zap'
|
||||
}
|
||||
className={styles.validateIcon}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{!isNull(errored.error) && (
|
||||
<div
|
||||
className={clsx(
|
||||
styles.validationFeedback,
|
||||
errored.error ? styles.error : styles.success
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={styles.feedbackIcon}
|
||||
name={errored.error ? 'cross' : 'tick'}
|
||||
/>
|
||||
<span className={styles.feedbackMessage}>{errored.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.toggleSection}>
|
||||
<label htmlFor="history" className={styles.toggleLabel}>
|
||||
<span className={styles.toggleTextWithInfo}>
|
||||
<span className={styles.toggleText}>Show Recent Links</span>
|
||||
<span className={styles.infoIcon}>
|
||||
<Icon name="info" />
|
||||
<span className={styles.tooltip}>
|
||||
Enables the History page to view your recent shortened links
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className={styles.toggleWrapper}>
|
||||
<span className={styles.toggleTrack} />
|
||||
<span
|
||||
className={clsx(
|
||||
styles.toggleKnob,
|
||||
formValues.history && styles.active
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id="history"
|
||||
name="history"
|
||||
type="checkbox"
|
||||
checked={formValues.history}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setFormValues((prev) => {
|
||||
return {...prev, history: e.target.checked};
|
||||
});
|
||||
}}
|
||||
className={styles.toggleInput}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label htmlFor="reuse" className={styles.toggleLabel}>
|
||||
<span className={styles.toggleTextWithInfo}>
|
||||
<span className={styles.toggleText}>Reuse Existing URLs</span>
|
||||
<span className={styles.infoIcon}>
|
||||
<Icon name="info" />
|
||||
<span className={styles.tooltip}>
|
||||
Returns the existing short link if the same URL was shortened
|
||||
before
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className={styles.toggleWrapper}>
|
||||
<span className={styles.toggleTrack} />
|
||||
<span
|
||||
className={clsx(
|
||||
styles.toggleKnob,
|
||||
formValues.reuse && styles.active
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id="reuse"
|
||||
name="reuse"
|
||||
type="checkbox"
|
||||
checked={formValues.reuse}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setFormValues((prev) => {
|
||||
return {...prev, reuse: e.target.checked};
|
||||
});
|
||||
}}
|
||||
className={styles.toggleInput}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label htmlFor="advanced" className={styles.toggleLabel}>
|
||||
<span className={styles.toggleTextWithInfo}>
|
||||
<span className={styles.toggleText}>Show Advanced Options</span>
|
||||
<span className={styles.infoIcon}>
|
||||
<Icon name="info" />
|
||||
<span className={styles.tooltip}>
|
||||
Configure a custom self-hosted Kutt instance URL
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className={styles.toggleWrapper}>
|
||||
<span className={styles.toggleTrack} />
|
||||
<span
|
||||
className={clsx(
|
||||
styles.toggleKnob,
|
||||
formValues.advanced && styles.active
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id="advanced"
|
||||
name="advanced"
|
||||
type="checkbox"
|
||||
checked={formValues.advanced}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setFormValues((prev) => {
|
||||
return {...prev, advanced: e.target.checked};
|
||||
});
|
||||
if (e.target.checked) {
|
||||
setTimeout(() => hostInputRef.current?.focus(), 350);
|
||||
}
|
||||
}}
|
||||
className={styles.toggleInput}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
styles.advancedSection,
|
||||
!formValues.advanced && styles.hidden
|
||||
)}
|
||||
>
|
||||
<div className={styles.inputGroup}>
|
||||
<label htmlFor="host" className={styles.label}>
|
||||
<span className={styles.labelWithInfo}>
|
||||
Custom Host
|
||||
<span className={styles.infoIcon}>
|
||||
<Icon name="info" />
|
||||
<span className={styles.tooltip}>
|
||||
URL of your self-hosted Kutt instance (e.g.,
|
||||
https://kutt.example.com)
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className={styles.inputWrapper}>
|
||||
<input
|
||||
ref={hostInputRef}
|
||||
id="host"
|
||||
name="host"
|
||||
type="text"
|
||||
value={formValues.host}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
handleHostUrlInputChange(e.target.value.trim());
|
||||
}}
|
||||
spellCheck="false"
|
||||
className={clsx(
|
||||
styles.input,
|
||||
!isUndefined(formValidity.host) &&
|
||||
!formValidity.host &&
|
||||
styles.inputError
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className={styles.errorText}>{formErrors.host}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.resetSection}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowResetConfirm(true)}
|
||||
className={styles.resetButton}
|
||||
>
|
||||
Reset All Settings
|
||||
</button>
|
||||
<span className={styles.resetHint}>
|
||||
This will clear all your settings and reload the extension
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showResetConfirm && (
|
||||
<div
|
||||
className={styles.modalOverlay}
|
||||
onClick={() => setShowResetConfirm(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setShowResetConfirm(false);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
className={styles.modal}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className={styles.modalHeader}>
|
||||
<Icon name="info" className={styles.modalIcon} />
|
||||
<span className={styles.modalTitle}>Reset Settings?</span>
|
||||
</div>
|
||||
<p className={styles.modalText}>
|
||||
This will permanently delete your API key and all preferences. You
|
||||
will need to reconfigure the extension.
|
||||
</p>
|
||||
<div className={styles.modalActions}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowResetConfirm(false)}
|
||||
className={styles.modalCancelButton}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetSettings}
|
||||
className={styles.modalConfirmButton}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Form;
|
||||
45
source/Options/Header.module.scss
Normal file
@@ -0,0 +1,45 @@
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
.logoContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
transition: opacity $transition-fast;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: $semibold;
|
||||
font-size: 1.75rem;
|
||||
background: $primary-gradient;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: $gray-500;
|
||||
font-weight: $regular;
|
||||
}
|
||||
39
source/Options/Header.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type {JSX} from 'react';
|
||||
import {memo} from 'react';
|
||||
|
||||
import {Kutt} from '../Background';
|
||||
|
||||
import styles from './Header.module.scss';
|
||||
|
||||
type Props = {
|
||||
subtitle?: string;
|
||||
hostUrl?: string;
|
||||
};
|
||||
|
||||
function Header({
|
||||
subtitle = 'Extension Settings',
|
||||
hostUrl = Kutt.hostUrl,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<a
|
||||
href={hostUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.logoContainer}
|
||||
>
|
||||
<img
|
||||
className={styles.logo}
|
||||
width="40"
|
||||
height="40"
|
||||
src="../assets/logo.png"
|
||||
alt="logo"
|
||||
/>
|
||||
<h1 className={styles.title}>Kutt</h1>
|
||||
</a>
|
||||
<p className={styles.subtitle}>{subtitle}</p>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Header);
|
||||
30
source/Options/Options.module.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
.optionsPage {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem 1.5rem;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.optionsContainer {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
padding: 2rem 2.5rem;
|
||||
margin: 1rem;
|
||||
background-color: $white;
|
||||
height: max-content;
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
|
||||
@media (min-width: 640px) {
|
||||
padding: 2.5rem 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.loaderContainer {
|
||||
height: 16rem;
|
||||
}
|
||||
108
source/Options/Options.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import type {JSX} from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
import {getExtensionSettings} from '../util/settings';
|
||||
import {
|
||||
HostProperties,
|
||||
useExtensionSettings,
|
||||
ExtensionSettingsActionTypes,
|
||||
} from '../contexts/extension-settings-context';
|
||||
import {
|
||||
useRequestStatus,
|
||||
RequestStatusActionTypes,
|
||||
} from '../contexts/request-status-context';
|
||||
import {isValidUrl} from '../util/link';
|
||||
import {Kutt} from '../Background';
|
||||
|
||||
import BodyWrapper from '../components/BodyWrapper';
|
||||
import Loader from '../components/Loader';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
import Form from './Form';
|
||||
|
||||
import styles from './Options.module.scss';
|
||||
|
||||
function Options(): JSX.Element {
|
||||
const [, extensionSettingsDispatch] = useExtensionSettings();
|
||||
const [requestStatusState, requestStatusDispatch] = useRequestStatus();
|
||||
const [hostUrl, setHostUrl] = useState<string>(Kutt.hostUrl);
|
||||
|
||||
useEffect(() => {
|
||||
async function getSavedSettings(): Promise<void> {
|
||||
const {settings = {}} = await getExtensionSettings();
|
||||
const advancedSettings: boolean =
|
||||
(settings?.advanced as boolean) || false;
|
||||
|
||||
const defaultHost: HostProperties =
|
||||
(advancedSettings &&
|
||||
(settings?.host as string) &&
|
||||
isValidUrl(settings.host as string) && {
|
||||
hostDomain:
|
||||
(settings.host as string)
|
||||
.replace('http://', '')
|
||||
.replace('https://', '')
|
||||
.replace('www.', '')
|
||||
.split(/[/?#]/)[0] || '', // extract domain
|
||||
hostUrl: (settings.host as string).endsWith('/')
|
||||
? (settings.host as string).slice(0, -1)
|
||||
: (settings.host as string), // slice `/` at the end
|
||||
}) ||
|
||||
Kutt;
|
||||
|
||||
// inject existing keys (if field doesn't exist, use default)
|
||||
// For history: default to true for new users, but respect existing user preference
|
||||
const historyEnabled = Object.prototype.hasOwnProperty.call(
|
||||
settings,
|
||||
'history'
|
||||
)
|
||||
? (settings.history as boolean)
|
||||
: true;
|
||||
|
||||
const defaultExtensionConfig = {
|
||||
apikey: (settings?.apikey as string)?.trim() || '',
|
||||
history: historyEnabled,
|
||||
advanced:
|
||||
defaultHost.hostUrl.trim() !== Kutt.hostUrl && advancedSettings, // disable `advanced` if customhost is not set
|
||||
host: defaultHost,
|
||||
reuse: (settings?.reuse as boolean) || false,
|
||||
};
|
||||
|
||||
setHostUrl(defaultExtensionConfig.host.hostUrl);
|
||||
|
||||
extensionSettingsDispatch({
|
||||
type: ExtensionSettingsActionTypes.HYDRATE_EXTENSION_SETTINGS,
|
||||
payload: defaultExtensionConfig,
|
||||
});
|
||||
requestStatusDispatch({
|
||||
type: RequestStatusActionTypes.SET_LOADING,
|
||||
payload: false,
|
||||
});
|
||||
}
|
||||
|
||||
getSavedSettings();
|
||||
}, [extensionSettingsDispatch, requestStatusDispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BodyWrapper>
|
||||
<div id="options" className={styles.optionsPage}>
|
||||
<div className={styles.optionsContainer}>
|
||||
<Header hostUrl={hostUrl} />
|
||||
|
||||
{!requestStatusState.loading ? (
|
||||
<Form />
|
||||
) : (
|
||||
<div className={styles.loaderContainer}>
|
||||
<Loader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</BodyWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Options;
|
||||
24
source/Options/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
|
||||
import {ExtensionSettingsProvider} from '../contexts/extension-settings-context';
|
||||
import {RequestStatusProvider} from '../contexts/request-status-context';
|
||||
import Options from './Options';
|
||||
|
||||
import '../styles/main.scss';
|
||||
|
||||
const container = document.getElementById('options-root');
|
||||
if (!container) {
|
||||
throw new Error('Could not find options-root container');
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<ExtensionSettingsProvider>
|
||||
<RequestStatusProvider>
|
||||
<Options />
|
||||
</RequestStatusProvider>
|
||||
</ExtensionSettingsProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
12
source/Options/options.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Options: Kutt</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="options-root"></div>
|
||||
<script type="module" src="./index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
298
source/Popup/Form.module.scss
Normal file
@@ -0,0 +1,298 @@
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
.formContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
padding: 1rem;
|
||||
margin: 0 auto;
|
||||
background-color: $white;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.formGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.formGroupRelative {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 0.75rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.025em;
|
||||
color: $gray-600;
|
||||
|
||||
@media (min-width: 640px) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.labelAbsolute {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.025em;
|
||||
color: $gray-600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdownTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
background-color: $white;
|
||||
border: 1.5px solid $gray-300;
|
||||
border-radius: $radius-md;
|
||||
cursor: pointer;
|
||||
transition: border-color $transition-fast, box-shadow $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: $gray-400;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $blue-500;
|
||||
box-shadow: 0 0 0 3px rgba($blue-500, 0.1);
|
||||
}
|
||||
|
||||
&.open {
|
||||
border-color: $blue-500;
|
||||
box-shadow: 0 0 0 3px rgba($blue-500, 0.1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
background-color: $gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdownValue {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: $gray-500;
|
||||
|
||||
&.hasValue {
|
||||
color: $gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdownIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 0.5rem;
|
||||
color: $gray-400;
|
||||
transition: transform $transition-fast, color $transition-fast;
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdownMenu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
background-color: $white;
|
||||
border-radius: $radius-md;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
|
||||
animation: dropdownFade $transition-fast ease-out;
|
||||
}
|
||||
|
||||
@keyframes dropdownFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdownItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
color: $gray-700;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color $transition-fast;
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: $radius-md;
|
||||
border-top-right-radius: $radius-md;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom-left-radius: $radius-md;
|
||||
border-bottom-right-radius: $radius-md;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-200;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
color: $gray-700;
|
||||
font-weight: $medium;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
background-color: $white;
|
||||
border: 1.5px solid $gray-300;
|
||||
border-radius: $radius-md;
|
||||
margin-top: 1.2rem;
|
||||
transition: border-color $transition-fast, box-shadow $transition-fast;
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: $gray-400;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $blue-500;
|
||||
box-shadow: 0 0 0 3px rgba($blue-500, 0.1);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.inputError {
|
||||
border-color: $red-500;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 0.25rem;
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: $medium;
|
||||
letter-spacing: 0.025em;
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.passwordWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.passwordToggle {
|
||||
position: absolute;
|
||||
top: 1.2rem;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 2.5rem;
|
||||
}
|
||||
|
||||
.passwordToggleIcon {
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px 0 0 4px;
|
||||
cursor: pointer;
|
||||
color: $gray-400;
|
||||
transition: color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $gray-600;
|
||||
}
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: $semibold;
|
||||
text-align: center;
|
||||
color: $white;
|
||||
background: $primary-gradient;
|
||||
border: none;
|
||||
border-radius: $radius-md;
|
||||
box-shadow: 0 4px 6px -1px rgba(126, 87, 194, 0.3);
|
||||
min-height: 36px;
|
||||
cursor: pointer;
|
||||
transition: transform $transition-fast, box-shadow $transition-fast;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 10px -1px rgba(126, 87, 194, 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.createIcon {
|
||||
display: inline-flex;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
|
||||
svg {
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
transition: transform $transition-normal;
|
||||
}
|
||||
}
|
||||
332
source/Popup/Form.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import type {JSX} from 'react';
|
||||
import {useState, useRef, useEffect, type ChangeEvent} from 'react';
|
||||
import {EMPTY_STRING, isEmpty, isNull, get} from '@abhijithvijayan/ts-utils';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import {useExtensionSettings} from '../contexts/extension-settings-context';
|
||||
import {SHORTEN_URL} from '../Background/constants';
|
||||
import messageUtil from '../util/messageUtil';
|
||||
import {getCurrentTab} from '../util/tabs';
|
||||
import {
|
||||
RequestStatusActionTypes,
|
||||
useRequestStatus,
|
||||
} from '../contexts/request-status-context';
|
||||
import {isValidUrl, removeProtocol} from '../util/link';
|
||||
import {
|
||||
SuccessfulShortenStatusProperties,
|
||||
ShortUrlActionBodyProperties,
|
||||
ApiErroredProperties,
|
||||
ApiBodyProperties,
|
||||
} from '../Background';
|
||||
|
||||
import Icon from '../components/Icon';
|
||||
import styles from './Form.module.scss';
|
||||
|
||||
export enum CONSTANTS {
|
||||
DefaultDomainId = 'default',
|
||||
}
|
||||
|
||||
function Form(): JSX.Element {
|
||||
const extensionSettingsState = useExtensionSettings()[0];
|
||||
const requestStatusDispatch = useRequestStatus()[1];
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
domainOptions,
|
||||
host: {hostDomain},
|
||||
} = extensionSettingsState;
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent): void {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return (): void =>
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const [formState, setFormState] = useState({
|
||||
domain:
|
||||
domainOptions
|
||||
.find(({id}) => id === CONSTANTS.DefaultDomainId)
|
||||
?.value?.trim() || EMPTY_STRING,
|
||||
customurl: '',
|
||||
password: '',
|
||||
});
|
||||
const [formErrors, setFormErrors] = useState<{
|
||||
customurl?: string;
|
||||
password?: string;
|
||||
}>({});
|
||||
|
||||
const isFormValid: boolean =
|
||||
!formErrors.customurl && !formErrors.password && true;
|
||||
|
||||
async function handleFormSubmit(): Promise<void> {
|
||||
// enable loading screen
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Get target link to shorten
|
||||
const tabs = await getCurrentTab();
|
||||
const target: string | null = get(tabs, '[0].url', null);
|
||||
const shouldSubmit: boolean = !isNull(target) && isValidUrl(target);
|
||||
|
||||
if (!shouldSubmit) {
|
||||
setIsSubmitting(false);
|
||||
requestStatusDispatch({
|
||||
type: RequestStatusActionTypes.SET_REQUEST_STATUS,
|
||||
payload: {
|
||||
error: true,
|
||||
message: 'Not a valid URL',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const apiBody: ApiBodyProperties = {
|
||||
apikey: extensionSettingsState.apikey,
|
||||
target: target!,
|
||||
...(formState.customurl.trim() !== EMPTY_STRING && {
|
||||
customurl: formState.customurl.trim(),
|
||||
}),
|
||||
...(!isEmpty(formState.password) && {password: formState.password}),
|
||||
reuse: extensionSettingsState.reuse,
|
||||
...(formState.domain.trim() !== EMPTY_STRING && {
|
||||
domain: formState.domain.trim(),
|
||||
}),
|
||||
};
|
||||
|
||||
const apiShortenUrlBody: ShortUrlActionBodyProperties = {
|
||||
apiBody,
|
||||
hostUrl: extensionSettingsState.host.hostUrl,
|
||||
};
|
||||
|
||||
// shorten url in the background
|
||||
const response: SuccessfulShortenStatusProperties | ApiErroredProperties =
|
||||
await messageUtil.send(SHORTEN_URL, apiShortenUrlBody);
|
||||
|
||||
// disable spinner
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (!response.error) {
|
||||
const {
|
||||
data: {link},
|
||||
} = response;
|
||||
// show shortened url
|
||||
requestStatusDispatch({
|
||||
type: RequestStatusActionTypes.SET_REQUEST_STATUS,
|
||||
payload: {
|
||||
error: false,
|
||||
message: link,
|
||||
},
|
||||
});
|
||||
// reset form fields (keep domain selection)
|
||||
setFormState((prev) => {
|
||||
return {...prev, customurl: '', password: ''};
|
||||
});
|
||||
setFormErrors({});
|
||||
} else {
|
||||
// errored
|
||||
requestStatusDispatch({
|
||||
type: RequestStatusActionTypes.SET_REQUEST_STATUS,
|
||||
payload: {
|
||||
error: true,
|
||||
message: response.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleCustomUrlInputChange(url: string): void {
|
||||
setFormState((prev) => {
|
||||
return {...prev, customurl: url};
|
||||
});
|
||||
|
||||
if (url.length > 0 && url.length < 3) {
|
||||
setFormErrors((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
customurl: 'Custom URL must be at-least 3 characters',
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setFormErrors((prev) => {
|
||||
return {...prev, customurl: undefined};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handlePasswordInputChange(password: string): void {
|
||||
setFormState((prev) => {
|
||||
return {...prev, password};
|
||||
});
|
||||
|
||||
if (password.length > 0 && password.length < 3) {
|
||||
setFormErrors((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
password: 'Password must be at-least 3 characters',
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setFormErrors((prev) => {
|
||||
return {...prev, password: undefined};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.formContainer}>
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.label}>Domain</label>
|
||||
|
||||
<div className={styles.dropdown} ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
styles.dropdownTrigger,
|
||||
isDropdownOpen && styles.open
|
||||
)}
|
||||
onClick={() => !isSubmitting && setIsDropdownOpen(!isDropdownOpen)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
styles.dropdownValue,
|
||||
formState.domain && styles.hasValue
|
||||
)}
|
||||
>
|
||||
{domainOptions.find(({value}) => value === formState.domain)
|
||||
?.option ||
|
||||
(formState.domain && removeProtocol(formState.domain)) ||
|
||||
'Select domain'}
|
||||
</span>
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
className={clsx(
|
||||
styles.dropdownIcon,
|
||||
isDropdownOpen && styles.open
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div className={styles.dropdownMenu}>
|
||||
{domainOptions.filter(({disabled}) => !disabled).length > 0 ? (
|
||||
domainOptions
|
||||
.filter(({disabled}) => !disabled)
|
||||
.map(({id, option, value}) => (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
className={clsx(
|
||||
styles.dropdownItem,
|
||||
formState.domain === value && styles.selected
|
||||
)}
|
||||
onClick={() => {
|
||||
setFormState((prev) => {
|
||||
return {...prev, domain: value};
|
||||
});
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{option || removeProtocol(value)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<span className={styles.dropdownItem}>
|
||||
No domains available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroupRelative}>
|
||||
<label htmlFor="customurl" className={styles.labelAbsolute}>
|
||||
<span>{hostDomain}/</span>
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="customurl"
|
||||
name="customurl"
|
||||
type="text"
|
||||
value={formState.customurl}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
handleCustomUrlInputChange(e.target.value.trim());
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
spellCheck="false"
|
||||
className={clsx(
|
||||
styles.input,
|
||||
formErrors.customurl && styles.inputError
|
||||
)}
|
||||
/>
|
||||
|
||||
<span className={styles.errorText}>{formErrors.customurl}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroupRelative}>
|
||||
<label htmlFor="password" className={styles.labelAbsolute}>
|
||||
<span>Password</span>
|
||||
</label>
|
||||
|
||||
<div className={styles.passwordWrapper}>
|
||||
<div className={styles.passwordToggle}>
|
||||
<Icon
|
||||
onClick={(): void => {
|
||||
if (!isSubmitting) {
|
||||
setShowPassword(!showPassword);
|
||||
}
|
||||
}}
|
||||
name={!showPassword ? 'eye-closed' : 'eye'}
|
||||
className={styles.passwordToggleIcon}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={!showPassword ? 'password' : 'text'}
|
||||
value={formState.password}
|
||||
spellCheck="false"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
handlePasswordInputChange(e.target.value);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className={clsx(
|
||||
styles.input,
|
||||
formErrors.password && styles.inputError
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className={styles.errorText}>{formErrors.password}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isFormValid || isSubmitting}
|
||||
onClick={handleFormSubmit}
|
||||
className={styles.submitButton}
|
||||
>
|
||||
{!isSubmitting ? (
|
||||
<span>Create</span>
|
||||
) : (
|
||||
<Icon className={styles.createIcon} name="spinner" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Form;
|
||||
95
source/Popup/Header.module.scss
Normal file
@@ -0,0 +1,95 @@
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.logoLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
transition: opacity $transition-fast;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
|
||||
&:hover .tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.styledIcon {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
color: $gray-400;
|
||||
transition: color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $gray-600;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: calc(100% + 0.375rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: $medium;
|
||||
white-space: nowrap;
|
||||
color: $white;
|
||||
background-color: $gray-800;
|
||||
border-radius: $radius-sm;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||
transition: opacity $transition-fast, visibility $transition-fast;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 0.25rem;
|
||||
border-style: solid;
|
||||
border-color: transparent transparent $gray-800 transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// Position the last tooltip (Settings) to the right to prevent overflow
|
||||
.iconWrapper:last-child .tooltip {
|
||||
left: auto;
|
||||
right: 0;
|
||||
transform: none;
|
||||
|
||||
&::before {
|
||||
left: auto;
|
||||
right: 0.5rem;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
134
source/Popup/Header.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import {isNull, EMPTY_STRING} from '@abhijithvijayan/ts-utils';
|
||||
import type {JSX} from 'react';
|
||||
import {useState, useRef, useEffect} from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import {openExtOptionsPage, openHistoryPage} from '../util/tabs';
|
||||
import {updateExtensionSettings} from '../util/settings';
|
||||
import {CHECK_API_KEY} from '../Background/constants';
|
||||
import {
|
||||
ExtensionSettingsActionTypes,
|
||||
useExtensionSettings,
|
||||
} from '../contexts/extension-settings-context';
|
||||
import messageUtil from '../util/messageUtil';
|
||||
import {
|
||||
SuccessfulApiKeyCheckProperties,
|
||||
AuthRequestBodyProperties,
|
||||
ApiErroredProperties,
|
||||
ErrorStateProperties,
|
||||
} from '../Background';
|
||||
|
||||
import Icon from '../components/Icon';
|
||||
import styles from './Header.module.scss';
|
||||
|
||||
function Header(): JSX.Element {
|
||||
const [extensionSettingsState, extensionSettingsDispatch] =
|
||||
useExtensionSettings();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [errored, setErrored] = useState<ErrorStateProperties>({
|
||||
error: null,
|
||||
message: EMPTY_STRING,
|
||||
});
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
async function fetchUserDomains(): Promise<void> {
|
||||
setLoading(true);
|
||||
|
||||
const apiKeyValidationBody: AuthRequestBodyProperties = {
|
||||
apikey: extensionSettingsState.apikey,
|
||||
hostUrl: extensionSettingsState.host.hostUrl,
|
||||
};
|
||||
|
||||
const response: SuccessfulApiKeyCheckProperties | ApiErroredProperties =
|
||||
await messageUtil.send(CHECK_API_KEY, apiKeyValidationBody);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!response.error) {
|
||||
setErrored({error: false, message: 'Fetching domains successful'});
|
||||
const {domains, email} = response.data;
|
||||
await updateExtensionSettings({user: {domains, email}});
|
||||
} else {
|
||||
setErrored({error: true, message: response.message});
|
||||
await updateExtensionSettings({user: null});
|
||||
}
|
||||
|
||||
extensionSettingsDispatch({
|
||||
type: ExtensionSettingsActionTypes.RELOAD_EXTENSION_SETTINGS,
|
||||
payload: !extensionSettingsState.reload,
|
||||
});
|
||||
|
||||
// Clear any existing timer before setting a new one
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
setErrored({error: null, message: EMPTY_STRING});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
const iconToShow = loading
|
||||
? 'spinner'
|
||||
: (!isNull(errored.error) && (!errored.error ? 'tick' : 'cross')) ||
|
||||
'refresh';
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<a
|
||||
href={extensionSettingsState.host.hostUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.logoLink}
|
||||
>
|
||||
<img
|
||||
className={styles.logo}
|
||||
width="32"
|
||||
height="32"
|
||||
src="../assets/logo.png"
|
||||
alt="logo"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<span className={styles.iconWrapper}>
|
||||
<Icon
|
||||
onClick={fetchUserDomains}
|
||||
name={iconToShow}
|
||||
className={clsx('icon', styles.styledIcon)}
|
||||
/>
|
||||
<span className={styles.tooltip}>Sync account</span>
|
||||
</span>
|
||||
{extensionSettingsState.history && (
|
||||
<span className={styles.iconWrapper}>
|
||||
<Icon
|
||||
onClick={openHistoryPage}
|
||||
name="clock"
|
||||
className={clsx('icon', styles.styledIcon)}
|
||||
/>
|
||||
<span className={styles.tooltip}>History</span>
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.iconWrapper}>
|
||||
<Icon
|
||||
onClick={openExtOptionsPage}
|
||||
name="settings"
|
||||
className={clsx('icon', styles.styledIcon)}
|
||||
/>
|
||||
<span className={styles.tooltip}>Settings</span>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
7
source/Popup/Popup.module.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
.popup {
|
||||
font-size: 1.125rem;
|
||||
min-height: 350px;
|
||||
min-width: 270px;
|
||||
}
|
||||
185
source/Popup/Popup.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import {isNull, EMPTY_STRING} from '@abhijithvijayan/ts-utils';
|
||||
import type {JSX} from 'react';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
import {Kutt, UserSettingsResponseProperties} from '../Background';
|
||||
import {openExtOptionsPage} from '../util/tabs';
|
||||
import {isValidUrl} from '../util/link';
|
||||
|
||||
import {
|
||||
ExtensionSettingsActionTypes,
|
||||
DomainOptionsProperties,
|
||||
useExtensionSettings,
|
||||
HostProperties,
|
||||
} from '../contexts/extension-settings-context';
|
||||
import {
|
||||
RequestStatusActionTypes,
|
||||
useRequestStatus,
|
||||
} from '../contexts/request-status-context';
|
||||
import {getExtensionSettings} from '../util/settings';
|
||||
|
||||
import BodyWrapper from '../components/BodyWrapper';
|
||||
import ResponseBody from './ResponseBody';
|
||||
import PopupHeader from './Header';
|
||||
import Loader from '../components/Loader';
|
||||
import Form, {CONSTANTS} from './Form';
|
||||
|
||||
import styles from './Popup.module.scss';
|
||||
|
||||
function Popup(): JSX.Element {
|
||||
const [extensionSettingsState, extensionSettingsDispatch] =
|
||||
useExtensionSettings();
|
||||
const [requestStatusState, requestStatusDispatch] = useRequestStatus();
|
||||
const {reload: liveReloadFlag} = extensionSettingsState;
|
||||
|
||||
// re-renders on `liveReloadFlag` change
|
||||
useEffect((): void => {
|
||||
async function getUserSettings(): Promise<void> {
|
||||
const {settings = {}} = await getExtensionSettings();
|
||||
|
||||
// No API Key set
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(settings, 'apikey') ||
|
||||
(settings.apikey as string) === EMPTY_STRING
|
||||
) {
|
||||
requestStatusDispatch({
|
||||
type: RequestStatusActionTypes.SET_REQUEST_STATUS,
|
||||
payload: {
|
||||
error: true,
|
||||
message: 'Extension requires an API Key to work',
|
||||
},
|
||||
});
|
||||
requestStatusDispatch({
|
||||
type: RequestStatusActionTypes.SET_LOADING,
|
||||
payload: false,
|
||||
});
|
||||
|
||||
// Open options page
|
||||
setTimeout(() => openExtOptionsPage(), 1300);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let defaultHost: HostProperties = Kutt;
|
||||
|
||||
// If `advanced` field is true
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(settings, 'advanced') &&
|
||||
(settings.advanced as boolean)
|
||||
) {
|
||||
// If `host` field is set
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(settings, 'host') &&
|
||||
(settings.host as string)?.trim().length > 0 &&
|
||||
isValidUrl(settings.host as string)
|
||||
) {
|
||||
defaultHost = {
|
||||
hostDomain:
|
||||
(settings.host as string)
|
||||
.replace('http://', EMPTY_STRING)
|
||||
.replace('https://', EMPTY_STRING)
|
||||
.replace('www.', EMPTY_STRING)
|
||||
.split(/[/?#]/)[0] || EMPTY_STRING,
|
||||
hostUrl: (settings.host as string).endsWith('/')
|
||||
? (settings.host as string).slice(0, -1)
|
||||
: (settings.host as string),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// `history` field set - default to true for new users
|
||||
let historyEnabled = true;
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'history')) {
|
||||
historyEnabled = settings.history as boolean;
|
||||
}
|
||||
|
||||
// options menu
|
||||
const defaultOptions: DomainOptionsProperties[] = [
|
||||
{
|
||||
id: EMPTY_STRING,
|
||||
option: '-- Choose Domain --',
|
||||
value: EMPTY_STRING,
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: CONSTANTS.DefaultDomainId,
|
||||
option: defaultHost.hostDomain,
|
||||
value: defaultHost.hostDomain,
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
// `user` & `apikey` fields exist on storage
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(settings, 'user') &&
|
||||
(settings.user as UserSettingsResponseProperties)
|
||||
) {
|
||||
const {user}: {user: UserSettingsResponseProperties} = settings;
|
||||
|
||||
let optionsList: DomainOptionsProperties[] = user.domains.map(
|
||||
({id, address, homepage, banned}) => {
|
||||
return {
|
||||
id,
|
||||
option: homepage,
|
||||
value: address,
|
||||
disabled: banned,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// merge to beginning of array
|
||||
optionsList = defaultOptions.concat(optionsList);
|
||||
|
||||
// update domain list
|
||||
extensionSettingsDispatch({
|
||||
type: ExtensionSettingsActionTypes.HYDRATE_EXTENSION_SETTINGS,
|
||||
payload: {
|
||||
apikey: (settings.apikey as string)?.trim(),
|
||||
domainOptions: optionsList,
|
||||
host: defaultHost,
|
||||
history: historyEnabled,
|
||||
reuse: (settings.reuse as boolean) || false,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// no `user` but `apikey` exist on storage
|
||||
extensionSettingsDispatch({
|
||||
type: ExtensionSettingsActionTypes.HYDRATE_EXTENSION_SETTINGS,
|
||||
payload: {
|
||||
apikey: (settings.apikey as string)?.trim(),
|
||||
domainOptions: defaultOptions,
|
||||
host: defaultHost,
|
||||
history: historyEnabled,
|
||||
reuse: (settings.reuse as boolean) || false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// stop loader
|
||||
requestStatusDispatch({
|
||||
type: RequestStatusActionTypes.SET_LOADING,
|
||||
payload: false,
|
||||
});
|
||||
}
|
||||
|
||||
getUserSettings();
|
||||
}, [liveReloadFlag, extensionSettingsDispatch, requestStatusDispatch]);
|
||||
|
||||
return (
|
||||
<BodyWrapper>
|
||||
<div id="popup" className={styles.popup}>
|
||||
{!requestStatusState.loading ? (
|
||||
<>
|
||||
<PopupHeader />
|
||||
{!isNull(requestStatusState.error) && <ResponseBody />}
|
||||
<Form />
|
||||
</>
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
</div>
|
||||
</BodyWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default Popup;
|
||||
62
source/Popup/ResponseBody.module.scss
Normal file
@@ -0,0 +1,62 @@
|
||||
@use '../styles/variables' as *;
|
||||
|
||||
.popupBody {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem 1rem 0;
|
||||
|
||||
.icon {
|
||||
cursor: pointer;
|
||||
transition: opacity $transition-fast;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
svg {
|
||||
stroke: $green-500;
|
||||
stroke-width: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qrIcon {
|
||||
margin: 0;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
.copyIcon {
|
||||
margin: 0;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.link {
|
||||
border-bottom: 1px dotted $stats-total-underline;
|
||||
padding-bottom: 2px;
|
||||
color: $gray-700;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: $light;
|
||||
cursor: pointer;
|
||||
transition: color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
padding-top: 0.25rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: $medium;
|
||||
color: $red-500;
|
||||
}
|
||||
|
||||
.qrCodeContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
padding: 1rem 0 0;
|
||||
}
|
||||
82
source/Popup/ResponseBody.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||
import type {JSX} from 'react';
|
||||
import {useState, useEffect} from 'react';
|
||||
import {QRCodeSVG} from 'qrcode.react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import {useRequestStatus} from '../contexts/request-status-context';
|
||||
import {removeProtocol} from '../util/link';
|
||||
import Icon from '../components/Icon';
|
||||
|
||||
import styles from './ResponseBody.module.scss';
|
||||
|
||||
function ResponseBody(): JSX.Element {
|
||||
const [{error, message}] = useRequestStatus();
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
const [QRView, setQRView] = useState<boolean>(false);
|
||||
|
||||
// reset copy message
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
timer = setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1300);
|
||||
|
||||
return (): void => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [copied]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.popupBody}>
|
||||
{!error ? (
|
||||
<>
|
||||
<Icon
|
||||
className={clsx(styles.icon, styles.qrIcon)}
|
||||
name="qrcode"
|
||||
onClick={(): void => setQRView(!QRView)}
|
||||
/>
|
||||
|
||||
{!copied ? (
|
||||
<CopyToClipboard
|
||||
text={message}
|
||||
onCopy={(): void => setCopied(true)}
|
||||
>
|
||||
<Icon
|
||||
className={clsx(styles.icon, styles.copyIcon)}
|
||||
name="copy"
|
||||
/>
|
||||
</CopyToClipboard>
|
||||
) : (
|
||||
<Icon
|
||||
className={clsx(styles.icon, styles.copyIcon)}
|
||||
name="tick"
|
||||
/>
|
||||
)}
|
||||
|
||||
<CopyToClipboard
|
||||
text={message}
|
||||
onCopy={(): void => setCopied(true)}
|
||||
>
|
||||
<h1 className={styles.link}>{removeProtocol(message)}</h1>
|
||||
</CopyToClipboard>
|
||||
</>
|
||||
) : (
|
||||
<p className={styles.errorMessage}>{message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!error && QRView && (
|
||||
<div className={styles.qrCodeContainer}>
|
||||
<QRCodeSVG size={128} value={message} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResponseBody;
|
||||
24
source/Popup/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
|
||||
import {ExtensionSettingsProvider} from '../contexts/extension-settings-context';
|
||||
import {RequestStatusProvider} from '../contexts/request-status-context';
|
||||
import Popup from './Popup';
|
||||
|
||||
import '../styles/main.scss';
|
||||
|
||||
const container = document.getElementById('popup-root');
|
||||
if (!container) {
|
||||
throw new Error('Could not find popup-root container');
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<ExtensionSettingsProvider>
|
||||
<RequestStatusProvider>
|
||||
<Popup />
|
||||
</RequestStatusProvider>
|
||||
</ExtensionSettingsProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
12
source/Popup/popup.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=500" />
|
||||
<title>Kutt</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="popup-root"></div>
|
||||
<script type="module" src="./index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3
source/components/BodyWrapper.module.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
12
source/components/BodyWrapper.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import type {JSX, ReactNode} from 'react';
|
||||
import styles from './BodyWrapper.module.scss';
|
||||
|
||||
type WrapperProperties = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function BodyWrapper({children}: WrapperProperties): JSX.Element {
|
||||
return <div className={styles.wrapper}>{children}</div>;
|
||||
}
|
||||
|
||||
export default BodyWrapper;
|
||||
18
source/components/Icon/ChevronDown.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
const ChevronDown: React.FC = () => (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default React.memo(ChevronDown);
|
||||
20
source/components/Icon/Clock.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
const Clock: React.FC = () => (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="clock_svg__feather clock_svg__feather-clock"
|
||||
>
|
||||
<circle cx={12} cy={12} r={10} />
|
||||
<path d="M12 6v6l4 2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default React.memo(Clock);
|
||||
20
source/components/Icon/Copy.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
const Copy: React.FC = () => (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="copy_svg__feather copy_svg__feather-copy"
|
||||
>
|
||||
<rect x={9} y={9} width={13} height={13} rx={2} ry={2} />
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default React.memo(Copy);
|
||||
19
source/components/Icon/Cross.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
const Cross: React.FC = () => (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="x_svg__feather x_svg__feather-x"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default React.memo(Cross);
|
||||
20
source/components/Icon/Eye.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
const Eye: React.FC = () => (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="eye_svg__feather eye_svg__feather-eye"
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx={12} cy={12} r={3} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default React.memo(Eye);
|
||||
19
source/components/Icon/EyeClosed.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
const EyeClosed: React.FC = () => (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="eye-off_svg__feather eye-off_svg__feather-eye-off"
|
||||
>
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24M1 1l22 22" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default React.memo(EyeClosed);
|
||||
55
source/components/Icon/Icon.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
|
||||
import ChevronDownIcon from './ChevronDown';
|
||||
import StarYellowIcon from './StarYellow';
|
||||
import EyeClosedIcon from './EyeClosed';
|
||||
import StarWhiteIcon from './StarWhite';
|
||||
import SettingsIcon from './Settings';
|
||||
import RefreshIcon from './Refresh';
|
||||
import SpinnerIcon from './Spinner';
|
||||
import QRCodeIcon from './QRCode';
|
||||
import CrossIcon from './Cross';
|
||||
import ClockIcon from './Clock';
|
||||
import CopyIcon from './Copy';
|
||||
import TickIcon from './Tick';
|
||||
import InfoIcon from './Info';
|
||||
import ZapIcon from './Zap';
|
||||
import EyeIcon from './Eye';
|
||||
|
||||
const icons = {
|
||||
'chevron-down': ChevronDownIcon,
|
||||
clock: ClockIcon,
|
||||
copy: CopyIcon,
|
||||
cross: CrossIcon,
|
||||
eye: EyeIcon,
|
||||
'eye-closed': EyeClosedIcon,
|
||||
info: InfoIcon,
|
||||
qrcode: QRCodeIcon,
|
||||
refresh: RefreshIcon,
|
||||
settings: SettingsIcon,
|
||||
spinner: SpinnerIcon,
|
||||
'star-yellow': StarYellowIcon,
|
||||
'star-white': StarWhiteIcon,
|
||||
tick: TickIcon,
|
||||
zap: ZapIcon,
|
||||
};
|
||||
|
||||
export type Icons = keyof typeof icons;
|
||||
|
||||
type Props = {
|
||||
name: Icons;
|
||||
title?: string;
|
||||
stroke?: string;
|
||||
fill?: string;
|
||||
hoverFill?: string;
|
||||
hoverStroke?: string;
|
||||
strokeWidth?: string;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const Icon: React.FC<Props> = ({name, ...rest}) => (
|
||||
<div {...rest}>{React.createElement(icons[name])}</div>
|
||||
);
|
||||
|
||||
export default Icon;
|
||||
20
source/components/Icon/Info.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
const Info: React.FC = () => (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default React.memo(Info);
|
||||
19
source/components/Icon/QRCode.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
const QRCode: React.FC = () => (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="maximize_svg__feather maximize_svg__feather-maximize"
|
||||
>
|
||||
<path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default React.memo(QRCode);
|
||||
20
source/components/Icon/Refresh.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
const Refresh: React.FC = () => (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="refresh-ccw_svg__feather refresh-ccw_svg__feather-refresh-ccw"
|
||||
>
|
||||
<path d="M1 4v6h6M23 20v-6h-6" />
|
||||
<path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default React.memo(Refresh);
|
||||
20
source/components/Icon/Settings.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
const Settings: React.FC = () => (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="settings_svg__feather settings_svg__feather-settings"
|
||||
>
|
||||
<circle cx={12} cy={12} r={3} />
|
||||
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default React.memo(Settings);
|
||||
30
source/components/Icon/Spinner.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
const Spinner: React.FC = () => (
|
||||
<>
|
||||
<svg
|
||||
id="spinner"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
stroke="#b8b8b8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
className="feather feather-loader"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 2L12 6" />
|
||||
<path d="M12 18L12 22" />
|
||||
<path d="M4.93 4.93L7.76 7.76" />
|
||||
<path d="M16.24 16.24L19.07 19.07" />
|
||||
<path d="M2 12L6 12" />
|
||||
<path d="M18 12L22 12" />
|
||||
<path d="M4.93 19.07L7.76 16.24" />
|
||||
<path d="M16.24 7.76L19.07 4.93" />
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
|
||||
export default React.memo(Spinner);
|
||||
19
source/components/Icon/StarWhite.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
const StarWhite: React.FC = () => (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="star_svg__feather star_svg__feather-star"
|
||||
>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default React.memo(StarWhite);
|
||||
19
source/components/Icon/StarYellow.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
const StarYellow: React.FC = () => (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="#ecc94b"
|
||||
stroke="#ecc94b"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="star_svg__feather star_svg__feather-star"
|
||||
>
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default React.memo(StarYellow);
|
||||
19
source/components/Icon/Tick.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
const Tick: React.FC = () => (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="check_svg__feather check_svg__feather-check"
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default React.memo(Tick);
|
||||
19
source/components/Icon/Zap.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
const Zap: React.FC = () => (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="zap_svg__feather zap_svg__feather-zap"
|
||||
>
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default React.memo(Zap);
|
||||
1
source/components/Icon/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {default} from './Icon';
|
||||
10
source/components/Loader.module.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.loader {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
13
source/components/Loader.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type {JSX} from 'react';
|
||||
import Icon from './Icon';
|
||||
import styles from './Loader.module.scss';
|
||||
|
||||
function Loader(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.loader}>
|
||||
<Icon name="spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Loader;
|
||||
143
source/contexts/extension-settings-context.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import type {JSX} from 'react';
|
||||
import {createContext, useReducer, useContext, type ReactNode} from 'react';
|
||||
|
||||
import {Kutt} from '../Background';
|
||||
|
||||
export enum ExtensionSettingsActionTypes {
|
||||
HYDRATE_EXTENSION_SETTINGS = 'set-extension-settings',
|
||||
RELOAD_EXTENSION_SETTINGS = 'reload-extension-settings',
|
||||
}
|
||||
|
||||
export type HostProperties = {
|
||||
hostDomain: string;
|
||||
hostUrl: string;
|
||||
};
|
||||
|
||||
export type DomainOptionsProperties = {
|
||||
option: string;
|
||||
value: string;
|
||||
id: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type HYDRATE_EXTENSION_SETTINGS = {
|
||||
type: ExtensionSettingsActionTypes.HYDRATE_EXTENSION_SETTINGS;
|
||||
payload:
|
||||
| {
|
||||
apikey: string;
|
||||
domainOptions: DomainOptionsProperties[];
|
||||
host: HostProperties;
|
||||
history: boolean;
|
||||
reuse: boolean;
|
||||
}
|
||||
| {
|
||||
apikey: string;
|
||||
host: HostProperties;
|
||||
history: boolean;
|
||||
advanced: boolean;
|
||||
reuse: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type RELOAD_EXTENSION_SETTINGS = {
|
||||
type: ExtensionSettingsActionTypes.RELOAD_EXTENSION_SETTINGS;
|
||||
payload: boolean;
|
||||
};
|
||||
|
||||
type Action = HYDRATE_EXTENSION_SETTINGS | RELOAD_EXTENSION_SETTINGS;
|
||||
|
||||
type InitialValues = {
|
||||
apikey: string;
|
||||
domainOptions: DomainOptionsProperties[];
|
||||
host: HostProperties;
|
||||
reload: boolean;
|
||||
history: boolean;
|
||||
advanced: boolean;
|
||||
reuse: boolean;
|
||||
};
|
||||
|
||||
const initialValues: InitialValues = {
|
||||
apikey: '',
|
||||
domainOptions: [],
|
||||
host: Kutt,
|
||||
reload: false,
|
||||
history: true,
|
||||
advanced: false,
|
||||
reuse: false,
|
||||
};
|
||||
|
||||
type State = InitialValues;
|
||||
type Dispatch = (action: Action) => void;
|
||||
|
||||
const ExtensionSettingsStateContext = createContext<State | undefined>(
|
||||
undefined
|
||||
);
|
||||
const ExtensionSettingsDispatchContext = createContext<Dispatch | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
function extensionSettingsReducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case ExtensionSettingsActionTypes.HYDRATE_EXTENSION_SETTINGS: {
|
||||
return {...state, ...action.payload};
|
||||
}
|
||||
|
||||
case ExtensionSettingsActionTypes.RELOAD_EXTENSION_SETTINGS: {
|
||||
return {...state, reload: action.payload};
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function useExtensionSettingsState(): State {
|
||||
const context = useContext(ExtensionSettingsStateContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useExtensionSettingsState must be used within a ExtensionSettingsProvider'
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function useExtensionSettingsDispatch(): Dispatch {
|
||||
const context = useContext(ExtensionSettingsDispatchContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useExtensionSettingsDispatch must be used within a ExtensionSettingsProvider'
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function useExtensionSettings(): [State, Dispatch] {
|
||||
// To access const [state, dispatch] = useExtensionSettings()
|
||||
return [useExtensionSettingsState(), useExtensionSettingsDispatch()];
|
||||
}
|
||||
|
||||
type ExtensionSettingsProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function ExtensionSettingsProvider({
|
||||
children,
|
||||
}: ExtensionSettingsProviderProps): JSX.Element {
|
||||
const [state, dispatch] = useReducer(extensionSettingsReducer, initialValues);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExtensionSettingsStateContext.Provider value={state}>
|
||||
<ExtensionSettingsDispatchContext.Provider value={dispatch}>
|
||||
{children}
|
||||
</ExtensionSettingsDispatchContext.Provider>
|
||||
</ExtensionSettingsStateContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export {ExtensionSettingsProvider, useExtensionSettings};
|
||||
108
source/contexts/request-status-context.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import type {JSX} from 'react';
|
||||
import {createContext, useReducer, useContext, type ReactNode} from 'react';
|
||||
|
||||
export enum RequestStatusActionTypes {
|
||||
SET_REQUEST_STATUS = 'set-request-status',
|
||||
SET_LOADING = 'set-loading',
|
||||
}
|
||||
|
||||
type SET_REQUEST_STATUS = {
|
||||
type: RequestStatusActionTypes.SET_REQUEST_STATUS;
|
||||
payload: {
|
||||
error: boolean;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
type SET_LOADING = {
|
||||
type: RequestStatusActionTypes.SET_LOADING;
|
||||
payload: boolean;
|
||||
};
|
||||
|
||||
type Action = SET_REQUEST_STATUS | SET_LOADING;
|
||||
|
||||
type InitialValues = {
|
||||
loading: boolean;
|
||||
error: boolean | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const initialValues: InitialValues = {
|
||||
loading: true,
|
||||
error: null,
|
||||
message: '',
|
||||
};
|
||||
|
||||
type State = InitialValues;
|
||||
type Dispatch = (action: Action) => void;
|
||||
|
||||
const RequestStatusStateContext = createContext<State | undefined>(undefined);
|
||||
const RequestStatusDispatchContext = createContext<Dispatch | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
function requestStatusReducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case RequestStatusActionTypes.SET_REQUEST_STATUS: {
|
||||
return {...state, ...action.payload};
|
||||
}
|
||||
|
||||
case RequestStatusActionTypes.SET_LOADING: {
|
||||
return {...state, loading: action.payload};
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function useRequestStatusState(): State {
|
||||
const context = useContext(RequestStatusStateContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useRequestStatusState must be used within a RequestStatusProvider'
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function useRequestStatusDispatch(): Dispatch {
|
||||
const context = useContext(RequestStatusDispatchContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useRequestStatusDispatch must be used within a RequestStatusProvider'
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function useRequestStatus(): [State, Dispatch] {
|
||||
// To access const [state, dispatch] = useRequestStatus()
|
||||
return [useRequestStatusState(), useRequestStatusDispatch()];
|
||||
}
|
||||
|
||||
type RequestStatusProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function RequestStatusProvider({
|
||||
children,
|
||||
}: RequestStatusProviderProps): JSX.Element {
|
||||
const [state, dispatch] = useReducer(requestStatusReducer, initialValues);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RequestStatusStateContext.Provider value={state}>
|
||||
<RequestStatusDispatchContext.Provider value={dispatch}>
|
||||
{children}
|
||||
</RequestStatusDispatchContext.Provider>
|
||||
</RequestStatusStateContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export {RequestStatusProvider, useRequestStatus};
|
||||
115
source/contexts/shortened-links-context.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import type {JSX} from 'react';
|
||||
import {createContext, useContext, useReducer, type ReactNode} from 'react';
|
||||
|
||||
import {UserShortenedLinkStats} from '../Background';
|
||||
|
||||
export enum ShortenedLinksActionTypes {
|
||||
HYDRATE_SHORTENED_LINKS = 'hydrate-shortened-links',
|
||||
SET_CURRENT_SELECTED = 'set-current-selected',
|
||||
}
|
||||
|
||||
type HYDRATE_SHORTENED_LINKS = {
|
||||
type: ShortenedLinksActionTypes.HYDRATE_SHORTENED_LINKS;
|
||||
payload: {
|
||||
items: UserShortenedLinkStats[];
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
|
||||
type SET_CURRENT_SELECTED = {
|
||||
type: ShortenedLinksActionTypes.SET_CURRENT_SELECTED;
|
||||
payload: string;
|
||||
};
|
||||
|
||||
type Action = HYDRATE_SHORTENED_LINKS | SET_CURRENT_SELECTED;
|
||||
|
||||
type InitialValues = {
|
||||
items: UserShortenedLinkStats[];
|
||||
total: number;
|
||||
selected: UserShortenedLinkStats | null;
|
||||
};
|
||||
|
||||
const initialValues: InitialValues = {
|
||||
items: [],
|
||||
total: 0,
|
||||
selected: null,
|
||||
};
|
||||
|
||||
type State = InitialValues;
|
||||
type Dispatch = (action: Action) => void;
|
||||
|
||||
const ShortenedLinksStateContext = createContext<State | undefined>(undefined);
|
||||
const ShortenedLinksDispatchContext = createContext<Dispatch | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const shortenedLinksReducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case ShortenedLinksActionTypes.HYDRATE_SHORTENED_LINKS: {
|
||||
return {
|
||||
...state,
|
||||
...action.payload,
|
||||
};
|
||||
}
|
||||
|
||||
case ShortenedLinksActionTypes.SET_CURRENT_SELECTED: {
|
||||
const selected: null | UserShortenedLinkStats =
|
||||
state.items.filter((item) => item.id === action.payload)[0] || null;
|
||||
|
||||
return {...state, selected};
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
function useShortenedLinksContextState(): State {
|
||||
const context = useContext(ShortenedLinksStateContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useShortenedLinksContextState must be used within a ShortenedLinksProvider'
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function useShortenedLinksContextDispatch(): Dispatch {
|
||||
const context = useContext(ShortenedLinksDispatchContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useShortenedLinksContextDispatch must be used within a ShortenedLinksProvider'
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function useShortenedLinks(): [State, Dispatch] {
|
||||
return [useShortenedLinksContextState(), useShortenedLinksContextDispatch()];
|
||||
}
|
||||
|
||||
type ShortenedLinksProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function ShortenedLinksProvider({
|
||||
children,
|
||||
}: ShortenedLinksProviderProps): JSX.Element {
|
||||
const [state, dispatch] = useReducer(shortenedLinksReducer, initialValues);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ShortenedLinksStateContext.Provider value={state}>
|
||||
<ShortenedLinksDispatchContext.Provider value={dispatch}>
|
||||
{children}
|
||||
</ShortenedLinksDispatchContext.Provider>
|
||||
</ShortenedLinksStateContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export {useShortenedLinks, ShortenedLinksProvider};
|
||||
12
source/globals.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
declare const __DEV__: boolean;
|
||||
declare const __TARGET_BROWSER__: 'chrome' | 'firefox';
|
||||
|
||||
declare module '*.module.scss' {
|
||||
const classes: {readonly [key: string]: string};
|
||||
export default classes;
|
||||
}
|
||||
|
||||
declare module '*.scss' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
52
source/manifest.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Kutt",
|
||||
"version": "0.0.0",
|
||||
"short_name": "Kutt",
|
||||
"description": "Shorten long URLs with just one click.",
|
||||
"icons": {
|
||||
"16": "assets/icons/favicon-16.png",
|
||||
"32": "assets/icons/favicon-32.png",
|
||||
"48": "assets/icons/favicon-48.png",
|
||||
"128": "assets/icons/favicon-128.png"
|
||||
},
|
||||
"homepage_url": "https://github.com/thedevs-network/kutt-extension.git",
|
||||
"__firefox__browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "support@kutt.it",
|
||||
"strict_min_version": "112.0",
|
||||
"data_collection_permissions": {
|
||||
"required": ["websiteActivity"],
|
||||
"optional": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"__chrome|firefox__author": "abhijithvijayan",
|
||||
"action": {
|
||||
"default_popup": "Popup/popup.html",
|
||||
"default_icon": {
|
||||
"16": "assets/icons/favicon-16.png",
|
||||
"32": "assets/icons/favicon-32.png",
|
||||
"48": "assets/icons/favicon-48.png",
|
||||
"128": "assets/icons/favicon-128.png"
|
||||
},
|
||||
"default_title": "Shorten this URL"
|
||||
},
|
||||
"background": {
|
||||
"__chrome__service_worker": "assets/js/background.bundle.js",
|
||||
"__chrome__type": "module",
|
||||
"__firefox__scripts": ["assets/js/background.bundle.js"],
|
||||
"__firefox__type": "module"
|
||||
},
|
||||
"__chrome__minimum_chrome_version": "88",
|
||||
"permissions": ["activeTab", "storage"],
|
||||
"host_permissions": ["http://*/*", "https://*/*"],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self'; object-src 'self';"
|
||||
},
|
||||
"__chrome__options_page": "Options/options.html",
|
||||
"options_ui": {
|
||||
"page": "Options/options.html",
|
||||
"open_in_tab": true
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 984 B After Width: | Height: | Size: 984 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
25
source/styles/_components.scss
Normal file
@@ -0,0 +1,25 @@
|
||||
@use 'variables' as *;
|
||||
|
||||
.icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background-color: rgb(235, 255, 243);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
box-shadow: rgba(138, 158, 168, 0.12) 0px 2px 1px;
|
||||
padding: 4px;
|
||||
outline: none;
|
||||
transition: transform 0.4s ease-out 0s;
|
||||
border-radius: 100%;
|
||||
|
||||
svg {
|
||||
transition: all 0.2s ease-out 0s;
|
||||
}
|
||||
}
|
||||
|
||||
.d-none {
|
||||
display: none !important;
|
||||
}
|
||||
67
source/styles/_reset.scss
Normal file
@@ -0,0 +1,67 @@
|
||||
// CSS reset
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
height: auto;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
img,
|
||||
picture,
|
||||
video,
|
||||
canvas,
|
||||
svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
// Spinner animation (used by Icon component)
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
#spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
48
source/styles/_variables.scss
Normal file
@@ -0,0 +1,48 @@
|
||||
// Colors
|
||||
$black: #111111;
|
||||
$light-black: #0f0f0f;
|
||||
$grey-white: #f3f3f3;
|
||||
$white: #ffffff;
|
||||
|
||||
// Tailwind-like colors
|
||||
$gray-200: #e5e7eb;
|
||||
$gray-300: #d1d5db;
|
||||
$gray-400: #9ca3af;
|
||||
$gray-500: #6b7280;
|
||||
$gray-600: #4b5563;
|
||||
$gray-700: #374151;
|
||||
$gray-800: #1f2937;
|
||||
$gray-900: #111827;
|
||||
$red-500: #ef4444;
|
||||
$green-500: #22c55e;
|
||||
$green-900: #14532d;
|
||||
$blue-500: #3b82f6;
|
||||
$indigo-400: #818cf8;
|
||||
$purple-600: #9333ea;
|
||||
$yellow-500: #eab308;
|
||||
|
||||
// Custom colors
|
||||
$copy-icon-bg: hsl(144, 100%, 96%);
|
||||
$stats-total-underline: hsl(200, 35%, 65%);
|
||||
$primary-gradient: linear-gradient(to right, rgb(126, 87, 194), rgb(98, 0, 234));
|
||||
|
||||
// Font weights
|
||||
$thin: 100;
|
||||
$exlight: 200;
|
||||
$light: 300;
|
||||
$regular: 400;
|
||||
$medium: 500;
|
||||
$semibold: 600;
|
||||
$bold: 700;
|
||||
$exbold: 800;
|
||||
$exblack: 900;
|
||||
|
||||
// Transitions
|
||||
$transition-fast: 200ms ease-out;
|
||||
$transition-normal: 300ms ease-in-out;
|
||||
|
||||
// Border radius
|
||||
$radius-sm: 4px;
|
||||
$radius-md: 8px;
|
||||
$radius-lg: 12px;
|
||||
$radius-full: 9999px;
|
||||
3
source/styles/main.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
@use 'reset';
|
||||
@use 'variables';
|
||||
@use 'components';
|
||||
47
source/util/browser.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import {EMPTY_STRING} from '@abhijithvijayan/ts-utils';
|
||||
|
||||
// Custom fork of https://github.com/DamonOehlman/detect-browser/blob/master/src/index.ts
|
||||
type Browser = 'edge-chromium' | 'chrome' | 'firefox' | 'opera';
|
||||
type UserAgentRule = [Browser, RegExp];
|
||||
type UserAgentMatch = [Browser, RegExpExecArray] | false;
|
||||
|
||||
const userAgentRules: UserAgentRule[] = [
|
||||
['edge-chromium', /Edg\/([0-9.]+)/],
|
||||
['chrome', /(?!Chrom.*OPR)Chrom(?:e|ium)\/([0-9.]+)(:?\s|$)/],
|
||||
['firefox', /Firefox\/([0-9.]+)(?:\s|$)/],
|
||||
['opera', /Opera\/([0-9.]+)(?:\s|$)/],
|
||||
['opera', /OPR\/([0-9.]+)(:?\s|$)/],
|
||||
];
|
||||
|
||||
function matchUserAgent(ua: string): UserAgentMatch {
|
||||
return (
|
||||
ua !== EMPTY_STRING &&
|
||||
userAgentRules.reduce<UserAgentMatch>(
|
||||
(matched: UserAgentMatch, [browser, regex]) => {
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
|
||||
const uaMatch = regex.exec(ua);
|
||||
return !!uaMatch && [browser, uaMatch];
|
||||
},
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
export function detectBrowser(): Browser | null {
|
||||
const matchedRule = matchUserAgent(navigator.userAgent);
|
||||
|
||||
if (!matchedRule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [name, match] = matchedRule;
|
||||
|
||||
let versionParts = match[1] && match[1].split(/[._]/).slice(0, 3);
|
||||
if (!versionParts) {
|
||||
versionParts = [];
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
14
source/util/link.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import {IPV4_REGEX} from '@abhijithvijayan/ts-utils';
|
||||
|
||||
export const removeProtocol = (link: string): string =>
|
||||
link.replace(/^https?:\/\//i, '').replace(/^www\./i, '');
|
||||
|
||||
export function isValidUrl(url: string): boolean {
|
||||
// https://regex101.com/r/BzoIRR/1
|
||||
const ipRegex = IPV4_REGEX.toString().slice(2, -2);
|
||||
const re = new RegExp(
|
||||
`^(http[s]?:\\/\\/)(www\\.){0,1}(([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,5}[.]{0,1})|(${ipRegex}))`
|
||||
);
|
||||
|
||||
return re.test(url);
|
||||
}
|
||||
14
source/util/messageUtil.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
const messageUtil = {
|
||||
send(name: string, params?: unknown): Promise<any> {
|
||||
const data = {
|
||||
action: name,
|
||||
params,
|
||||
};
|
||||
|
||||
return browser.runtime.sendMessage(data);
|
||||
},
|
||||
};
|
||||
|
||||
export default messageUtil;
|
||||
38
source/util/settings.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
import {DomainEntryProperties} from '../Background';
|
||||
|
||||
// Core Extensions settings props
|
||||
export type ExtensionSettingsProperties = {
|
||||
apikey: string;
|
||||
history: boolean;
|
||||
user?: {
|
||||
email?: string;
|
||||
domains?: DomainEntryProperties[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
// update extension settings in browser storage
|
||||
export function saveExtensionSettings(settings: any): Promise<void> {
|
||||
return browser.storage.local.set({
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function getExtensionSettings(): Promise<{[s: string]: any}> {
|
||||
return browser.storage.local.get('settings');
|
||||
}
|
||||
|
||||
export async function updateExtensionSettings(newFields?: {
|
||||
[s: string]: any;
|
||||
}): Promise<void> {
|
||||
const {settings = {}} = await getExtensionSettings();
|
||||
|
||||
return saveExtensionSettings({...settings, ...newFields});
|
||||
}
|
||||
|
||||
// Clear all extension settings
|
||||
export function clearExtensionSettings(): Promise<void> {
|
||||
return browser.storage.local.clear();
|
||||
}
|
||||
20
source/util/tabs.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import browser from 'webextension-polyfill';
|
||||
import type {Tabs} from 'webextension-polyfill';
|
||||
|
||||
export function openExtOptionsPage(): Promise<void> {
|
||||
return browser.runtime.openOptionsPage();
|
||||
}
|
||||
|
||||
export function openHistoryPage(): Promise<Tabs.Tab> {
|
||||
return browser.tabs.create({
|
||||
active: true,
|
||||
url: '/History/history.html',
|
||||
});
|
||||
}
|
||||
|
||||
export function getCurrentTab(): Promise<Tabs.Tab[]> {
|
||||
return browser.tabs.query({
|
||||
active: true,
|
||||
lastFocusedWindow: true,
|
||||
});
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg aria-hidden="true" data-prefix="fas" data-icon="chart-line" class="svg-inline--fa fa-chart-line fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M496 384H64V80c0-8.84-7.16-16-16-16H16C7.16 64 0 71.16 0 80v336c0 17.67 14.33 32 32 32h464c8.84 0 16-7.16 16-16v-32c0-8.84-7.16-16-16-16zM464 96H345.94c-21.38 0-32.09 25.85-16.97 40.97l32.4 32.4L288 242.75l-73.37-73.37c-12.5-12.5-32.76-12.5-45.25 0l-68.69 68.69c-6.25 6.25-6.25 16.38 0 22.63l22.62 22.62c6.25 6.25 16.38 6.25 22.63 0L192 237.25l73.37 73.37c12.5 12.5 32.76 12.5 45.25 0l96-96 32.4 32.4c15.12 15.12 40.97 4.41 40.97-16.97V112c.01-8.84-7.15-16-15.99-16z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 680 B |
@@ -1 +0,0 @@
|
||||
<svg aria-hidden="true" data-prefix="far" data-icon="copy" class="svg-inline--fa fa-copy fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="#888" d="M433.941 65.941l-51.882-51.882A48 48 0 0 0 348.118 0H176c-26.51 0-48 21.49-48 48v48H48c-26.51 0-48 21.49-48 48v320c0 26.51 21.49 48 48 48h224c26.51 0 48-21.49 48-48v-48h80c26.51 0 48-21.49 48-48V99.882a48 48 0 0 0-14.059-33.941zM266 464H54a6 6 0 0 1-6-6V150a6 6 0 0 1 6-6h74v224c0 26.51 21.49 48 48 48h96v42a6 6 0 0 1-6 6zm128-96H182a6 6 0 0 1-6-6V54a6 6 0 0 1 6-6h106v88c0 13.255 10.745 24 24 24h88v202a6 6 0 0 1-6 6zm6-256h-64V48h9.632c1.591 0 3.117.632 4.243 1.757l48.368 48.368a6 6 0 0 1 1.757 4.243V112z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 710 B |
@@ -1 +0,0 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="trash-alt" class="svg-inline--fa fa-trash-alt fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="#888" d="M268 416h24a12 12 0 0 0 12-12V188a12 12 0 0 0-12-12h-24a12 12 0 0 0-12 12v216a12 12 0 0 0 12 12zM432 80h-82.41l-34-56.7A48 48 0 0 0 274.41 0H173.59a48 48 0 0 0-41.16 23.3L98.41 80H16A16 16 0 0 0 0 96v16a16 16 0 0 0 16 16h16v336a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128h16a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16zM171.84 50.91A6 6 0 0 1 177 48h94a6 6 0 0 1 5.15 2.91L293.61 80H154.39zM368 464H80V128h288zm-212-48h24a12 12 0 0 0 12-12V188a12 12 0 0 0-12-12h-24a12 12 0 0 0-12 12v216a12 12 0 0 0 12 12z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 731 B |
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path fill="#444" d="M896 128q209 0 385.5 103t279.5 279.5 103 385.5q0 251-146.5 451.5t-378.5 277.5q-27 5-40-7t-13-30q0-3 .5-76.5t.5-134.5q0-97-52-142 57-6 102.5-18t94-39 81-66.5 53-105 20.5-150.5q0-119-79-206 37-91-8-204-28-9-81 11t-92 44l-38 24q-93-26-192-26t-192 26q-16-11-42.5-27t-83.5-38.5-85-13.5q-45 113-8 204-79 87-79 206 0 85 20.5 150t52.5 105 80.5 67 94 39 102.5 18q-39 36-49 103-21 10-45 15t-57 5-65.5-21.5-55.5-62.5q-19-32-48.5-52t-49.5-24l-20-3q-21 0-29 4.5t-5 11.5 9 14 13 12l7 5q22 10 43.5 38t31.5 51l10 23q13 38 44 61.5t67 30 69.5 7 55.5-3.5l23-4q0 38 .5 88.5t.5 54.5q0 18-13 30t-40 7q-232-77-378.5-277.5t-146.5-451.5q0-209 103-385.5t279.5-279.5 385.5-103zm-477 1103q3-7-7-12-10-3-13 2-3 7 7 12 9 6 13-2zm31 34q7-5-2-16-10-9-16-3-7 5 2 16 10 10 16 3zm30 45q9-7 0-19-8-13-17-6-9 5 0 18t17 7zm42 42q8-8-4-19-12-12-20-3-9 8 4 19 12 12 20 3zm57 25q3-11-13-16-15-4-19 7t13 15q15 6 19-6zm63 5q0-13-17-11-16 0-16 11 0 13 17 11 16 0 16-11zm58-10q-2-11-18-9-16 3-14 15t18 8 14-14z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1 +0,0 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="history" class="svg-inline--fa fa-history fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M504 255.531c.253 136.64-111.18 248.372-247.82 248.468-59.015.042-113.223-20.53-155.822-54.911-11.077-8.94-11.905-25.541-1.839-35.607l11.267-11.267c8.609-8.609 22.353-9.551 31.891-1.984C173.062 425.135 212.781 440 256 440c101.705 0 184-82.311 184-184 0-101.705-82.311-184-184-184-48.814 0-93.149 18.969-126.068 49.932l50.754 50.754c10.08 10.08 2.941 27.314-11.313 27.314H24c-8.837 0-16-7.163-16-16V38.627c0-14.254 17.234-21.393 27.314-11.314l49.372 49.372C129.209 34.136 189.552 8 256 8c136.81 0 247.747 110.78 248 247.531zm-180.912 78.784l9.823-12.63c8.138-10.463 6.253-25.542-4.21-33.679L288 256.349V152c0-13.255-10.745-24-24-24h-16c-13.255 0-24 10.745-24 24v135.651l65.409 50.874c10.463 8.137 25.541 6.253 33.679-4.21z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 955 B |
@@ -1 +0,0 @@
|
||||
<svg aria-hidden="true" data-prefix="fas" data-icon="qrcode" class="svg-inline--fa fa-qrcode fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="#888" d="M0 224h192V32H0v192zM64 96h64v64H64V96zm192-64v192h192V32H256zm128 128h-64V96h64v64zM0 480h192V288H0v192zm64-128h64v64H64v-64zm352-64h32v128h-96v-32h-32v96h-64V288h96v32h64v-32zm0 160h32v32h-32v-32zm-64 0h32v32h-32v-32z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 425 B |
@@ -1 +0,0 @@
|
||||
<svg aria-hidden="true" data-prefix="fas" data-icon="cog" class="svg-inline--fa fa-cog fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#444" d="M444.788 291.1l42.616 24.599c4.867 2.809 7.126 8.618 5.459 13.985-11.07 35.642-29.97 67.842-54.689 94.586a12.016 12.016 0 0 1-14.832 2.254l-42.584-24.595a191.577 191.577 0 0 1-60.759 35.13v49.182a12.01 12.01 0 0 1-9.377 11.718c-34.956 7.85-72.499 8.256-109.219.007-5.49-1.233-9.403-6.096-9.403-11.723v-49.184a191.555 191.555 0 0 1-60.759-35.13l-42.584 24.595a12.016 12.016 0 0 1-14.832-2.254c-24.718-26.744-43.619-58.944-54.689-94.586-1.667-5.366.592-11.175 5.459-13.985L67.212 291.1a193.48 193.48 0 0 1 0-70.199l-42.616-24.599c-4.867-2.809-7.126-8.618-5.459-13.985 11.07-35.642 29.97-67.842 54.689-94.586a12.016 12.016 0 0 1 14.832-2.254l42.584 24.595a191.577 191.577 0 0 1 60.759-35.13V25.759a12.01 12.01 0 0 1 9.377-11.718c34.956-7.85 72.499-8.256 109.219-.007 5.49 1.233 9.403 6.096 9.403 11.723v49.184a191.555 191.555 0 0 1 60.759 35.13l42.584-24.595a12.016 12.016 0 0 1 14.832 2.254c24.718 26.744 43.619 58.944 54.689 94.586 1.667 5.366-.592 11.175-5.459 13.985L444.788 220.9a193.485 193.485 0 0 1 0 70.2zM336 256c0-44.112-35.888-80-80-80s-80 35.888-80 80 35.888 80 80 80 80-35.888 80-80z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,78 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>History: Kutt</title>
|
||||
<link rel="stylesheet" href="css/history.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<section id="history">
|
||||
<div class="container history__content--holder">
|
||||
<div class="table__content--holder">
|
||||
<h2 class="table__content--title">Recent shortened links.</h2>
|
||||
<table class="table__content--wrapper" id="URL_table">
|
||||
<thead class="table__content--head">
|
||||
<tr class="table__head--holder">
|
||||
<th class="table__head--longURL">
|
||||
Original URL
|
||||
</th>
|
||||
<th class="table__head--shortURL">
|
||||
Short URL
|
||||
</th>
|
||||
<th class="table__head--clearAll">
|
||||
<ul class="table__list--clearAll">
|
||||
<li class="table__listItem--clear">
|
||||
<button class="table__clearAll--btn" id="table__clearAll--btn" title="Delete All">
|
||||
Clear All <img class="selectDisable icon__img" src="assets/delete.svg" alt="Delete All" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table__content--body" id="delegation__element">
|
||||
<!-- TEMPLATE TO USE -->
|
||||
<!-- <tr class="table__body--holder">
|
||||
<td class="table__body--original">
|
||||
<a href="#" class="table__body--originalURL" target="_blank" rel="noopener">https://google.com</a>
|
||||
</td>
|
||||
<td class="table__body--shortened">
|
||||
<div class="table__body--shortenBody">
|
||||
<a href="#" class="table__body--shortenURL" target="_blank" rel="noopener">https://kutt.it/abc</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="table__body--functionBtns">
|
||||
<div class="table__body--btnHolder">
|
||||
<button class="table__body--copy" title="Copy">
|
||||
<img class="selectDisable icon__img" src="assets/copy.svg" alt="copy" />
|
||||
</button>
|
||||
<button class="table__body--qrcode" title="QR Code">
|
||||
<img class="selectDisable icon__img" src="assets/qrcode.svg" alt="QR Code" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="table__qrcodePopup--div">
|
||||
<div class="table__qrcode--popup">
|
||||
<div class="table__qrcode--holder">
|
||||
<img id="table__qrcode" src="#" alt="QRCode" />
|
||||
</div>
|
||||
<div class="table__closebtn--holder">
|
||||
<button class="table__closebtn--inner">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr> -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="js/history.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Kutt",
|
||||
"version": "0.9.0",
|
||||
"description": "URL Shortener",
|
||||
"icons": {
|
||||
"16": "assets/favicon-16.png",
|
||||
"32": "assets/favicon-32.png",
|
||||
"48": "assets/favicon-48.png",
|
||||
"128": "assets/favicon-128.png"
|
||||
},
|
||||
"browser_action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "assets/favicon-16.png",
|
||||
"32": "assets/favicon-32.png",
|
||||
"48": "assets/favicon-48.png",
|
||||
"128": "assets/favicon-128.png"
|
||||
},
|
||||
"default_title": "Kutt",
|
||||
"chrome_style": false
|
||||
},
|
||||
"author": "abhijithvijayan",
|
||||
"background": {
|
||||
"persistent": false,
|
||||
"scripts": [
|
||||
"js/background.js"
|
||||
]
|
||||
},
|
||||
"homepage_url": "https://github.com/abhijithvijayan/kutt-extension",
|
||||
"minimum_chrome_version": "49",
|
||||
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
|
||||
"options_page": "options.html",
|
||||
"options_ui": {
|
||||
"page": "options.html",
|
||||
"chrome_style": false,
|
||||
"open_in_tab": true
|
||||
},
|
||||
"permissions": [
|
||||
"tabs",
|
||||
"storage",
|
||||
"clipboardRead",
|
||||
"https://kutt.it/*/*"
|
||||
],
|
||||
"short_name": "Kutt"
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Kutt",
|
||||
"version": "0.9.0",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "support@kutt.it"
|
||||
}
|
||||
},
|
||||
"description": "URL Shortener",
|
||||
"icons": {
|
||||
"16": "assets/favicon-16.png",
|
||||
"32": "assets/favicon-32.png",
|
||||
"48": "assets/favicon-48.png",
|
||||
"128": "assets/favicon-128.png"
|
||||
},
|
||||
"browser_action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "assets/favicon-16.png",
|
||||
"32": "assets/favicon-32.png",
|
||||
"48": "assets/favicon-48.png",
|
||||
"128": "assets/favicon-128.png"
|
||||
},
|
||||
"default_title": "Kutt",
|
||||
"browser_style": false
|
||||
},
|
||||
"author": "abhijithvijayan",
|
||||
"background": {
|
||||
"scripts": [
|
||||
"js/background.js"
|
||||
]
|
||||
},
|
||||
"homepage_url": "https://github.com/abhijithvijayan/kutt-extension",
|
||||
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
|
||||
"options_ui": {
|
||||
"page": "options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"permissions": [
|
||||
"tabs",
|
||||
"storage",
|
||||
"clipboardWrite",
|
||||
"clipboardRead",
|
||||
"https://kutt.it/*/*"
|
||||
],
|
||||
"short_name": "Kutt"
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Kutt",
|
||||
"version": "0.9.0",
|
||||
"description": "URL Shortener",
|
||||
"developer": {
|
||||
"name": "abhijithvijayan"
|
||||
},
|
||||
"icons": {
|
||||
"16": "assets/favicon-16.png",
|
||||
"32": "assets/favicon-32.png",
|
||||
"48": "assets/favicon-48.png",
|
||||
"128": "assets/favicon-128.png"
|
||||
},
|
||||
"browser_action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "assets/favicon-16.png",
|
||||
"32": "assets/favicon-32.png",
|
||||
"48": "assets/favicon-48.png",
|
||||
"128": "assets/favicon-128.png"
|
||||
},
|
||||
"default_title": "Kutt",
|
||||
"chrome_style": false
|
||||
},
|
||||
"background": {
|
||||
"persistent": false,
|
||||
"scripts": [
|
||||
"js/background.js"
|
||||
]
|
||||
},
|
||||
"homepage_url": "https://github.com/abhijithvijayan/kutt-extension",
|
||||
"minimum_opera_version": "36",
|
||||
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
|
||||
"options_page": "options.html",
|
||||
"options_ui": {
|
||||
"page": "options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"permissions": [
|
||||
"tabs",
|
||||
"storage",
|
||||
"clipboardRead",
|
||||
"https://kutt.it/*/*"
|
||||
],
|
||||
"short_name": "Kutt"
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>Options: Kutt</title>
|
||||
<link rel="stylesheet" href="css/options.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<section id="options">
|
||||
<div class="container options__content--holder">
|
||||
<div class="head__content--holder text-center">
|
||||
<img class="head__content--logo" src="assets/logo.png" />
|
||||
<a class="head__content--title" href="https://kutt.it" target="_blank" rel="noopener">Kutt.it</a>
|
||||
</div>
|
||||
<div class="form__content--holder">
|
||||
<form class="form__content">
|
||||
<label class="api__key--label">API Key:
|
||||
<a class="api__label--Qmark" target="_blank" rel="noopener" href="https://kutt.it/login">?
|
||||
<span class="api__label--tooltiptext text-center">
|
||||
Generate key from Kutt.it Website<br>(Settings Page)
|
||||
</span>
|
||||
</a>
|
||||
</label>
|
||||
<input class="api__key--holder" id="api__key--value" type="password" spellcheck="false" />
|
||||
<br />
|
||||
<label class="password--label">Set Password
|
||||
<span class="password__label--optional">(Optional):
|
||||
<span class="password__label--tooltiptext text-center">
|
||||
Set Password for the Shortened URLs.<br /> (20 Char. Max)
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<input class="password--holder" id="password--value" type="password" maxlength="20" />
|
||||
<div class="password__check--holder">
|
||||
<input type="checkbox" id="password__view--checkbox">
|
||||
<span class="password__view--title">Show Password</span>
|
||||
</div>
|
||||
<br />
|
||||
<button class="button__submit" id="button__submit" type="button">Save</button>
|
||||
<label class="saved__alert v-none">Saved!!</label>
|
||||
</form>
|
||||
</div>
|
||||
<div class="footer__text--holder text-center">
|
||||
Made with ❤️ on <a class="github__repo--link" href="https://github.com/abhijithvijayan/kutt-extension" target="_blank"
|
||||
rel="noopener">GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="js/options.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,53 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>Kutt</title>
|
||||
<link rel="stylesheet" href="css/popup.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<section id="home">
|
||||
<div class="container">
|
||||
<nav class="navbar">
|
||||
<ul class="navbar__main">
|
||||
<li class="logo__content--holder">
|
||||
<img class="main__logo selectDisable" src="assets/logo.png">
|
||||
</li>
|
||||
<li class="options__content-holder" id="options__content-holder" title="History">
|
||||
<a href="history.html" target="_blank" rel="noopener">
|
||||
<img class="settings__logo selectDisable" src="assets/history.svg"></a>
|
||||
</li>
|
||||
<li class="options__content-holder" id="options__content-holder" title="Options">
|
||||
<a href="options.html" target="_blank" rel="noopener">
|
||||
<img class="settings__logo selectDisable" src="assets/settings.svg"></a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="content__holder">
|
||||
<div class="url__content--holder text-center">
|
||||
<h4 id="url__content-inner">Shortening...</h4>
|
||||
<ul class="buttons__content--holder d-none">
|
||||
<li class="copy__content--holder" id="button__copy--holder" title="Copy">
|
||||
<img id="button__copy" class="selectDisable" src="assets/copy.svg" alt="copy" />
|
||||
</li>
|
||||
<li class="qrbtn__content--holder" id="button__qrcode--holder" title="QR code">
|
||||
<img id="button__qrcode" class="selectDisable" src="assets/qrcode.svg" alt="QR Code" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="copy__alert text-center d-none">Copied!!!</p>
|
||||
<div class="qrcode__content--holder text-center d-none">
|
||||
<img id="qr_code" src="#" alt="QRCode" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="js/popup.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||