Compare commits

...

23 Commits

Author SHA1 Message Date
Corentin Thomasset
119041c185 chore(release): 2.17.0 2023-01-13 18:27:39 +01:00
Corentin Thomasset
4607837f9a feat(new-tool): temperature converter 2023-01-13 18:26:28 +01:00
Corentin Thomasset
f52f7a845c refactor(jwt-parser): simplified code 2023-01-13 14:02:44 +01:00
bastantoine
acc7f0a586 feat(new-tool): jwt parser (#262)
* npm install jwt-decode

* added base tool structure

* added function to decode JWT and display header and payload

* use a table to display the data

* show human readable values

* added switch to toggle display of parsed values

* lint

* replaced basic package-lock.json with pnpm-lock.json

* change the icon of the tool

* simplify return

* use camelCase

* added description of the tool

* always parse the values

* use camelCase...
2022-12-27 09:38:35 +01:00
Corentin Thomasset
ebb7301a98 chore(release): 2.16.0 2022-12-21 21:15:16 +01:00
Corentin Thomasset
def60e7248 refactor(tracker): better tracker injection 2022-12-21 21:02:57 +01:00
Corentin Thomasset
bf88836dbe feat(search-bar): use cmd + k to focus on mac 2022-12-21 00:21:12 +01:00
Corentin Thomasset
bfc2e24bbf feat(tracker): added actions monitoring 2022-12-21 00:03:31 +01:00
Corentin Thomasset
40872859a5 refactor(clean): removed unused import 2022-12-20 21:15:00 +01:00
Corentin Thomasset
cf723f144e refactor(clean): removed empty style tag 2022-12-20 21:14:40 +01:00
Corentin Thomasset
7f964941d3 chore(docs): updated readme 2022-12-20 20:57:24 +01:00
Corentin Thomasset
af075dcccc feat(tool): improved favorite tool management 2022-12-17 15:33:52 +01:00
Corentin Thomasset
274ff02b54 chore(git): added .env to gitignore 2022-12-17 11:42:47 +01:00
Corentin Thomasset
679dd1c1f6 refactor(menu): improve support button 2022-12-17 01:30:27 +01:00
Corentin Thomasset
4cd809bd0c feat(tools): added favorite tool handling 2022-12-17 01:30:02 +01:00
Corentin Thomasset
8d09086e78 chore(release): 2.15.0 2022-12-16 21:59:47 +01:00
Corentin Thomasset
acf8bc11db fix(tool-card): correct text color on light mode for card description 2022-12-16 21:57:23 +01:00
Corentin Thomasset
71e98e93e5 feat(search-bar): better search back result 2022-12-16 21:44:54 +01:00
Corentin Thomasset
1b5d4e72bd refactor(search-bar): improved tool fuzzy search 2022-12-16 18:10:50 +01:00
Corentin Thomasset
8476cf319b fix(integer-base-converter): handle non-decimal char and better error message 2022-12-07 21:52:24 +01:00
Corentin Thomasset
0ff853437b chore(release): 2.14.1 2022-11-23 22:00:08 +01:00
Corentin Thomasset
39c8f92065 chore(release): 2.14.0 2022-11-23 22:00:01 +01:00
Corentin Thomasset
35b5187119 feat(new-tool): chmod calculator 2022-11-23 21:57:38 +01:00
44 changed files with 1273 additions and 239 deletions

2
.gitignore vendored
View File

@@ -26,3 +26,5 @@ coverage
*.njsproj
*.sln
*.sw?
.env

View File

@@ -2,6 +2,64 @@
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
## [2.17.0](https://github.com/CorentinTh/it-tools/compare/v2.16.0...v2.17.0) (2023-01-13)
### Features
* **new-tool:** jwt parser ([#262](https://github.com/CorentinTh/it-tools/issues/262)) ([acc7f0a](https://github.com/CorentinTh/it-tools/commit/acc7f0a586c64500c5f720e70cdbccf9bffe76d9))
* **new-tool:** temperature converter ([4607837](https://github.com/CorentinTh/it-tools/commit/4607837f9a398440e0098f2ba862e8d7422ce94f))
### Refactors
* **jwt-parser:** simplified code ([f52f7a8](https://github.com/CorentinTh/it-tools/commit/f52f7a845c34ce7da57b11c17d261733be89554f))
## [2.16.0](https://github.com/CorentinTh/it-tools/compare/v2.15.0...v2.16.0) (2022-12-21)
### Features
* **search-bar:** use cmd + k to focus on mac ([bf88836](https://github.com/CorentinTh/it-tools/commit/bf88836dbe4037019e9545deaae1db06e5768cfb))
* **tool:** improved favorite tool management ([af075dc](https://github.com/CorentinTh/it-tools/commit/af075dccccec959a0863e6d11516206860bed91f))
* **tools:** added favorite tool handling ([4cd809b](https://github.com/CorentinTh/it-tools/commit/4cd809bd0c94836532f58a2ec6aa131694cce10d))
* **tracker:** added actions monitoring ([bfc2e24](https://github.com/CorentinTh/it-tools/commit/bfc2e24bbfc08f67ed9c9b1d93474029bc01dc8b))
### Refactors
* **clean:** removed empty style tag ([cf723f1](https://github.com/CorentinTh/it-tools/commit/cf723f144ee865b6de7323d3be58eb7a9586fa56))
* **clean:** removed unused import ([4087285](https://github.com/CorentinTh/it-tools/commit/40872859a580a20bb838b79db2b3c88c00995e37))
* **menu:** improve support button ([679dd1c](https://github.com/CorentinTh/it-tools/commit/679dd1c1f6265227cc9db60c55d83f8eaf8f72b4))
* **tracker:** better tracker injection ([def60e7](https://github.com/CorentinTh/it-tools/commit/def60e7248003e74ed67e9ff116b438bab410a92))
## [2.15.0](https://github.com/CorentinTh/it-tools/compare/v2.14.1...v2.15.0) (2022-12-16)
### Features
* **search-bar:** better search back result ([71e98e9](https://github.com/CorentinTh/it-tools/commit/71e98e93e5752cba934f67d679088524c4d3d2ad))
### Bug Fixes
* **integer-base-converter:** handle non-decimal char and better error message ([8476cf3](https://github.com/CorentinTh/it-tools/commit/8476cf319b7ebae87c7928592604a54833ac56ef))
* **tool-card:** correct text color on light mode for card description ([acf8bc1](https://github.com/CorentinTh/it-tools/commit/acf8bc11dbab85ab361edbe400ebbe5e52a11b89))
### Refactors
* **search-bar:** improved tool fuzzy search ([1b5d4e7](https://github.com/CorentinTh/it-tools/commit/1b5d4e72bdb222dd721a1e484c3e5d73bb62d2b1))
### [2.14.1](https://github.com/CorentinTh/it-tools/compare/v2.14.0...v2.14.1) (2022-11-23)
## [2.14.0](https://github.com/CorentinTh/it-tools/compare/v2.13.0...v2.14.0) (2022-11-23)
### Features
* **new-tool:** chmod calculator ([35b5187](https://github.com/CorentinTh/it-tools/commit/35b518711938c2bc88f35d104bb35d9956f0c267))
## [2.13.0](https://github.com/CorentinTh/it-tools/compare/v2.11.0...v2.13.0) (2022-11-14)

View File

@@ -10,46 +10,53 @@ You have an idea of a tool? Submit a [feature request](https://github.com/Corent
## Contribute
### Recommended IDE Setup
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.vscode-typescript-vue-plugin).
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
### Node version
## Type Support for `.vue` Imports in TS
Ensure you have the correct node/npm version
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
nvm use
pnpm install
```
### Project Setup
### Compile and Hot-Reload for Development
```sh
npm install
pnpm dev
```
#### Compile and Hot-Reload for Development
### Type-Check, Compile and Minify for Production
```sh
npm run dev
pnpm build
```
#### Type-Check, Compile and Minify for Production
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run build
pnpm test
```
#### Run Unit Tests with [Vitest](https://vitest.dev/)
### Lint with [ESLint](https://eslint.org/)
```sh
npm run test
```
#### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
pnpm lint
```
### Create a new tool
@@ -68,12 +75,9 @@ Coded with ❤️ by [Corentin Thomasset](//corentin-thomasset.fr).
This project is continuously deployed using [vercel.com](https://vercel.com).
<a href="https://www.producthunt.com/posts/it-tools?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-it&#0045;tools" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=345793&theme=light" alt="IT&#0032;Tools - Collection&#0032;of&#0032;handy&#0032;online&#0032;tools&#0032;for&#0032;devs&#0044;&#0032;with&#0032;great&#0032;UX | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
<a href="https://www.producthunt.com/posts/it-tools?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-it&#0045;tools" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=345793&theme=light&period=daily" alt="IT&#0032;Tools - Collection&#0032;of&#0032;handy&#0032;online&#0032;tools&#0032;for&#0032;devs&#0044;&#0032;with&#0032;great&#0032;UX | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
## License
This project is under the [GNU GPLv3](LICENSE).

View File

@@ -1,6 +1,6 @@
{
"name": "it-tools",
"version": "2.13.0",
"version": "2.17.0",
"description": "Collection of handy online tools for developers, with great UX. ",
"keywords": [
"productivity",
@@ -45,8 +45,10 @@
"crypto-js": "^4.1.1",
"date-fns": "^2.29.3",
"figue": "^1.2.0",
"fuse.js": "^6.6.2",
"highlight.js": "^11.6.0",
"json5": "^2.2.1",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"mathjs": "^10.6.4",
"mime-types": "^2.1.35",
@@ -56,6 +58,7 @@
"qrcode": "^1.5.1",
"randombytes": "^2.1.0",
"sql-formatter": "^8.2.0",
"ts-pattern": "^4.1.3",
"uuid": "^8.3.2",
"vue": "^3.2.45",
"vue-router": "^4.1.6"

88
pnpm-lock.yaml generated
View File

@@ -38,9 +38,11 @@ specifiers:
eslint-plugin-import: ^2.26.0
eslint-plugin-vue: ^8.7.1
figue: ^1.2.0
fuse.js: ^6.6.2
highlight.js: ^11.6.0
jsdom: ^19.0.0
json5: ^2.2.1
jwt-decode: ^3.1.2
less: ^4.1.3
lodash: ^4.17.21
mathjs: ^10.6.4
@@ -54,6 +56,7 @@ specifiers:
sql-formatter: ^8.2.0
standard-version: ^9.5.0
start-server-and-test: ^1.14.0
ts-pattern: ^4.1.3
typescript: ~4.5.5
uuid: ^8.3.2
vite: ^2.9.15
@@ -81,8 +84,10 @@ dependencies:
crypto-js: 4.1.1
date-fns: 2.29.3
figue: 1.2.0
fuse.js: 6.6.2
highlight.js: 11.6.0
json5: 2.2.1
jwt-decode: 3.1.2
lodash: 4.17.21
mathjs: 10.6.4
mime-types: 2.1.35
@@ -92,6 +97,7 @@ dependencies:
qrcode: 1.5.1
randombytes: 2.1.0
sql-formatter: 8.2.0
ts-pattern: 4.1.3
uuid: 8.3.2
vue: 3.2.45
vue-router: 4.1.6_vue@3.2.45
@@ -118,7 +124,7 @@ devDependencies:
eslint: 8.27.0
eslint-config-prettier: 8.5.0_eslint@8.27.0
eslint-import-resolver-typescript: 3.5.2_dcpv4nbdr5ks2h5677xdltrk6e
eslint-plugin-import: 2.26.0_gbipkkcbnjmysmpjttq6vkmfqq
eslint-plugin-import: 2.26.0_eslint@8.27.0
eslint-plugin-vue: 8.7.1_eslint@8.27.0
jsdom: 19.0.0
less: 4.1.3
@@ -128,7 +134,7 @@ devDependencies:
typescript: 4.5.5
vite: 2.9.15_less@4.1.3
vite-plugin-md: 0.12.4_vite@2.9.15
vite-plugin-pwa: 0.11.13_7mbbuzxp22mje5bxdolj2b6yg4
vite-plugin-pwa: 0.11.13_vite@2.9.15
vite-svg-loader: 3.6.0
vitest: 0.13.1_uwxj23d3xojfwkqpytqc7pyhry
vue-tsc: 0.31.4_typescript@4.5.5
@@ -2062,10 +2068,6 @@ packages:
peerDependencies:
eslint: ^6.2.0 || ^7.0.0 || ^8.0.0
eslint-plugin-vue: ^8.0.1
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
dependencies:
'@typescript-eslint/eslint-plugin': 5.42.1_vfr6z4qvdp6defk3ked6x75zyi
'@typescript-eslint/parser': 5.42.1_4rqwsplhh2ekz63wktwk7d7ht4
@@ -2075,6 +2077,7 @@ packages:
vue-eslint-parser: 8.3.0_eslint@8.27.0
transitivePeerDependencies:
- supports-color
- typescript
dev: true
/@vue/reactivity-transform/3.2.45:
@@ -2103,6 +2106,7 @@ packages:
'@vue/runtime-core': 3.2.45
'@vue/shared': 3.2.45
csstype: 2.6.21
dev: false
/@vue/server-renderer/3.2.45_vue@3.2.45:
resolution: {integrity: sha512-ebiMq7q24WBU1D6uhPK//2OTR1iRIyxjF5iVq/1a5I1SDMDyDu4Ts6fJaMnjrvD3MqnaiFkKQj+LKAgz5WIK3g==}
@@ -2112,6 +2116,7 @@ packages:
'@vue/compiler-ssr': 3.2.45
'@vue/shared': 3.2.45
vue: 3.2.45
dev: false
/@vue/shared/3.2.45:
resolution: {integrity: sha512-Ewzq5Yhimg7pSztDV+RH1UDKBzmtqieXQlpTVm2AwraoRL/Rks96mvd8Vgi7Lj+h+TH8dv7mXD3FRZR3TUvbSg==}
@@ -2856,8 +2861,8 @@ packages:
engines: {node: '>=10'}
hasBin: true
dependencies:
JSONStream: 1.3.5
is-text-path: 1.0.1
JSONStream: 1.3.5
lodash: 4.17.21
meow: 8.1.2
split2: 3.2.2
@@ -2986,6 +2991,7 @@ packages:
/csstype/2.6.21:
resolution: {integrity: sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==}
dev: false
/csstype/3.0.11:
resolution: {integrity: sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==}
@@ -3024,22 +3030,12 @@ packages:
/debug/2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
dependencies:
ms: 2.0.0
dev: true
/debug/3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
dependencies:
ms: 2.1.3
dev: true
@@ -3578,8 +3574,6 @@ packages:
dependencies:
debug: 3.2.7
resolve: 1.22.1
transitivePeerDependencies:
- supports-color
dev: true
/eslint-import-resolver-typescript/3.5.2_dcpv4nbdr5ks2h5677xdltrk6e:
@@ -3592,7 +3586,7 @@ packages:
debug: 4.3.4
enhanced-resolve: 5.10.0
eslint: 8.27.0
eslint-plugin-import: 2.26.0_gbipkkcbnjmysmpjttq6vkmfqq
eslint-plugin-import: 2.26.0_eslint@8.27.0
get-tsconfig: 4.2.0
globby: 13.1.2
is-core-module: 2.11.0
@@ -3602,54 +3596,32 @@ packages:
- supports-color
dev: true
/eslint-module-utils/2.7.4_idrr6ghswzssuopqxluk4kfum4:
/eslint-module-utils/2.7.4_eslint@8.27.0:
resolution: {integrity: sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==}
engines: {node: '>=4'}
peerDependencies:
'@typescript-eslint/parser': '*'
eslint: '*'
eslint-import-resolver-node: '*'
eslint-import-resolver-typescript: '*'
eslint-import-resolver-webpack: '*'
peerDependenciesMeta:
'@typescript-eslint/parser':
optional: true
eslint:
optional: true
eslint-import-resolver-node:
optional: true
eslint-import-resolver-typescript:
optional: true
eslint-import-resolver-webpack:
optional: true
dependencies:
'@typescript-eslint/parser': 5.42.1_4rqwsplhh2ekz63wktwk7d7ht4
debug: 3.2.7
eslint: 8.27.0
eslint-import-resolver-node: 0.3.6
eslint-import-resolver-typescript: 3.5.2_dcpv4nbdr5ks2h5677xdltrk6e
transitivePeerDependencies:
- supports-color
dev: true
/eslint-plugin-import/2.26.0_gbipkkcbnjmysmpjttq6vkmfqq:
/eslint-plugin-import/2.26.0_eslint@8.27.0:
resolution: {integrity: sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==}
engines: {node: '>=4'}
peerDependencies:
'@typescript-eslint/parser': '*'
eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8
peerDependenciesMeta:
'@typescript-eslint/parser':
optional: true
dependencies:
'@typescript-eslint/parser': 5.42.1_4rqwsplhh2ekz63wktwk7d7ht4
array-includes: 3.1.6
array.prototype.flat: 1.3.1
debug: 2.6.9
doctrine: 2.1.0
eslint: 8.27.0
eslint-import-resolver-node: 0.3.6
eslint-module-utils: 2.7.4_idrr6ghswzssuopqxluk4kfum4
eslint-module-utils: 2.7.4_eslint@8.27.0
has: 1.0.3
is-core-module: 2.11.0
is-glob: 4.0.3
@@ -3657,10 +3629,6 @@ packages:
object.values: 1.1.6
resolve: 1.22.1
tsconfig-paths: 3.14.1
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
dev: true
/eslint-plugin-prettier/4.2.1_v7o5sx5x3wbs57ifz6wc4f76we:
@@ -4054,6 +4022,11 @@ packages:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
dev: true
/fuse.js/6.6.2:
resolution: {integrity: sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==}
engines: {node: '>=10'}
dev: false
/gensync/1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
@@ -4831,6 +4804,10 @@ packages:
promise: 7.3.1
dev: true
/jwt-decode/3.1.2:
resolution: {integrity: sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==}
dev: false
/kind-of/6.0.3:
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
engines: {node: '>=0.10.0'}
@@ -4857,8 +4834,6 @@ packages:
mime: 1.6.0
needle: 3.1.0
source-map: 0.6.1
transitivePeerDependencies:
- supports-color
dev: true
/leven/3.1.0:
@@ -5201,8 +5176,6 @@ packages:
debug: 3.2.7
iconv-lite: 0.6.3
sax: 1.2.4
transitivePeerDependencies:
- supports-color
dev: true
optional: true
@@ -6482,6 +6455,10 @@ packages:
engines: {node: '>=8'}
dev: true
/ts-pattern/4.1.3:
resolution: {integrity: sha512-8beXMWTGEv1JfDjSxfNhe4uT5jKYdhmEUKzt4gZW9dmHlquq3b+IbEyA7vX9LjBfzHmvKnM4HiomAUCyaW2Pew==}
dev: false
/tsconfig-paths/3.14.1:
resolution: {integrity: sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==}
dependencies:
@@ -6565,6 +6542,7 @@ packages:
resolution: {integrity: sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==}
engines: {node: '>=4.2.0'}
hasBin: true
dev: true
/uc.micro/1.0.6:
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
@@ -6721,11 +6699,10 @@ packages:
vite: 2.9.15_less@4.1.3
dev: true
/vite-plugin-pwa/0.11.13_7mbbuzxp22mje5bxdolj2b6yg4:
/vite-plugin-pwa/0.11.13_vite@2.9.15:
resolution: {integrity: sha512-Ssj14m3TRVLfkFEAWSMcFE2d1cSdEZyrVTzfY2lSL+umHYvcIFHVDAY143sygtBCb44OPczsAOmWwBTxwOvh7g==}
peerDependencies:
vite: ^2.0.0
workbox-window: ^6.4.0
dependencies:
debug: 4.3.4
fast-glob: 3.2.12
@@ -6991,6 +6968,7 @@ packages:
'@vue/runtime-dom': 3.2.45
'@vue/server-renderer': 3.2.45_vue@3.2.45
'@vue/shared': 3.2.45
dev: false
/vueuc/0.4.49_vue@3.2.45:
resolution: {integrity: sha512-WarAC44a/Yx78CxkAgROYLq+LkAeCGA/6wHidVoFmHLbzyF3SiP2nzRNGD/8zJeJInXv18EnWK6A//eGgMMq8w==}

View File

@@ -0,0 +1,38 @@
<template>
<n-tooltip trigger="hover">
<template #trigger>
<n-button circle quaternary :type="buttonType" :style="{ opacity: isFavorite ? 1 : 0.2 }" @click="toggleFavorite">
<template #icon>
<n-icon :component="FavoriteFilled" />
</template>
</n-button>
</template>
{{ isFavorite ? 'Remove from favorites' : 'Add to favorites' }}
</n-tooltip>
</template>
<script setup lang="ts">
import { FavoriteFilled } from '@vicons/material';
import { useToolStore } from '@/tools/tools.store';
import type { Tool } from '@/tools/tools.types';
import { computed, toRefs } from 'vue';
const toolStore = useToolStore();
const props = defineProps<{ tool: Tool }>();
const { tool } = toRefs(props);
const isFavorite = computed(() => toolStore.isToolFavorite({ tool }));
const buttonType = computed(() => (isFavorite.value ? 'primary' : 'default'));
function toggleFavorite(event: MouseEvent) {
event.preventDefault();
if (toolStore.isToolFavorite({ tool })) {
toolStore.removeToolFromFavorites({ tool });
return;
}
toolStore.addToolToFavorites({ tool });
}
</script>

View File

@@ -6,11 +6,11 @@
</template>
<script setup lang="ts">
import type { ITool } from '@/tools/tool';
import type { Tool } from '@/tools/tools.types';
import { useThemeVars } from 'naive-ui';
import { toRefs } from 'vue';
const props = defineProps<{ tool: ITool }>();
const props = defineProps<{ tool: Tool }>();
const { tool } = toRefs(props);
const theme = useThemeVars();

View File

@@ -1,36 +1,38 @@
<script lang="ts" setup>
import { useFuzzySearch } from '@/composable/fuzzySearch';
import { useTracker } from '@/modules/tracker/tracker.services';
import { tools } from '@/tools';
import type { Tool } from '@/tools/tools.types';
import { SearchRound } from '@vicons/material';
import { useMagicKeys, whenever } from '@vueuse/core';
import { deburr } from 'lodash';
import { computed, ref } from 'vue';
import type { NInput } from 'naive-ui';
import { computed, h, ref } from 'vue';
import { useRouter } from 'vue-router';
import SearchBarItem from './SearchBarItem.vue';
const toolToOption = (tool: Tool) => ({ label: tool.name, value: tool.path, tool });
const router = useRouter();
const { tracker } = useTracker();
const queryString = ref('');
const cleanString = (s: string) => deburr(s.trim().toLowerCase());
const searchableTools = tools.map(({ name, description, keywords, path }) => ({
searchableText: [name, description, ...keywords].map(cleanString).join(' '),
path,
name,
}));
const inputEl = ref<HTMLElement>();
const displayDropDown = ref(true);
const isMac = computed(() => window.navigator.userAgent.toLowerCase().includes('mac'));
const options = computed(() => {
const query = cleanString(queryString.value);
if (queryString.value === '') {
return tools.map(toolToOption);
}
return searchableTools
.filter(({ searchableText }) => searchableText.includes(query))
.map(({ name, path }) => ({ label: name, value: path }));
return searchResult.value.map(toolToOption);
});
function onSelect(path: string) {
router.push(path);
queryString.value = '';
}
const focusTarget = ref();
const { searchResult } = useFuzzySearch({
search: queryString,
data: tools,
options: { keys: [{ name: 'name', weight: 2 }, 'description', 'keywords'] },
});
const keys = useMagicKeys({
passive: false,
@@ -38,12 +40,40 @@ const keys = useMagicKeys({
if (e.ctrlKey && e.key === 'k' && e.type === 'keydown') {
e.preventDefault();
}
if (e.metaKey && e.key === 'k' && e.type === 'keydown') {
e.preventDefault();
}
},
});
whenever(keys.ctrl_k, () => {
focusTarget.value.focus();
});
whenever(keys.ctrl_k, claimFocus);
whenever(keys.meta_k, claimFocus);
whenever(keys.escape, releaseFocus);
function renderOption({ tool }: { tool: Tool }) {
return h(SearchBarItem, { tool });
}
function onSelect(path: string) {
router.push(path);
queryString.value = '';
}
function claimFocus() {
displayDropDown.value = true;
inputEl.value?.focus();
}
function releaseFocus() {
displayDropDown.value = false;
}
function onFocus() {
tracker.trackEvent({ eventName: 'Search-bar focused' });
displayDropDown.value = true;
}
</script>
<template>
@@ -51,16 +81,21 @@ whenever(keys.ctrl_k, () => {
<n-auto-complete
v-model:value="queryString"
:options="options"
:input-props="{ autocomplete: 'disabled' }"
:on-select="onSelect"
:on-select="(value) => onSelect(String(value))"
:render-label="renderOption"
:default-value="'aa'"
:get-show="() => displayDropDown"
:on-focus="onFocus"
@update:value="() => (displayDropDown = true)"
>
<template #default="{ handleInput, handleBlur, handleFocus, value: slotValue }">
<n-input
ref="focusTarget"
ref="inputEl"
round
clearable
placeholder="Search a tool... [Ctrl + K]"
:placeholder="`Search a tool (use ${isMac ? 'Cmd' : 'Ctrl'} + K to focus)`"
:value="slotValue"
:input-props="{ autocomplete: 'disabled' }"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -73,9 +108,3 @@ whenever(keys.ctrl_k, () => {
</n-auto-complete>
</div>
</template>
<style lang="less" scoped>
// ::v-deep(.n-input__border) {
// border: none;
// }
</style>

View File

@@ -0,0 +1,45 @@
<script lang="ts" setup>
import type { Tool } from '@/tools/tools.types';
import { toRefs } from 'vue';
const props = defineProps<{ tool: Tool }>();
const { tool } = toRefs(props);
</script>
<template>
<div class="search-bar-item">
<n-icon class="icon" :component="tool.icon" />
<div>
<div class="name">{{ tool.name }}</div>
<div class="description">{{ tool.description }}</div>
</div>
</div>
</template>
<style lang="less" scoped>
.search-bar-item {
padding: 10px;
display: flex;
flex-direction: row;
align-items: center;
.icon {
font-size: 30px;
margin-right: 10px;
opacity: 0.7;
}
.name {
font-weight: bold;
font-size: 15px;
line-height: 1;
margin-bottom: 5px;
}
.description {
opacity: 0.7;
line-height: 1;
}
}
</style>

View File

@@ -3,17 +3,21 @@
<n-card class="tool-card">
<n-space justify="space-between" align="center">
<n-icon class="icon" size="40" :component="tool.icon" />
<n-tag
v-if="tool.isNew"
size="small"
class="badge-new"
round
type="success"
:bordered="false"
:color="{ color: theme.primaryColor, textColor: theme.tagColor }"
>
New
</n-tag>
<n-space align="center">
<n-tag
v-if="tool.isNew"
size="small"
class="badge-new"
round
type="success"
:bordered="false"
:color="{ color: theme.primaryColor, textColor: theme.tagColor }"
>
New
</n-tag>
<favorite-button :tool="tool" />
</n-space>
</n-space>
<n-h3 class="title">
<n-ellipsis>{{ tool.name }}</n-ellipsis>
@@ -29,11 +33,12 @@
</template>
<script setup lang="ts">
import type { ITool } from '@/tools/tool';
import type { Tool } from '@/tools/tools.types';
import { useThemeVars } from 'naive-ui';
import { toRefs } from 'vue';
import FavoriteButton from './FavoriteButton.vue';
const props = defineProps<{ tool: ITool & { category: string } }>();
const props = defineProps<{ tool: Tool & { category: string } }>();
const { tool } = toRefs(props);
const theme = useThemeVars();
</script>
@@ -50,7 +55,7 @@ a {
.icon {
opacity: 0.6;
color: #ffffff;
color: v-bind('theme.textColorBase');
}
.title {
@@ -59,7 +64,7 @@ a {
.description {
opacity: 0.6;
color: #ffffff;
color: v-bind('theme.textColorBase');
margin: 5px 0;
}
}

View File

@@ -0,0 +1,23 @@
import { get, type MaybeRef } from '@vueuse/core';
import Fuse from 'fuse.js';
import { computed } from 'vue';
export { useFuzzySearch };
function useFuzzySearch<Data>({
search,
data,
options = {},
}: {
search: MaybeRef<string>;
data: Data[];
options?: Fuse.IFuseOptions<Data>;
}) {
const fuse = new Fuse(data, options);
const searchResult = computed(() => {
return fuse.search(get(search)).map(({ item }) => item);
});
return { searchResult };
}

View File

@@ -1,13 +1,15 @@
<script lang="ts" setup>
import { NIcon, useThemeVars, type MenuGroupOption } from 'naive-ui';
import { h } from 'vue';
import { computed, h } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import { Heart, Menu2, Home2 } from '@vicons/tabler';
import { toolsByCategory } from '@/tools';
import { useStyleStore } from '@/stores/style.store';
import { config } from '@/config';
import MenuIconItem from '@/components/MenuIconItem.vue';
import type { ITool } from '@/tools/tool';
import type { Tool } from '@/tools/tools.types';
import { useToolStore } from '@/tools/tools.store';
import { useTracker } from '@/modules/tracker/tracker.services';
import SearchBar from '../components/SearchBar.vue';
import HeroGradient from '../assets/hero-gradient.svg?component';
import MenuLayout from '../components/MenuLayout.vue';
@@ -19,19 +21,30 @@ const styleStore = useStyleStore();
const version = config.app.version;
const commitSha = config.app.lastCommitSha.slice(0, 7);
const makeLabel = (tool: ITool) => () => h(RouterLink, { to: tool.path }, { default: () => tool.name });
const makeIcon = (tool: ITool) => () => h(MenuIconItem, { tool });
const makeLabel = (tool: Tool) => () => h(RouterLink, { to: tool.path }, { default: () => tool.name });
const makeIcon = (tool: Tool) => () => h(MenuIconItem, { tool });
const menuOptions: MenuGroupOption[] = toolsByCategory.map((category) => ({
label: category.name,
key: category.name,
type: 'group',
children: category.components.map((tool) => ({
label: makeLabel(tool),
icon: makeIcon(tool),
key: tool.name,
const { tracker } = useTracker();
const toolStore = useToolStore();
const menuOptions = computed<MenuGroupOption[]>(() =>
[
...(toolStore.favoriteTools.length > 0
? [{ name: 'Your favorite tools', components: toolStore.favoriteTools }]
: []),
...toolsByCategory,
].map((category) => ({
label: category.name,
key: category.name,
type: 'group',
children: category.components.map((tool) => ({
label: makeLabel(tool),
icon: makeIcon(tool),
key: tool.name,
})),
})),
}));
);
</script>
<template>
@@ -145,6 +158,9 @@ const menuOptions: MenuGroupOption[] = toolsByCategory.map((category) => ({
href="https://github.com/sponsors/CorentinTh"
rel="noopener"
target="_blank"
class="support-button"
:bordered="false"
@click="() => tracker.trackEvent({ eventName: 'Support button clicked' })"
>
Buy me a coffee
<n-icon v-if="!styleStore.isSmallScreen" :component="Heart" style="margin-left: 5px" />
@@ -170,6 +186,19 @@ const menuOptions: MenuGroupOption[] = toolsByCategory.map((category) => ({
// background-size: @size @size;
// }
.support-button {
background: rgb(37, 99, 108);
background: linear-gradient(48deg, rgba(37, 99, 108, 1) 0%, rgba(59, 149, 111, 1) 60%, rgba(20, 160, 88, 1) 100%);
color: #fff;
transition: all ease 0.2s;
&:hover {
color: #fff;
padding-left: 30px;
padding-right: 30px;
}
}
.footer {
text-align: center;
color: #838587;

View File

@@ -3,22 +3,22 @@ import { useRoute } from 'vue-router';
import { useHead } from '@vueuse/head';
import type { HeadObject } from '@vueuse/head';
import { computed } from 'vue';
import { useThemeVars } from 'naive-ui';
import FavoriteButton from '@/components/FavoriteButton.vue';
import type { Tool } from '@/tools/tools.types';
import BaseLayout from './base.layout.vue';
const route = useRoute();
const theme = useThemeVars();
const head = computed<HeadObject>(() => ({
title: `${route.meta.name} - IT Tools`,
meta: [
{
name: 'description',
content: route.meta.description,
content: route.meta?.description as string,
},
{
name: 'keywords',
content: route.meta.keywords,
content: ((route.meta.keywords ?? []) as string[]).join(','),
},
],
}));
@@ -29,22 +29,18 @@ useHead(head);
<base-layout>
<div class="tool-layout">
<div class="tool-header">
<n-h1>
{{ route.meta.name }}
<n-space align="center" justify="space-between" :wrap="false">
<n-h1>
{{ route.meta.name }}
</n-h1>
<n-tag
v-if="route.meta.isNew"
round
type="success"
:bordered="false"
:color="{ color: theme.primaryColor, textColor: theme.tagColor }"
>
New tool
</n-tag>
<!-- <span class="new-tool-badge">New !</span> -->
</n-h1>
<div>
<favorite-button :tool="{name: route.meta.name} as Tool" />
</div>
</n-space>
<div class="separator" />
<div class="description">
{{ route.meta.description }}
</div>
@@ -92,6 +88,7 @@ useHead(head);
width: 200px;
height: 2px;
background: rgb(161, 161, 161);
opacity: 0.2;
margin: 10px 0;
}

View File

@@ -0,0 +1,27 @@
import _ from 'lodash';
import type Plausible from 'plausible-tracker';
import { inject } from 'vue';
export { createTrackerService, useTracker };
function createTrackerService({ plausible }: { plausible: ReturnType<typeof Plausible> }) {
return {
trackEvent({ eventName }: { eventName: string }) {
plausible.trackEvent(eventName);
},
};
}
function useTracker() {
const plausible: ReturnType<typeof Plausible> | undefined = inject('plausible');
if (_.isNil(plausible)) {
throw new Error('Plausible must be instantiated');
}
const tracker = createTrackerService({ plausible });
return {
tracker,
};
}

View File

@@ -0,0 +1,3 @@
import type { createTrackerService } from './tracker.services';
export type TrackerService = ReturnType<typeof createTrackerService>;

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import { useTracker } from '@/modules/tracker/tracker.services';
import { useHead } from '@vueuse/head';
useHead({ title: 'About - IT Tools' });
const { tracker } = useTracker();
</script>
<template>
@@ -25,6 +27,7 @@ useHead({ title: 'About - IT Tools' });
href="https://github.com/sponsors/CorentinTh"
rel="noopener"
target="_blank"
@click="() => tracker.trackEvent({ eventName: 'Support button clicked' })"
>
sponsoring me </n-button
>.

View File

@@ -1,41 +1,71 @@
<script setup lang="ts">
import { toolsWithCategory } from '@/tools';
import { useToolStore } from '@/tools/tools.store';
import { Heart } from '@vicons/tabler';
import { useHead } from '@vueuse/head';
import ColoredCard from '../components/ColoredCard.vue';
import ToolCard from '../components/ToolCard.vue';
const toolStore = useToolStore();
useHead({ title: 'IT Tools - Handy online tools for developers' });
</script>
<template>
<div class="home-page">
<n-grid x-gap="12" y-gap="12" cols="1 400:2 800:3 1200:4 2000:8">
<n-gi>
<colored-card title="You like it-tools?" :icon="Heart">
Give us a star on
<a
href="https://github.com/CorentinTh/it-tools"
rel="noopener"
target="_blank"
aria-label="IT-Tools' github repository"
>github</a
>
or follow us on
<a
href="https://twitter.com/ittoolsdottech"
rel="noopener"
target="_blank"
aria-label="IT-Tools' twitter account"
>twitter</a
>! Thank you
<n-icon :component="Heart" />
</colored-card>
</n-gi>
<n-gi v-for="tool in toolsWithCategory" :key="tool.name">
<tool-card :tool="tool" />
</n-gi>
</n-grid>
<div class="grid-wrapper">
<n-grid x-gap="12" y-gap="12" cols="1 400:2 800:3 1200:4 2000:8">
<n-gi>
<colored-card title="You like it-tools?" :icon="Heart">
Give us a star on
<a
href="https://github.com/CorentinTh/it-tools"
rel="noopener"
target="_blank"
aria-label="IT-Tools' github repository"
>github</a
>
or follow us on
<a
href="https://twitter.com/ittoolsdottech"
rel="noopener"
target="_blank"
aria-label="IT-Tools' twitter account"
>twitter</a
>! Thank you
<n-icon :component="Heart" />
</colored-card>
</n-gi>
</n-grid>
<transition name="height">
<div v-if="toolStore.favoriteTools.length > 0">
<n-h3>Your favorite tools</n-h3>
<n-grid x-gap="12" y-gap="12" cols="1 400:2 800:3 1200:4 2000:8">
<n-gi v-for="tool in toolStore.favoriteTools" :key="tool.name">
<tool-card :tool="tool" />
</n-gi>
</n-grid>
</div>
</transition>
<div v-if="toolStore.newTools.length > 0">
<n-h3>Newest tools</n-h3>
<n-grid x-gap="12" y-gap="12" cols="1 400:2 800:3 1200:4 2000:8">
<n-gi v-for="tool in toolStore.newTools" :key="tool.name">
<tool-card :tool="tool" />
</n-gi>
</n-grid>
</div>
<n-h3>All the tools</n-h3>
<n-grid x-gap="12" y-gap="12" cols="1 400:2 800:3 1200:4 2000:8">
<n-gi v-for="tool in toolStore.tools" :key="tool.name">
<transition>
<tool-card :tool="tool" />
</transition>
</n-gi>
</n-grid>
</div>
</div>
</template>
@@ -43,4 +73,27 @@ useHead({ title: 'IT Tools - Handy online tools for developers' });
.home-page {
padding-top: 50px;
}
.n-h3 {
margin-bottom: 10px;
}
::v-deep(.n-grid) {
margin-bottom: 30px;
}
.height-enter-active,
.height-leave-active {
transition: all 0.5s ease-in-out;
overflow: hidden;
max-height: 500px;
}
.height-enter-from,
.height-leave-to {
max-height: 42px;
overflow: hidden;
opacity: 0;
margin-bottom: 0;
}
</style>

View File

@@ -53,9 +53,12 @@ import {
NTooltip,
NUpload,
NUploadDragger,
NPopover,
NCheckbox,
} from 'naive-ui';
const components = [
NCheckbox,
NDynamicInput,
NDatePicker,
NCode,
@@ -109,6 +112,7 @@ const components = [
NIcon,
NSwitch,
NCollapseTransition,
NPopover,
];
export const naive = create({ components });

View File

@@ -1,4 +1,5 @@
import { config } from '@/config';
import Plausible from 'plausible-tracker';
import type { App } from 'vue';
@@ -7,6 +8,6 @@ export const plausible = {
const plausible = Plausible(config.plausible);
plausible.enableAutoPageviews();
app.config.globalProperties.$plausible = plausible;
app.provide('plausible', plausible);
},
};

View File

@@ -6,6 +6,12 @@ export const lightThemeOverrides: GlobalThemeOverrides = {
},
Layout: { color: '#f1f5f9' },
AutoComplete: {
peers: {
InternalSelectMenu: { height: '500px' },
},
},
};
export const darkThemeOverrides: GlobalThemeOverrides = {
@@ -16,6 +22,12 @@ export const darkThemeOverrides: GlobalThemeOverrides = {
primaryColorSuppl: '#36AD6AFF',
},
AutoComplete: {
peers: {
InternalSelectMenu: { height: '500px', color: '#1e1e1e' },
},
},
Menu: {
itemHeight: '32px',
},

View File

@@ -53,5 +53,3 @@ const b64Validation = useValidation({
rules: [{ message: 'Invalid base64 string', validator: (value) => isValidBase64(value.trim()) }],
});
</script>
<style lang="less" scoped></style>

View File

@@ -0,0 +1,68 @@
import { expect, describe, it } from 'vitest';
import { computeChmodOctalRepresentation } from './chmod-calculator.service';
describe('chmod-calculator', () => {
describe('computeChmodOctalRepresentation', () => {
it('get the octal representation from permissions', () => {
expect(
computeChmodOctalRepresentation({
permissions: {
owner: { read: true, write: true, execute: true },
group: { read: true, write: true, execute: true },
public: { read: true, write: true, execute: true },
},
}),
).to.eql('777');
expect(
computeChmodOctalRepresentation({
permissions: {
owner: { read: false, write: false, execute: false },
group: { read: false, write: false, execute: false },
public: { read: false, write: false, execute: false },
},
}),
).to.eql('000');
expect(
computeChmodOctalRepresentation({
permissions: {
owner: { read: false, write: true, execute: false },
group: { read: false, write: true, execute: true },
public: { read: true, write: false, execute: true },
},
}),
).to.eql('235');
expect(
computeChmodOctalRepresentation({
permissions: {
owner: { read: true, write: false, execute: false },
group: { read: false, write: true, execute: false },
public: { read: false, write: false, execute: true },
},
}),
).to.eql('421');
expect(
computeChmodOctalRepresentation({
permissions: {
owner: { read: false, write: false, execute: true },
group: { read: false, write: true, execute: false },
public: { read: true, write: false, execute: false },
},
}),
).to.eql('124');
expect(
computeChmodOctalRepresentation({
permissions: {
owner: { read: false, write: true, execute: false },
group: { read: false, write: true, execute: false },
public: { read: false, write: true, execute: false },
},
}),
).to.eql('222');
});
});
});

View File

@@ -0,0 +1,17 @@
import _ from 'lodash';
import type { GroupPermissions, Permissions } from './chmod-calculator.types';
export { computeChmodOctalRepresentation };
function computeChmodOctalRepresentation({ permissions }: { permissions: Permissions }): string {
const permissionValue = { read: 4, write: 2, execute: 1 };
const getGroupPermissionValue = (permission: GroupPermissions) =>
_.reduce(permission, (acc, isPermSet, key) => acc + (isPermSet ? _.get(permissionValue, key, 0) : 0), 0);
return [
getGroupPermissionValue(permissions.owner),
getGroupPermissionValue(permissions.group),
getGroupPermissionValue(permissions.public),
].join('');
}

View File

@@ -0,0 +1,10 @@
export type Scope = 'read' | 'write' | 'execute';
export type Group = 'owner' | 'group' | 'public';
export type GroupPermissions = {
[k in Scope]: boolean;
};
export type Permissions = {
[k in Group]: GroupPermissions;
};

View File

@@ -0,0 +1,83 @@
<template>
<div>
<n-table :bordered="false" :bottom-bordered="false" single-column class="permission-table">
<thead>
<tr>
<th class="text-center" scope="col"></th>
<th class="text-center" scope="col">Owner (u)</th>
<th class="text-center" scope="col">Group (g)</th>
<th class="text-center" scope="col">Public (o)</th>
</tr>
</thead>
<tbody>
<tr v-for="{ scope, title } of scopes" :key="scope">
<td class="line-header">{{ title }}</td>
<td v-for="group of groups" :key="group" class="text-center">
<!-- <n-switch v-model:value="permissions[group][scope]" /> -->
<n-checkbox v-model:checked="permissions[group][scope]" size="large" />
</td>
</tr>
</tbody>
</n-table>
<div class="octal-result">
{{ octal }}
</div>
<input-copyable :value="`chmod ${octal} path`" readonly style="margin-bottom: 5px" />
</div>
</template>
<script setup lang="ts">
import { useThemeVars } from 'naive-ui';
import { computed, ref } from 'vue';
import { computeChmodOctalRepresentation } from './chmod-calculator.service';
import InputCopyable from '../../components/InputCopyable.vue';
import type { Group, Scope } from './chmod-calculator.types';
const themeVars = useThemeVars();
const scopes: { scope: Scope; title: string }[] = [
{ scope: 'read', title: 'Read (4)' },
{ scope: 'write', title: 'Write (2)' },
{ scope: 'execute', title: 'Execute (1)' },
];
const groups: Group[] = ['owner', 'group', 'public'];
const permissions = ref({
owner: { read: false, write: false, execute: false },
group: { read: false, write: false, execute: false },
public: { read: false, write: false, execute: false },
});
const octal = computed(() => computeChmodOctalRepresentation({ permissions: permissions.value }));
</script>
<style lang="less" scoped>
.octal-result {
text-align: center;
font-size: 50px;
font-family: monospace;
color: v-bind('themeVars.primaryColor');
margin: 20px 0;
}
.permission-table {
td,
th {
padding: 15px;
@media screen and (max-width: 600px) {
padding: 5px;
}
}
}
.line-header {
font-weight: bold;
text-align: right;
max-width: 80px;
}
.text-center {
text-align: center;
}
</style>

View File

@@ -0,0 +1,22 @@
import { FileInvoice } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Chmod calculator',
path: '/chmod-calculator',
description: 'Compute your chmod permissions and commands with this online chmod calculator.',
keywords: [
'chmod',
'calculator',
'file',
'permission',
'files',
'directory',
'folder',
'recursive',
'generator',
'octal',
],
component: () => import('./chmod-calculator.vue'),
icon: FileInvoice,
});

View File

@@ -94,5 +94,3 @@ const hmac = computed(() =>
);
const { copy } = useCopy({ source: hmac });
</script>
<style lang="less" scoped></style>

View File

@@ -1,14 +1,10 @@
import { LockOpen } from '@vicons/tabler';
import type { ToolCategory } from './tool';
import { tool as mimeTypes } from './mime-types';
import { tool as otpCodeGeneratorAndValidator } from './otp-code-generator-and-validator';
import { tool as base64FileConverter } from './base64-file-converter';
import { tool as base64StringConverter } from './base64-string-converter';
import { tool as basicAuthGenerator } from './basic-auth-generator';
import { tool as bcrypt } from './bcrypt';
import { tool as bip39 } from './bip39-generator';
import { tool as caseConverter } from './case-converter';
import { tool as chmodCalculator } from './chmod-calculator';
import { tool as chronometer } from './chronometer';
import { tool as colorConverter } from './color-converter';
import { tool as crontabGenerator } from './crontab-generator';
@@ -22,16 +18,21 @@ import { tool as hmacGenerator } from './hmac-generator';
import { tool as htmlEntities } from './html-entities';
import { tool as baseConverter } from './integer-base-converter';
import { tool as jsonViewer } from './json-viewer';
import { tool as jwtParser } from './jwt-parser';
import { tool as loremIpsumGenerator } from './lorem-ipsum-generator';
import { tool as mathEvaluator } from './math-evaluator';
import { tool as metaTagGenerator } from './meta-tag-generator';
import { tool as mimeTypes } from './mime-types';
import { tool as otpCodeGeneratorAndValidator } from './otp-code-generator-and-validator';
import { tool as qrCodeGenerator } from './qr-code-generator';
import { tool as randomPortGenerator } from './random-port-generator';
import { tool as romanNumeralConverter } from './roman-numeral-converter';
import { tool as sqlPrettify } from './sql-prettify';
import { tool as svgPlaceholderGenerator } from './svg-placeholder-generator';
import { tool as temperatureConverter } from './temperature-converter';
import { tool as textStatistics } from './text-statistics';
import { tool as tokenGenerator } from './token-generator';
import type { ToolCategory } from './tools.types';
import { tool as urlEncoder } from './url-encoder';
import { tool as urlParser } from './url-parser';
import { tool as uuidGenerator } from './uuid-generator';
@@ -39,12 +40,10 @@ import { tool as uuidGenerator } from './uuid-generator';
export const toolsByCategory: ToolCategory[] = [
{
name: 'Crypto',
icon: LockOpen,
components: [tokenGenerator, hashText, bcrypt, uuidGenerator, cypher, bip39, hmacGenerator],
},
{
name: 'Converter',
icon: LockOpen,
components: [
dateTimeConverter,
baseConverter,
@@ -57,7 +56,6 @@ export const toolsByCategory: ToolCategory[] = [
},
{
name: 'Web',
icon: LockOpen,
components: [
urlEncoder,
htmlEntities,
@@ -67,31 +65,27 @@ export const toolsByCategory: ToolCategory[] = [
metaTagGenerator,
otpCodeGeneratorAndValidator,
mimeTypes,
jwtParser,
],
},
{
name: 'Images',
icon: LockOpen,
components: [qrCodeGenerator, svgPlaceholderGenerator],
},
{
name: 'Development',
icon: LockOpen,
components: [gitMemo, randomPortGenerator, crontabGenerator, jsonViewer, sqlPrettify],
components: [gitMemo, randomPortGenerator, crontabGenerator, jsonViewer, sqlPrettify, chmodCalculator],
},
{
name: 'Math',
icon: LockOpen,
components: [mathEvaluator, etaCalculator],
},
{
name: 'Measurement',
icon: LockOpen,
components: [chronometer],
components: [chronometer, temperatureConverter],
},
{
name: 'Text',
icon: LockOpen,
components: [loremIpsumGenerator, textStatistics],
},
];

View File

@@ -7,7 +7,7 @@ export function convertBase({ value, fromBase, toBase }: { value: string; fromBa
.reverse()
.reduce((carry: number, digit: string, index: number) => {
if (!fromRange.includes(digit)) {
throw new Error('Invalid digit `' + digit + '` for base ' + fromBase + '.');
throw new Error('Invalid digit "' + digit + '" for base ' + fromBase + '.');
}
return (carry += fromRange.indexOf(digit) * Math.pow(fromBase, index));
}, 0);

View File

@@ -4,7 +4,7 @@
<div v-if="styleStore.isSmallScreen">
<n-input-group>
<n-input-group-label style="flex: 0 0 120px"> Input number: </n-input-group-label>
<n-input-number v-model:value="inputNumber" min="0" style="width: 100%" />
<n-input v-model:value="input" style="width: 100%" :status="error ? 'error' : undefined" />
</n-input-group>
<n-input-group>
<n-input-group-label style="flex: 0 0 120px"> Input base: </n-input-group-label>
@@ -14,51 +14,65 @@
<n-input-group v-else>
<n-input-group-label style="flex: 0 0 120px"> Input number: </n-input-group-label>
<n-input-number v-model:value="inputNumber" min="0" />
<n-input v-model:value="input" :status="error ? 'error' : undefined" />
<n-input-group-label style="flex: 0 0 120px"> Input base: </n-input-group-label>
<n-input-number v-model:value="inputBase" max="64" min="2" />
</n-input-group>
<n-alert v-if="error" style="margin-top: 25px" type="error">{{ error }}</n-alert>
<n-divider />
<n-input-group>
<n-input-group-label style="flex: 0 0 170px"> Binary (2): </n-input-group-label>
<input-copyable :value="convertBase({ value: String(inputNumber), fromBase: inputBase, toBase: 2 })" readonly />
<input-copyable
:value="errorlessConvert({ value: input, fromBase: inputBase, toBase: 2 })"
readonly
placeholder="Binary version will be here..."
/>
</n-input-group>
<n-input-group>
<n-input-group-label style="flex: 0 0 170px"> Octal (8): </n-input-group-label>
<input-copyable :value="convertBase({ value: String(inputNumber), fromBase: inputBase, toBase: 8 })" readonly />
<input-copyable
:value="errorlessConvert({ value: input, fromBase: inputBase, toBase: 8 })"
readonly
placeholder="Octal version will be here..."
/>
</n-input-group>
<n-input-group>
<n-input-group-label style="flex: 0 0 170px"> Decimal (10): </n-input-group-label>
<input-copyable
:value="convertBase({ value: String(inputNumber), fromBase: inputBase, toBase: 10 })"
:value="errorlessConvert({ value: input, fromBase: inputBase, toBase: 10 })"
readonly
placeholder="Decimal version will be here..."
/>
</n-input-group>
<n-input-group>
<n-input-group-label style="flex: 0 0 170px"> Hexadecimal (16): </n-input-group-label>
<input-copyable
:value="convertBase({ value: String(inputNumber), fromBase: inputBase, toBase: 16 })"
:value="errorlessConvert({ value: input, fromBase: inputBase, toBase: 16 })"
readonly
placeholder="Decimal version will be here..."
/>
</n-input-group>
<n-input-group>
<n-input-group-label style="flex: 0 0 170px"> Base64 (64): </n-input-group-label>
<input-copyable
:value="convertBase({ value: String(inputNumber), fromBase: inputBase, toBase: 64 })"
:value="errorlessConvert({ value: input, fromBase: inputBase, toBase: 64 })"
readonly
placeholder="Base64 version will be here..."
/>
</n-input-group>
<n-input-group>
<n-input-group-label style="flex: 0 0 85px"> Custom: </n-input-group-label>
<n-input-number v-model:value="outputBase" style="flex: 0 0 86px" max="64" min="2" />
<input-copyable
:value="convertBase({ value: String(inputNumber), fromBase: inputBase, toBase: outputBase })"
:value="errorlessConvert({ value: input, fromBase: inputBase, toBase: outputBase })"
readonly
:placeholder="`Base ${outputBase} will be here...`"
/>
</n-input-group>
</n-card>
@@ -66,16 +80,31 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useStyleStore } from '@/stores/style.store';
import { getErrorMessageIfThrows } from '@/utils/error';
import { convertBase } from './integer-base-converter.model';
import InputCopyable from '../../components/InputCopyable.vue';
const styleStore = useStyleStore();
const inputNumber = ref(42);
const input = ref('42');
const inputBase = ref(10);
const outputBase = ref(42);
function errorlessConvert(...args: Parameters<typeof convertBase>) {
try {
return convertBase(...args);
} catch (err) {
return '';
}
}
const error = computed(() =>
getErrorMessageIfThrows(() =>
convertBase({ value: input.value, fromBase: inputBase.value, toBase: outputBase.value }),
),
);
</script>
<style lang="less" scoped>

View File

@@ -0,0 +1,27 @@
import { Key } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'JWT parser',
path: '/jwt-parser',
description: 'Parse and decode your JSON Web Token (jwt) and display its content.',
keywords: [
'jwt',
'parser',
'decode',
'typ',
'alg',
'iss',
'sub',
'aud',
'exp',
'nbf',
'iat',
'jti',
'json',
'web',
'token',
],
component: () => import('./jwt-parser.vue'),
icon: Key,
});

View File

@@ -0,0 +1,92 @@
// From https://datatracker.ietf.org/doc/html/rfc7518#section-3.1
export const ALGORITHM_DESCRIPTIONS: { [k: string]: string } = {
HS256: 'HMAC using SHA-256',
HS384: 'HMAC using SHA-384',
HS512: 'HMAC using SHA-512',
RS256: 'RSASSA-PKCS1-v1_5 using SHA-256',
RS384: 'RSASSA-PKCS1-v1_5 using SHA-384',
RS512: 'RSASSA-PKCS1-v1_5 using SHA-512',
ES256: 'ECDSA using P-256 and SHA-256',
ES384: 'ECDSA using P-384 and SHA-384',
ES512: 'ECDSA using P-521 and SHA-512',
PS256: 'RSASSA-PSS using SHA-256 and MGF1 with SHA-256',
PS384: 'RSASSA-PSS using SHA-384 and MGF1 with SHA-384',
PS512: 'RSASSA-PSS using SHA-512 and MGF1 with SHA-512',
none: 'No digital signature or MAC performed',
};
// List extracted from IANA: https://www.iana.org/assignments/jwt/jwt.xhtml
export const CLAIM_DESCRIPTIONS: Record<string, string> = {
typ: 'Type',
alg: 'Algorithm',
iss: 'Issuer',
sub: 'Subject',
aud: 'Audience',
exp: 'Expiration Time',
nbf: 'Not Before',
iat: 'Issued At',
jti: 'JWT ID',
name: 'Full name',
given_name: 'Given name(s) or first name(s)',
family_name: 'Surname(s) or last name(s)',
middle_name: 'Middle name(s)',
nickname: 'Casual name',
preferred_username: 'Shorthand name by which the End-User wishes to be referred to',
profile: 'Profile page URL',
picture: 'Profile picture URL',
website: 'Web page or blog URL',
email: 'Preferred e-mail address',
email_verified: 'True if the e-mail address has been verified; otherwise false',
gender: 'Gender',
birthdate: 'Birthday',
zoneinfo: 'Time zone',
locale: 'Locale',
phone_number: 'Preferred telephone number',
phone_number_verified: 'True if the phone number has been verified; otherwise false',
address: 'Preferred postal address',
updated_at: 'Time the information was last updated',
azp: 'Authorized party - the party to which the ID Token was issued',
nonce: 'Value used to associate a Client session with an ID Token',
auth_time: 'Time when the authentication occurred',
at_hash: 'Access Token hash value',
c_hash: 'Code hash value',
acr: 'Authentication Context Class Reference',
amr: 'Authentication Methods References',
sub_jwk: 'Public key used to check the signature of an ID Token',
cnf: 'Confirmation',
sip_from_tag: 'SIP From tag header field parameter value',
sip_date: 'SIP Date header field value',
sip_callid: 'SIP Call-Id header field value',
sip_cseq_num: 'SIP CSeq numeric header field parameter value',
sip_via_branch: 'SIP Via branch header field parameter value',
orig: 'Originating Identity String',
dest: 'Destination Identity String',
mky: 'Media Key Fingerprint String',
events: 'Security Events',
toe: 'Time of Event',
txn: 'Transaction Identifier',
rph: 'Resource Priority Header Authorization',
sid: 'Session ID',
vot: 'Vector of Trust value',
vtm: 'Vector of Trust trustmark URL',
attest: 'Attestation level as defined in SHAKEN framework',
origid: 'Originating Identifier as defined in SHAKEN framework',
act: 'Actor',
scope: 'Scope Values',
client_id: 'Client Identifier',
may_act: 'Authorized Actor - the party that is authorized to become the actor',
jcard: 'jCard data',
at_use_nbr: 'Number of API requests for which the access token can be used',
div: 'Diverted Target of a Call',
opt: 'Original PASSporT (in Full Form)',
vc: 'Verifiable Credential as specified in the W3C Recommendation',
vp: 'Verifiable Presentation as specified in the W3C Recommendation',
sph: 'SIP Priority header field',
ace_profile: 'ACE profile a token is supposed to be used with.',
cnonce: 'Client nonce',
exi: 'Expires in',
roles: 'Roles',
groups: 'Groups',
entitlements: 'Entitlements',
token_introspection: 'Token introspection response',
};

View File

@@ -0,0 +1,46 @@
import jwtDecode, { type JwtHeader, type JwtPayload } from 'jwt-decode';
import _ from 'lodash';
import { match } from 'ts-pattern';
import { ALGORITHM_DESCRIPTIONS, CLAIM_DESCRIPTIONS } from './jwt-parser.constants';
export { decodeJwt };
function decodeJwt({ jwt }: { jwt: string }) {
const rawHeader = jwtDecode<JwtHeader>(jwt, { header: true });
const rawPayload = jwtDecode<JwtPayload>(jwt);
const header = _.map(rawHeader, (value, claim) => parseClaims({ claim, value }));
const payload = _.map(rawPayload, (value, claim) => parseClaims({ claim, value }));
return {
header,
payload,
};
}
function parseClaims({ claim, value }: { claim: string; value: unknown }) {
const claimDescription = CLAIM_DESCRIPTIONS[claim];
const formattedValue = _.toString(value);
const friendlyValue = getFriendlyValue({ claim, value });
return {
value: formattedValue,
friendlyValue,
claim,
claimDescription,
};
}
function getFriendlyValue({ claim, value }: { claim: string; value: unknown }) {
return match(claim)
.with('exp', 'nbf', 'iat', () => dateFormatter(value))
.with('alg', () => (_.isString(value) ? ALGORITHM_DESCRIPTIONS[value] : undefined))
.otherwise(() => undefined);
}
const dateFormatter = (value: unknown) => {
if (_.isNil(value)) return undefined;
const date = new Date(Number(value) * 1000);
return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
};

View File

@@ -0,0 +1,70 @@
<template>
<n-card>
<n-form-item label="JWT to decode" :feedback="validation.message" :validation-status="validation.status">
<n-input v-model:value="rawJwt" type="textarea" placeholder="Put your token here..." rows="5" />
</n-form-item>
<n-table v-if="validation.isValid">
<tbody>
<template v-for="section of sections" :key="section.key">
<th colspan="2" class="table-header">{{ section.title }}</th>
<tr v-for="{ claim, claimDescription, friendlyValue, value } in decodedJWT[section.key]" :key="claim + value">
<td class="claims">
<n-space>
<n-text strong>{{ claim }}</n-text>
<template v-if="claimDescription">
<n-text depth="3">({{ claimDescription }})</n-text>
</template>
</n-space>
</td>
<td>
<n-space>
<n-text>{{ value }}</n-text>
<template v-if="friendlyValue">
<n-text depth="3">({{ friendlyValue }})</n-text>
</template>
</n-space>
</td>
</tr>
</template>
</tbody>
</n-table>
</n-card>
</template>
<script setup lang="ts">
import { useValidation } from '@/composable/validation';
import { isNotThrowing } from '@/utils/boolean';
import { withDefaultOnError } from '@/utils/defaults';
import { computed, ref } from 'vue';
import { decodeJwt } from './jwt-parser.service';
const rawJwt = ref(
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c',
);
const decodedJWT = computed(() =>
withDefaultOnError(() => decodeJwt({ jwt: rawJwt.value }), { header: [], payload: [] }),
);
const sections = [
{ key: 'header', title: 'Header' },
{ key: 'payload', title: 'Payload' },
] as const;
const validation = useValidation({
source: rawJwt,
rules: [
{
validator: (value) => value.length > 0 && isNotThrowing(() => decodeJwt({ jwt: rawJwt.value })),
message: 'Invalid JWT',
},
],
});
</script>
<style lang="less" scoped>
.table-header {
text-align: center;
}
</style>

View File

@@ -29,5 +29,3 @@ const expression = ref('');
const result = computed(() => withDefaultOnError(() => evaluate(expression.value) ?? '', ''));
</script>
<style lang="less" scoped></style>

View File

@@ -95,5 +95,3 @@ const selectedExtension = ref(undefined);
const mimeTypeFound = computed(() => (selectedExtension.value ? extensionToMimeType[selectedExtension.value] : []));
</script>
<style lang="less" scoped></style>

View File

@@ -0,0 +1,24 @@
import { Temperature } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Temperature converter',
path: '/temperature-converter',
description:
'Temperature degrees conversions for Kelvin, Celsius, Fahrenheit, Rankine, Delisle, Newton, Réaumur and Rømer.',
keywords: [
'temperature',
'converter',
'degree',
'Kelvin',
'Celsius',
'Fahrenheit',
'Rankine',
'Delisle',
'Newton',
'Réaumur',
'Rømer',
],
component: () => import('./temperature-converter.vue'),
icon: Temperature,
});

View File

@@ -0,0 +1,20 @@
export const convertCelsiusToKelvin = (temperature: number) => temperature + 273.15;
export const convertKelvinToCelsius = (temperature: number) => temperature - 273.15;
export const convertFahrenheitToKelvin = (temperature: number) => (temperature + 459.67) * (5 / 9);
export const convertKelvinToFahrenheit = (temperature: number) => temperature * (9 / 5) - 459.67;
export const convertRankineToKelvin = (temperature: number) => temperature * (5 / 9);
export const convertKelvinToRankine = (temperature: number) => temperature * (9 / 5);
export const convertDelisleToKelvin = (temperature: number) => 373.15 - (2 / 3) * temperature;
export const convertKelvinToDelisle = (temperature: number) => (3 / 2) * (373.15 - temperature);
export const convertNewtonToKelvin = (temperature: number) => temperature * (100 / 33) + 273.15;
export const convertKelvinToNewton = (temperature: number) => (temperature - 273.15) * (33 / 100);
export const convertReaumurToKelvin = (temperature: number) => temperature * (5 / 4) + 273.15;
export const convertKelvinToReaumur = (temperature: number) => (temperature - 273.15) * (4 / 5);
export const convertRomerToKelvin = (temperature: number) => (temperature - 7.5) * (40 / 21) + 273.15;
export const convertKelvinToRomer = (temperature: number) => (temperature - 273.15) * (21 / 40) + 7.5;

View File

@@ -0,0 +1,127 @@
<template>
<div>
<n-input-group
v-for="[key, { title, unit }] in Object.entries(units)"
:key="key"
style="width: 100%; margin-bottom: 15px"
>
<n-input-group-label style="width: 100px">
{{ title }}
</n-input-group-label>
<n-input-number
v-model:value="units[key].ref"
style="flex: 1"
@update:value="() => update(key as TemperatureScale)"
/>
<n-input-group-label style="width: 50px">
{{ unit }}
</n-input-group-label>
</n-input-group>
</div>
</template>
<script setup lang="ts">
import _ from 'lodash';
import { reactive } from 'vue';
import {
convertCelsiusToKelvin,
convertDelisleToKelvin,
convertFahrenheitToKelvin,
convertKelvinToCelsius,
convertKelvinToDelisle,
convertKelvinToFahrenheit,
convertKelvinToNewton,
convertKelvinToRankine,
convertKelvinToReaumur,
convertKelvinToRomer,
convertNewtonToKelvin,
convertRankineToKelvin,
convertReaumurToKelvin,
convertRomerToKelvin,
} from './temperature-converter.models';
type TemperatureScale = 'kelvin' | 'celsius' | 'fahrenheit' | 'rankine' | 'delisle' | 'newton' | 'reaumur' | 'romer';
const units = reactive<
Record<
string | TemperatureScale,
{ title: string; unit: string; ref: number; toKelvin: (v: number) => number; fromKelvin: (v: number) => number }
>
>({
kelvin: {
title: 'Kelvin',
unit: 'K',
ref: 0,
toKelvin: _.identity,
fromKelvin: _.identity,
},
celsius: {
title: 'Celsius',
unit: '°C',
ref: 0,
toKelvin: convertCelsiusToKelvin,
fromKelvin: convertKelvinToCelsius,
},
fahrenheit: {
title: 'Fahrenheit',
unit: '°F',
ref: 0,
toKelvin: convertFahrenheitToKelvin,
fromKelvin: convertKelvinToFahrenheit,
},
rankine: {
title: 'Rankine',
unit: '°R',
ref: 0,
toKelvin: convertRankineToKelvin,
fromKelvin: convertKelvinToRankine,
},
delisle: {
title: 'Delisle',
unit: '°De',
ref: 0,
toKelvin: convertDelisleToKelvin,
fromKelvin: convertKelvinToDelisle,
},
newton: {
title: 'Newton',
unit: '°N',
ref: 0,
toKelvin: convertNewtonToKelvin,
fromKelvin: convertKelvinToNewton,
},
reaumur: {
title: 'Réaumur',
unit: '°Ré',
ref: 0,
toKelvin: convertReaumurToKelvin,
fromKelvin: convertKelvinToReaumur,
},
romer: {
title: 'Rømer',
unit: '°Rø',
ref: 0,
toKelvin: convertRomerToKelvin,
fromKelvin: convertKelvinToRomer,
},
});
function update(key: TemperatureScale) {
const { ref: value, toKelvin } = units[key];
const kelvins = toKelvin(value) ?? 0;
_.chain(units)
.omit(key)
.forEach(({ fromKelvin }, index) => {
units[index].ref = Math.floor((fromKelvin(kelvins) ?? 0) * 100) / 100;
})
.value();
}
update('kelvin');
</script>
<style lang="less" scoped></style>

View File

@@ -1,27 +1,10 @@
import { config } from '@/config';
import type { Component } from 'vue';
export interface ITool {
name: string;
path: string;
description: string;
keywords: string[];
component: () => Promise<Component>;
icon: Component;
redirectFrom?: string[];
isNew: boolean;
}
export interface ToolCategory {
name: string;
icon: Component;
components: ITool[];
}
import type { Tool } from './tools.types';
type WithOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
export function defineTool(
tool: WithOptional<ITool, 'isNew'>,
tool: WithOptional<Tool, 'isNew'>,
{ newTools }: { newTools: string[] } = { newTools: config.tools.newTools },
) {
const isNew = newTools.includes(tool.name);

44
src/tools/tools.store.ts Normal file
View File

@@ -0,0 +1,44 @@
import { get, useStorage, type MaybeRef } from '@vueuse/core';
import { defineStore } from 'pinia';
import type { Ref } from 'vue';
import { toolsWithCategory } from './index';
import type { Tool, ToolWithCategory } from './tools.types';
export const useToolStore = defineStore('tools', {
state: () => ({
favoriteToolsName: useStorage('favoriteToolsName', []) as Ref<string[]>,
}),
getters: {
favoriteTools(state) {
return state.favoriteToolsName
.map((favoriteName) => toolsWithCategory.find(({ name }) => name === favoriteName))
.filter(Boolean) as ToolWithCategory[]; // cast because .filter(Boolean) does not remove undefined from type
},
notFavoriteTools(state): ToolWithCategory[] {
return toolsWithCategory.filter((tool) => !state.favoriteToolsName.includes(tool.name));
},
tools(): ToolWithCategory[] {
return toolsWithCategory;
},
newTools(): ToolWithCategory[] {
return this.tools.filter(({ isNew }) => isNew);
},
},
actions: {
addToolToFavorites({ tool }: { tool: MaybeRef<Tool> }) {
this.favoriteToolsName.push(get(tool).name);
},
removeToolFromFavorites({ tool }: { tool: MaybeRef<Tool> }) {
this.favoriteToolsName = this.favoriteToolsName.filter((name) => get(tool).name !== name);
},
isToolFavorite({ tool }: { tool: MaybeRef<Tool> }) {
return this.favoriteToolsName.includes(get(tool).name);
},
},
});

19
src/tools/tools.types.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { Component } from 'vue';
export type Tool = {
name: string;
path: string;
description: string;
keywords: string[];
component: () => Promise<Component>;
icon: Component;
redirectFrom?: string[];
isNew: boolean;
};
export type ToolCategory = {
name: string;
components: Tool[];
};
export type ToolWithCategory = Tool & { category: string };

29
src/utils/error.test.ts Normal file
View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { getErrorMessageIfThrows } from './error';
describe('error util', () => {
describe('getErrorMessageIfThrows', () => {
it('get an error message if the callback throws, undefined instead', () => {
expect(
getErrorMessageIfThrows(() => {
throw 'message';
}),
).to.equal('message');
expect(
getErrorMessageIfThrows(() => {
throw new Error('message');
}),
).to.equal('message');
expect(
getErrorMessageIfThrows(() => {
throw { message: 'message' };
}),
).to.equal('message');
// eslint-disable-next-line @typescript-eslint/no-empty-function
expect(getErrorMessageIfThrows(() => {})).to.equal(undefined);
});
});
});

24
src/utils/error.ts Normal file
View File

@@ -0,0 +1,24 @@
import _ from 'lodash';
export { getErrorMessageIfThrows };
function getErrorMessageIfThrows(cb: () => unknown) {
try {
cb();
return undefined;
} catch (err) {
if (_.isString(err)) {
return err;
}
if (_.isError(err)) {
return err.message;
}
if (_.isObject(err) && _.has(err, 'message')) {
return (err as { message: string }).message;
}
return 'An error as occurred.';
}
}