Compare commits

...

100 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
Corentin Thomasset
94698cea50 chore(release): 2.13.0 2022-11-14 22:17:34 +01:00
Corentin Thomasset
8294cd68da chore(deps): upgraded dependencies 2022-11-14 22:13:37 +01:00
Corentin Thomasset
7c9b8ac178 feat(new-tool): mime type to extension converter 2022-11-14 22:13:26 +01:00
Corentin Thomasset
5d8f46abf8 refactor(ui): change sponsor button location and caption 2022-11-13 23:16:57 +01:00
Corentin Thomasset
35a3760771 fix(ui): remove icon transparency overlap 2022-11-13 23:13:50 +01:00
Corentin Thomasset
4ef25887b9 feat(date-converter): added mongodb objectID format 2022-09-09 11:53:06 +02:00
Corentin Thomasset
7f229959d6 refactor(otp-generator): changed url 2022-08-24 00:22:54 +02:00
Corentin Thomasset
d3a2936979 chore(release): 2.12.0 2022-08-24 00:18:01 +02:00
Corentin Thomasset
5f16885923 feat(new-tool): added otp generator 2022-08-24 00:18:01 +02:00
Corentin Thomasset
ea5e7a7fc7 feat(config): added tsx to allowed extension 2022-08-24 00:18:01 +02:00
Corentin Thomasset
7de6c86f9e refactor(useQRCode): switched args to MaybeRef 2022-08-24 00:18:01 +02:00
Corentin Thomasset
83da6b7ee9 refactor: token generator can use a custom alphabet 2022-08-24 00:18:01 +02:00
Corentin Thomasset
737319edf1 chore(release): 2.11.0 2022-08-19 17:47:34 +02:00
Corentin Thomasset
a77a82f5a2 chore: updated ci workflow to handle pnpm 2022-08-19 17:42:46 +02:00
Corentin Thomasset
da17696293 refactor(colored-card): added transition on like hover 2022-08-19 17:40:13 +02:00
Corentin Thomasset
164e32b442 feat(new-tool): meta tag generator 2022-08-19 17:40:00 +02:00
Corentin Thomasset
49755909bd fix(deps): added missing optional deps 2022-08-18 10:53:23 +02:00
Corentin Thomasset
44d653b1f2 chore(deps): switched to pnpm 2022-08-18 10:35:22 +02:00
Corentin Thomasset
7c449f4f2d fix: removed colored card border 2022-08-17 17:33:44 +02:00
Corentin Thomasset
ab7483b5c2 feat: added colored share card 2022-08-17 17:06:48 +02:00
Corentin THOMASSET
5222bd5d04 refactor(share): updated share meta 2022-08-16 15:22:41 +02:00
Corentin Thomasset
cf5e4d9056 chore(release): 2.10.3 2022-08-14 10:55:06 +02:00
Corentin Thomasset
992f96b48a refactor(share): updated twitter meta tags 2022-08-14 10:53:45 +02:00
Corentin Thomasset
fcf4cfe64d refactor(share): new share banner 2022-08-14 10:53:39 +02:00
Corentin Thomasset
f54223fb0a refactor(validation): simplified validation management with helpers 2022-08-04 21:59:48 +02:00
Corentin Thomasset
b38ab82d05 chore(release): 2.10.2 2022-08-04 23:29:47 +02:00
Corentin Thomasset
f6cd9b76d3 refactor(dry): mutualised duplicated code with withDefaultOnError 2022-08-04 23:14:32 +02:00
Corentin Thomasset
208a373fd0 refactor(lint): added import rules 2022-08-04 22:46:50 +02:00
Corentin Thomasset
8089c60000 refactor(json-prettifier): more permissive json parser 2022-08-04 22:18:15 +02:00
Corentin Thomasset
d30cd8a9ab refactor(home): removed new tool first sort 2022-08-04 21:59:22 +02:00
Corentin Thomasset
04a8e122be chore(release): 2.10.1 2022-08-04 12:16:54 +02:00
Corentin Thomasset
447bdf2148 refactor(base64): mutualized base64 functions into global utilities 2022-08-04 12:09:32 +02:00
Corentin Thomasset
ca7cb44389 fix(bip39-generator): cleared an issue with the mnemonic validation 2022-08-04 12:08:23 +02:00
Corentin Thomasset
e48d60b1ed refactor(chronometer): improved chronometer precision 2022-08-04 09:06:42 +02:00
Corentin Thomasset
fda0b0ca25 fix(import): removed auto added weird .js extension 2022-08-04 08:50:15 +02:00
Corentin Thomasset
cc717bc87e chore(release): 2.10.0 2022-08-03 17:25:48 +02:00
Corentin Thomasset
1bc6380c6f feat(new-tool): hmac generator 2022-08-03 17:19:53 +02:00
Corentin Thomasset
02c4963531 refactor(lint): externalization of prettier for simpler IDE support 2022-08-03 17:19:53 +02:00
Corentin Thomasset
129f74c371 feat(new-tool): an svg placeholder image generator 2022-08-03 17:18:18 +02:00
Corentin Thomasset
0be33fb337 refactor(display): mutualized code display 2022-08-03 17:18:18 +02:00
Corentin THOMASSET
422b6eb05a feat(hash-text): digest base selector (#254) 2022-08-03 17:16:03 +02:00
Corentin Thomasset
fad4833ca2 chore(deps): updated dependencies 2022-08-03 17:13:34 +02:00
marvin-j97
531a25c1c4 fix(eta-calculator): clamp inputs (#249) 2022-08-03 17:13:33 +02:00
Corentin Thomasset
77b5b0cab5 refactor(validation): simplified validation system 2022-08-03 17:13:33 +02:00
Corentin Thomasset
7570ad9656 fix(bip39-generator): typo in validation message 2022-08-03 17:13:33 +02:00
Corentin Thomasset
8a9e7888de fix(base64-to-string): prevent validation error 2022-08-03 17:13:33 +02:00
Corentin Thomasset
750a76b00f refactor(base64-to-file): clean validation to convert base64 to file 2022-08-03 17:13:33 +02:00
marvin-j97
5f03619ab4 fix(wording): removed spaces before ponctuation (#252) 2022-08-03 08:46:59 +02:00
Corentin Thomasset
352365f012 chore(release): 2.9.2 2022-07-28 19:31:16 +02:00
Corentin Thomasset
4f599b6999 fix(device-information): handle of unknown values 2022-07-28 19:30:36 +02:00
JWB
138149e6f0 fix(device-information): prevent unwanted y-truncature of text
* Device information page styling fix

I was checking out your site, and really like it! already added to my bookmarks, and when I was on the Device Information page I noticed the `p` and `y` in the words 1 dppx and landscape-primary were cut off and my ocd kicked in to tweak it.
The fix was to remove the line height set on the value, and then to keep the spacing how it was just moving it up 5px seemed to do the trick.

I'm using Firefox 102.0.1 on Windows 11. I'll attach some pics so you can see the before and after.

I've never messed with vue, but after looking at this file here ya make me want to check it out more. I love how its keeping it all together in one file.
Might also need to rebuild it the site after this change, but you get the point. 

Awesome site!

* Update src/tools/device-information/device-information.vue

Co-authored-by: Corentin THOMASSET <corentin.thomasset74@gmail.com>
2022-07-28 19:28:23 +02:00
Corentin Thomasset
412de23796 fix(base64-file): fixed url slug 2022-07-25 23:31:06 +02:00
Corentin Thomasset
1a22d55b3c refactor(base64-file): fixed typo 2022-07-25 23:23:53 +02:00
Corentin Thomasset
bb4aac6d4a chore(release): 2.9.1 2022-07-25 23:22:32 +02:00
Corentin Thomasset
e6953d1b67 refactor(base64): split base64 text and file conversion in two tools + base64 to file 2022-07-25 23:21:42 +02:00
Corentin Thomasset
a70a0f83a1 chore(release): 2.9.0 2022-07-25 18:48:09 +02:00
Corentin Thomasset
bdee93a9e4 feat(new-tool): added a basic auth generator 2022-07-25 18:47:27 +02:00
Corentin Thomasset
08ce407a01 chore(release): 2.8.0 2022-07-24 18:36:15 +02:00
Corentin Thomasset
125a50215a feat(new-tool): added an ETA calculator 2022-07-24 18:32:05 +02:00
Corentin Thomasset
d5738e1aef chore(deps): upgraded naive-ui version 2022-07-24 18:24:44 +02:00
Corentin Thomasset
560fcf3f78 fix(sql-prettifier): better responsiveness 2022-07-24 15:54:05 +02:00
Corentin Thomasset
328fda65b3 refactor(json-prettify): improved layout for the json prettifier 2022-07-24 15:10:38 +02:00
Corentin Thomasset
ba87097e3d refactor(sql-prettifier): remove unused service files 2022-07-24 15:06:57 +02:00
Corentin Thomasset
99383d25fc chore(release): 2.7.0 2022-07-24 14:39:40 +02:00
Corentin Thomasset
d1f95f5b34 feat(new-tool): added an SQL prettifier and formatter 2022-07-24 14:37:27 +02:00
Corentin Thomasset
6cd25a743e fix(typo): fix few typos 2022-07-24 10:38:28 +02:00
Corentin Thomasset
d2f5d3c3de chore(release): 2.6.0 2022-07-24 00:58:59 +02:00
Corentin Thomasset
130031c225 feat(new-tool): added chronometer 2022-07-24 00:58:18 +02:00
Corentin Thomasset
1c7257eeb0 fix(script):fixed missing paranthesis in tool generator script 2022-07-23 23:07:53 +02:00
Corentin Thomasset
214084262c refactor(import): auto reordered imports 2022-07-23 23:04:57 +02:00
Corentin Thomasset
92ce419f45 fix(text-statistics): empty text mean 0 words and 0 lines 2022-07-23 19:09:22 +02:00
Corentin Thomasset
394d085846 refactor: added accessibility labels on icon buttons 2022-07-23 18:55:26 +02:00
Corentin Thomasset
ab53048d5f feat(search): focus the search bar using Ctrl+K 2022-07-23 18:49:10 +02:00
Corentin Thomasset
c3a302bc38 fix(lint): cleanned index.html 2022-07-23 18:19:40 +02:00
Corentin Thomasset
a16161cdb4 fix(deps): run dependencie audit auto fix 2022-07-23 18:19:06 +02:00
Corentin Thomasset
1dc113aaef chore(release): 2.5.3 2022-07-21 21:13:42 +02:00
Corentin Thomasset
e371e8fedf fix: updated license in README 2022-07-21 21:13:01 +02:00
126 changed files with 11429 additions and 20400 deletions

View File

@@ -7,23 +7,29 @@ module.exports = {
'plugin:vue/vue3-essential',
'eslint:recommended',
'plugin:vue/vue3-recommended',
'plugin:vue/vue3-recommended',
'@vue/eslint-config-typescript/recommended',
'@vue/eslint-config-prettier',
'plugin:import/recommended',
],
settings: {
'import/resolver': { typescript: { project: './tsconfig.app.json' } },
},
env: {
'vue/setup-compiler-macros': true,
},
rules: {
'vue/multi-word-component-names': ['off'],
'prettier/prettier': [
'prettier/prettier': ['error'],
'import/no-duplicates': ['error', { considerQueryString: true }],
'import/order': ['error', { groups: [['builtin', 'external', 'internal']] }],
'import/extensions': [
'error',
'ignorePackages',
{
singleQuote: true,
semi: true,
tabWidth: 2,
trailingComma: 'all',
printWidth: 120,
js: 'never',
ts: 'never',
tsx: 'never',
},
],
},

View File

@@ -7,32 +7,21 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@master
with:
fetch-depth: 0
- name: Setup node env
uses: actions/setup-node@v3.0.0
- uses: actions/checkout@v3
- run: corepack enable
- uses: actions/setup-node@v3
with:
node-version: 16
- name: Cache node_modules
uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
cache: 'pnpm'
- name: Install dependencies
run: npm ci
run: pnpm i
- name: Run linters
run: npm run lint
run: pnpm lint
- name: Run unit test
run: npm run test
run: pnpm test
- name: Build the app
run: npm run build
run: pnpm build

2
.gitignore vendored
View File

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

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"singleQuote": true,
"semi": true,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 120
}

View File

@@ -2,6 +2,275 @@
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)
### Features
* **config:** added tsx to allowed extension ([ea5e7a7](https://github.com/CorentinTh/it-tools/commit/ea5e7a7fc7df1a3a912193912a6ab80a8a36a256))
* **date-converter:** added mongodb objectID format ([4ef2588](https://github.com/CorentinTh/it-tools/commit/4ef25887b9d874b8789bf8dbabd8aab92b4b1b03))
* **new-tool:** added otp generator ([5f16885](https://github.com/CorentinTh/it-tools/commit/5f168859238e9c3a8b8bbaf6b550c4b9bd163e00))
* **new-tool:** mime type to extension converter ([7c9b8ac](https://github.com/CorentinTh/it-tools/commit/7c9b8ac178967151a4f921ac26e8c2fe8d23b886))
### Bug Fixes
* **ui:** remove icon transparency overlap ([35a3760](https://github.com/CorentinTh/it-tools/commit/35a376077116dd65b21f9a0786d2ecfc14db6051))
### Refactors
* **otp-generator:** changed url ([7f22995](https://github.com/CorentinTh/it-tools/commit/7f229959d64b7a932f32753e3838d87a819a9192))
* token generator can use a custom alphabet ([83da6b7](https://github.com/CorentinTh/it-tools/commit/83da6b7ee9db29e40faf288f9627257aa7124038))
* **ui:** change sponsor button location and caption ([5d8f46a](https://github.com/CorentinTh/it-tools/commit/5d8f46abf8d5a10cc4650efc87b12a9a6c537fe5))
* **useQRCode:** switched args to MaybeRef ([7de6c86](https://github.com/CorentinTh/it-tools/commit/7de6c86f9ead8d7315614cc508dfee4fed90e9c2))
## [2.12.0](https://github.com/CorentinTh/it-tools/compare/v2.10.3...v2.12.0) (2022-08-23)
### Features
* added colored share card ([ab7483b](https://github.com/CorentinTh/it-tools/commit/ab7483b5c2bd5aee1b8b609597c22b7b7b55606d))
* **config:** added tsx to allowed extension ([741a3c2](https://github.com/CorentinTh/it-tools/commit/741a3c25a915d8296987b23bda03f2b664d51ba6))
* **new-tool:** added otp generator ([cc6070a](https://github.com/CorentinTh/it-tools/commit/cc6070a16655bce9de90517bdda3bf6224ba139d))
* **new-tool:** meta tag generator ([164e32b](https://github.com/CorentinTh/it-tools/commit/164e32b4428b8dfaaddcefa06b767a8af94573a9))
### Bug Fixes
* **deps:** added missing optional deps ([4975590](https://github.com/CorentinTh/it-tools/commit/49755909bdaea9399e51b67fbd1a6d071acd3182))
* removed colored card border ([7c449f4](https://github.com/CorentinTh/it-tools/commit/7c449f4f2d491ce58726c5419a74dc295fa92905))
### Refactors
* **colored-card:** added transition on like hover ([da17696](https://github.com/CorentinTh/it-tools/commit/da17696293270005b1b7ec4aafc0df7496f602c7))
* **share:** updated share meta ([5222bd5](https://github.com/CorentinTh/it-tools/commit/5222bd5d04ad089ba4cbade399dada55e29dcde5))
* token generator can use a custom alphabet ([59ec629](https://github.com/CorentinTh/it-tools/commit/59ec6293b65526fe8dc527ac596d0e5af29b1e32))
* **useQRCode:** switched args to MaybeRef ([a89c9be](https://github.com/CorentinTh/it-tools/commit/a89c9bea42d598f4caba10800becd66a07bbcdc9))
## [2.11.0](https://github.com/CorentinTh/it-tools/compare/v2.10.3...v2.11.0) (2022-08-19)
### Features
* added colored share card ([ab7483b](https://github.com/CorentinTh/it-tools/commit/ab7483b5c2bd5aee1b8b609597c22b7b7b55606d))
* **new-tool:** meta tag generator ([164e32b](https://github.com/CorentinTh/it-tools/commit/164e32b4428b8dfaaddcefa06b767a8af94573a9))
### Bug Fixes
* **deps:** added missing optional deps ([4975590](https://github.com/CorentinTh/it-tools/commit/49755909bdaea9399e51b67fbd1a6d071acd3182))
* removed colored card border ([7c449f4](https://github.com/CorentinTh/it-tools/commit/7c449f4f2d491ce58726c5419a74dc295fa92905))
### Refactors
* **colored-card:** added transition on like hover ([da17696](https://github.com/CorentinTh/it-tools/commit/da17696293270005b1b7ec4aafc0df7496f602c7))
* **share:** updated share meta ([5222bd5](https://github.com/CorentinTh/it-tools/commit/5222bd5d04ad089ba4cbade399dada55e29dcde5))
### [2.10.3](https://github.com/CorentinTh/it-tools/compare/v2.10.2...v2.10.3) (2022-08-14)
### Refactors
* **share:** new share banner ([fcf4cfe](https://github.com/CorentinTh/it-tools/commit/fcf4cfe64d4c1c3814137c8ff23b83a1ca0d502d))
* **share:** updated twitter meta tags ([992f96b](https://github.com/CorentinTh/it-tools/commit/992f96b48a89e2793ccf75fb9e28b2ec7b7f62b6))
* **validation:** simplified validation management with helpers ([f54223f](https://github.com/CorentinTh/it-tools/commit/f54223fb0aaedbd101b5d3dc4176053533bb936a))
### [2.10.2](https://github.com/CorentinTh/it-tools/compare/v2.10.1...v2.10.2) (2022-08-04)
### Refactors
* **dry:** mutualised duplicated code with withDefaultOnError ([f6cd9b7](https://github.com/CorentinTh/it-tools/commit/f6cd9b76d38800e1a1f63d07152fc96cda562795))
* **home:** removed new tool first sort ([d30cd8a](https://github.com/CorentinTh/it-tools/commit/d30cd8a9abc3298c0a0b05f249e54318bb4537f2))
* **json-prettifier:** more permissive json parser ([8089c60](https://github.com/CorentinTh/it-tools/commit/8089c60000000c42c821c6586c128d3d2b248885))
* **lint:** added import rules ([208a373](https://github.com/CorentinTh/it-tools/commit/208a373fd08ac550778745eb6e4536bf02537da7))
### [2.10.1](https://github.com/CorentinTh/it-tools/compare/v2.10.0...v2.10.1) (2022-08-04)
### Bug Fixes
* **bip39-generator:** cleared an issue with the mnemonic validation ([ca7cb44](https://github.com/CorentinTh/it-tools/commit/ca7cb4438972ca09f28a6a40332ec94ceaa4aab4))
* **import:** removed auto added weird .js extension ([fda0b0c](https://github.com/CorentinTh/it-tools/commit/fda0b0ca25c1733542a4e797ac1a2150c546a660))
### Refactors
* **base64:** mutualized base64 functions into global utilities ([447bdf2](https://github.com/CorentinTh/it-tools/commit/447bdf2148098d70ba309e13d9b1e846b5064da1))
* **chronometer:** improved chronometer precision ([e48d60b](https://github.com/CorentinTh/it-tools/commit/e48d60b1ed19279f48441743f7ed69e8fd915011))
## [2.10.0](https://github.com/CorentinTh/it-tools/compare/v2.9.2...v2.10.0) (2022-08-03)
### Features
* **hash-text:** digest base selector ([#254](https://github.com/CorentinTh/it-tools/issues/254)) ([422b6eb](https://github.com/CorentinTh/it-tools/commit/422b6eb05a2fb5e7eec816a6bd2d37b53e4a6bdc))
* **new-tool:** an svg placeholder image generator ([129f74c](https://github.com/CorentinTh/it-tools/commit/129f74c371eaf09fdc3a19afb709cee40b7aaf7f))
* **new-tool:** hmac generator ([1bc6380](https://github.com/CorentinTh/it-tools/commit/1bc6380c6fdd7a9b500422a54bc508ab5557eb46))
### Bug Fixes
* **base64-to-string:** prevent validation error ([8a9e788](https://github.com/CorentinTh/it-tools/commit/8a9e7888dec41364c8c17b1234adcdc0616612b0))
* **bip39-generator:** typo in validation message ([7570ad9](https://github.com/CorentinTh/it-tools/commit/7570ad965602233f860b9e03177a5b9dacf1b034))
* **eta-calculator:** clamp inputs ([#249](https://github.com/CorentinTh/it-tools/issues/249)) ([531a25c](https://github.com/CorentinTh/it-tools/commit/531a25c1c4892835633ba5635c6ee48e1fbef31c))
* **wording:** removed spaces before ponctuation ([#252](https://github.com/CorentinTh/it-tools/issues/252)) ([5f03619](https://github.com/CorentinTh/it-tools/commit/5f03619ab44c0b35455c46698ec37d79e87555b5))
### Refactors
* **base64-to-file:** clean validation to convert base64 to file ([750a76b](https://github.com/CorentinTh/it-tools/commit/750a76b00fb79c0e9c2851c112141158ee0ffab1))
* **display:** mutualized code display ([0be33fb](https://github.com/CorentinTh/it-tools/commit/0be33fb337e8d82474922c0fdf9555aa328cd729))
* **lint:** externalization of prettier for simpler IDE support ([02c4963](https://github.com/CorentinTh/it-tools/commit/02c49635315661ca08deb0859c5ba33113368b9b))
* **validation:** simplified validation system ([77b5b0c](https://github.com/CorentinTh/it-tools/commit/77b5b0cab50a05dcb419ce87d74517d82e7cd2c0))
### [2.9.2](https://github.com/CorentinTh/it-tools/compare/v2.9.1...v2.9.2) (2022-07-28)
### Bug Fixes
* **base64-file:** fixed url slug ([412de23](https://github.com/CorentinTh/it-tools/commit/412de23796babbc080b0768a75029ff2ddf2acfc))
* **device-information:** handle of unknown values ([4f599b6](https://github.com/CorentinTh/it-tools/commit/4f599b699901a93444bcc67cbb3b3556a0561ae4))
* **device-information:** prevent unwanted y-truncature of text ([138149e](https://github.com/CorentinTh/it-tools/commit/138149e6f0be91255907a6083887898e5c68882e))
### Refactors
* **base64-file:** fixed typo ([1a22d55](https://github.com/CorentinTh/it-tools/commit/1a22d55b3c48f58b05b5a50de4fea260e781fbef))
### [2.9.1](https://github.com/CorentinTh/it-tools/compare/v2.9.0...v2.9.1) (2022-07-25)
### Refactors
* **base64:** split base64 text and file conversion in two tools + base64 to file ([e6953d1](https://github.com/CorentinTh/it-tools/commit/e6953d1b67b81a6d3c19973b706f29637c421f98))
## [2.9.0](https://github.com/CorentinTh/it-tools/compare/v2.8.0...v2.9.0) (2022-07-25)
### Features
* **new-tool:** added a basic auth generator ([bdee93a](https://github.com/CorentinTh/it-tools/commit/bdee93a9e45c6b46e7f75cdcbe1907f138722dca))
## [2.8.0](https://github.com/CorentinTh/it-tools/compare/v2.7.0...v2.8.0) (2022-07-24)
### Features
* **new-tool:** added an ETA calculator ([125a502](https://github.com/CorentinTh/it-tools/commit/125a50215a7abb9e0b59dbbc62aee49007b05ffe))
### Bug Fixes
* **sql-prettifier:** better responsiveness ([560fcf3](https://github.com/CorentinTh/it-tools/commit/560fcf3f783c66b9197e4a015420c43a729518bc))
### Refactors
* **json-prettify:** improved layout for the json prettifier ([328fda6](https://github.com/CorentinTh/it-tools/commit/328fda65b3490869328467c5e2d5f538c689d9b6))
* **sql-prettifier:** remove unused service files ([ba87097](https://github.com/CorentinTh/it-tools/commit/ba87097e3d834b6ea3212d28c2c33badb95f85e1))
## [2.7.0](https://github.com/CorentinTh/it-tools/compare/v2.6.0...v2.7.0) (2022-07-24)
### Features
* **new-tool:** added an SQL prettifier and formatter ([d1f95f5](https://github.com/CorentinTh/it-tools/commit/d1f95f5b34a4570f1033a5289f0bd009d1aefb0c))
### Bug Fixes
* **typo:** fix few typos ([6cd25a7](https://github.com/CorentinTh/it-tools/commit/6cd25a743e32fceeaec8c1f8b94927a9c5d901f1))
## [2.6.0](https://github.com/CorentinTh/it-tools/compare/v2.5.3...v2.6.0) (2022-07-23)
### Features
* **new-tool:** added chronometer ([130031c](https://github.com/CorentinTh/it-tools/commit/130031c2256f3d4d46948974b9de85ee6e92bf8b))
* **search:** focus the search bar using Ctrl+K ([ab53048](https://github.com/CorentinTh/it-tools/commit/ab53048d5f6fdca7d00edbb79dee1a5409e6b11e))
### Bug Fixes
* **deps:** run dependencie audit auto fix ([a16161c](https://github.com/CorentinTh/it-tools/commit/a16161cdb48c064882b9dc91ec3d091d286f5c63))
* **lint:** cleanned index.html ([c3a302b](https://github.com/CorentinTh/it-tools/commit/c3a302bc389a0e13aef4b14d5a9d3ec3a0d32729))
* **text-statistics:** empty text mean 0 words and 0 lines ([92ce419](https://github.com/CorentinTh/it-tools/commit/92ce419f45e110509ab202485a36bf175ce345da))
### Refactors
* added accessibility labels on icon buttons ([394d085](https://github.com/CorentinTh/it-tools/commit/394d085846d976219ea775c21cd7e77f0f72a12b))
* **import:** auto reordered imports ([2140842](https://github.com/CorentinTh/it-tools/commit/214084262cec7fb881fd397626356b080ea1a5cc))
### [2.5.3](https://github.com/CorentinTh/it-tools/compare/v2.5.2...v2.5.3) (2022-07-21)
### Bug Fixes
* updated license in README ([e371e8f](https://github.com/CorentinTh/it-tools/commit/e371e8fedfd68f3cf6ecd3fbc9e2da8849f7d5bd))
### [2.5.2](https://github.com/CorentinTh/it-tools/compare/v2.5.1...v2.5.2) (2022-07-21)
### [2.5.1](https://github.com/CorentinTh/it-tools/compare/v2.5.0...v2.5.1) (2022-06-01)

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 [MIT license](LICENSE).
This project is under the [GNU GPLv3](LICENSE).

View File

@@ -6,11 +6,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IT Tools - Handy online tools for developers</title>
<meta itemprop="name" content="IT Tools - Handy online tools for developers" />
<meta name="description" content="Collection of handy online tools for developers, with great UX. IT Tools is a free and open-source collection of handy online tools for developers & people working in IT." />
<meta itemprop="description" content="Collection of handy online tools for developers, with great UX. IT Tools is a free and open-source collection of handy online tools for developers & people working in IT." />
<meta
name="description"
content="Collection of handy online tools for developers, with great UX. IT Tools is a free and open-source collection of handy online tools for developers & people working in IT."
/>
<meta
itemprop="description"
content="Collection of handy online tools for developers, with great UX. IT Tools is a free and open-source collection of handy online tools for developers & people working in IT."
/>
<link rel="author" href="/humans.txt" />
<link rel=canonical href="https://it-tools.tech">
<link rel="canonical" href="https://it-tools.tech" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
@@ -21,15 +27,23 @@
<meta property="og:url" content="https://it-tools.tech/" />
<meta property="og:type" content="website" />
<meta property="og:title" content="IT Tools - Handy online tools for developers" />
<meta property="og:description" content="Collection of handy online tools for developers, with great UX. IT Tools is a free and open-source collection of handy online tools for developers & people working in IT." />
<meta property="og:image" content="/banner.png" />
<meta
property="og:description"
content="Collection of handy online tools for developers, with great UX. IT Tools is a free and open-source collection of handy online tools for developers & people working in IT."
/>
<meta property="og:image" content="https://it-tools.tech/banner.png?v=2" />
<meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:domain" content="it-tools.tech" />
<meta property="twitter:url" content="https://it-tools.tech/" />
<meta name="twitter:site" content="@ittoolsdottech" />
<meta name="twitter:creator" content="@cthmsst" />
<meta name="twitter:title" content="IT Tools - Handy online tools for developers" />
<meta name="twitter:description" content="Collection of handy online tools for developers, with great UX. IT Tools is a free and open-source collection of handy online tools for developers & people working in IT." />
<meta name="twitter:image" content="/banner.png" />
<meta
name="twitter:description"
content="Collection of handy online tools for developers, with great UX. IT Tools is a free and open-source collection of handy online tools for developers & people working in IT."
/>
<meta name="twitter:image" content="https://it-tools.tech/banner.png?v=2" />
<meta name="twitter:image:alt" content="IT Tools - Handy online tools for developers" />
</head>
<body>
<div id="app"></div>

19894
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "it-tools",
"version": "2.5.2",
"version": "2.17.0",
"description": "Collection of handy online tools for developers, with great UX. ",
"keywords": [
"productivity",
@@ -32,59 +32,73 @@
},
"dependencies": {
"@it-tools/bip39": "^0.0.4",
"@it-tools/oggen": "^1.3.0",
"@vicons/material": "^0.12.0",
"@vicons/tabler": "^0.12.0",
"@vueuse/core": "^8.2.1",
"@vueuse/head": "^0.7.5",
"@vueuse/core": "^8.9.4",
"@vueuse/head": "^0.7.13",
"bcryptjs": "^2.4.3",
"change-case": "^4.1.2",
"colord": "^2.9.2",
"colord": "^2.9.3",
"cron-validator": "^1.3.1",
"cronstrue": "^2.2.0",
"cronstrue": "^2.15.0",
"crypto-js": "^4.1.1",
"date-fns": "^2.28.0",
"date-fns": "^2.29.3",
"figue": "^1.2.0",
"highlight.js": "^11.5.1",
"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.0",
"naive-ui": "^2.28.0",
"pinia": "^2.0.11",
"plausible-tracker": "^0.3.5",
"qrcode": "^1.5.0",
"mathjs": "^10.6.4",
"mime-types": "^2.1.35",
"naive-ui": "^2.33.5",
"pinia": "^2.0.23",
"plausible-tracker": "^0.3.8",
"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.31",
"vue-router": "^4.0.12"
"vue": "^3.2.45",
"vue-router": "^4.1.6"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.1.0",
"@rushstack/eslint-patch": "^1.2.0",
"@types/bcryptjs": "^2.4.2",
"@types/crypto-js": "^4.1.1",
"@types/jsdom": "^16.2.14",
"@types/node": "^16.11.25",
"@types/qrcode": "^1.4.2",
"@types/jsdom": "^16.2.15",
"@types/lodash": "^4.14.188",
"@types/mime-types": "^2.1.1",
"@types/node": "^16.18.3",
"@types/qrcode": "^1.5.0",
"@types/randombytes": "^2.0.0",
"@types/uuid": "^8.3.4",
"@vitejs/plugin-vue": "^2.2.2",
"@vitejs/plugin-vue-jsx": "^1.3.7",
"@typescript-eslint/parser": "^5.42.1",
"@vitejs/plugin-vue": "^2.3.4",
"@vitejs/plugin-vue-jsx": "^1.3.10",
"@vue/eslint-config-prettier": "^7.0.0",
"@vue/eslint-config-typescript": "^10.0.0",
"@vue/test-utils": "^2.0.0-rc.18",
"@vue/test-utils": "^2.2.2",
"@vue/tsconfig": "^0.1.3",
"c8": "^7.11.0",
"eslint": "^8.5.0",
"eslint-plugin-vue": "^8.2.0",
"c8": "^7.12.0",
"eslint": "^8.27.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-vue": "^8.7.1",
"jsdom": "^19.0.0",
"less": "^4.1.2",
"prettier": "^2.6.2",
"standard-version": "^9.3.2",
"less": "^4.1.3",
"prettier": "^2.7.1",
"standard-version": "^9.5.0",
"start-server-and-test": "^1.14.0",
"typescript": "~4.5.5",
"vite": "^2.9.1",
"vite": "^2.9.15",
"vite-plugin-md": "^0.12.4",
"vite-plugin-pwa": "^0.11.13",
"vite-svg-loader": "^3.2.0",
"vite-svg-loader": "^3.6.0",
"vitest": "^0.13.1",
"vue-tsc": "^0.31.4"
"vue-tsc": "^0.31.4",
"workbox-window": "^6.5.4"
}
}

7356
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 155 KiB

View File

@@ -1,6 +1,6 @@
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { mkdir, readFile, writeFile } from 'fs/promises';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const currentDirname = dirname(fileURLToPath(import.meta.url));
@@ -55,7 +55,7 @@ export const tool = defineTool({
keywords: ['${toolName.split('-').join("', '")}'],
component: () => import('./${toolName}.vue'),
icon: ArrowsShuffle,
};
});
`,
);

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import { layouts } from './layouts';
import { computed } from 'vue';
import { useRoute, RouterView } from 'vue-router';
import { darkThemeOverrides, lightThemeOverrides } from './themes';
import { darkTheme, NGlobalStyle, NMessageProvider } from 'naive-ui';
import { darkThemeOverrides, lightThemeOverrides } from './themes';
import { layouts } from './layouts';
import { useStyleStore } from './stores/style.store';
const route = useRoute();

View File

@@ -0,0 +1,59 @@
<template>
<n-card class="colored-card">
<n-space justify="space-between" align="center">
<n-icon class="icon" size="40" :component="icon" />
</n-space>
<n-h3 class="title">
<n-ellipsis>{{ title }}</n-ellipsis>
</n-h3>
<div class="description">
<n-ellipsis :line-clamp="2" :tooltip="false">
<slot />
</n-ellipsis>
</div>
</n-card>
</template>
<script setup lang="ts">
import { toRefs, type Component } from 'vue';
const props = defineProps<{ icon: Component; title: string }>();
const { icon, title } = toRefs(props);
</script>
<style lang="less" scoped>
.colored-card {
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;
border: none;
.icon {
opacity: 0.7;
}
.title {
color: #fff;
margin: 5px 0;
}
.description {
opacity: 0.8;
margin: 5px 0;
::v-deep(a) {
color: inherit;
text-decoration: underline;
font-weight: bold;
transition: color ease 0.2s;
&:hover {
color: rgb(20, 20, 20);
}
}
}
}
</style>

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

@@ -9,6 +9,7 @@
href="https://github.com/CorentinTh/it-tools"
rel="noopener"
target="_blank"
aria-label="IT-Tools' github repository"
>
<n-icon size="25" :component="BrandGithub" />
</n-button>
@@ -26,17 +27,18 @@
href="https://twitter.com/ittoolsdottech"
rel="noopener"
target="_blank"
aria-label="IT Tools' twitter account"
>
<n-icon size="25" :component="BrandTwitter" />
</n-button>
</template>
Creator twitter
IT Tools' twitter account
</n-tooltip>
<router-link to="/about" #="{ navigate, href }" custom>
<n-tooltip trigger="hover">
<template #trigger>
<n-button tag="a" :href="href" circle quaternary size="large" aria-label="Home" @click="navigate">
<n-button tag="a" :href="href" circle quaternary size="large" aria-label="About" @click="navigate">
<n-icon size="25" :component="InfoCircle" />
</n-button>
</template>
@@ -45,7 +47,7 @@
</router-link>
<n-tooltip trigger="hover">
<template #trigger>
<n-button size="large" circle quaternary @click="isDarkTheme = !isDarkTheme">
<n-button size="large" circle quaternary aria-label="Toggle dark/light mode" @click="isDarkTheme = !isDarkTheme">
<n-icon v-if="isDarkTheme" size="25" :component="Sun" />
<n-icon v-else size="25" :component="Moon" />
</n-button>
@@ -57,8 +59,8 @@
<script setup lang="ts">
import { useStyleStore } from '@/stores/style.store';
import { BrandGithub, BrandTwitter, InfoCircle, Moon, Sun } from '@vicons/tabler';
import { toRefs } from 'vue';
import { BrandGithub, BrandTwitter, Moon, Sun, InfoCircle } from '@vicons/tabler';
const styleStore = useStyleStore();
const { isDarkTheme } = toRefs(styleStore);

View File

@@ -1,33 +1,79 @@
<script lang="ts" setup>
import { SearchRound } from '@vicons/material';
import { computed, ref } from 'vue';
import { deburr } from 'lodash';
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 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);
});
const { searchResult } = useFuzzySearch({
search: queryString,
data: tools,
options: { keys: [{ name: 'name', weight: 2 }, 'description', 'keywords'] },
});
const keys = useMagicKeys({
passive: false,
onEventFired(e) {
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, 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>
@@ -35,15 +81,21 @@ function onSelect(path: string) {
<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="inputEl"
round
clearable
placeholder="Search a tool..."
:placeholder="`Search a tool (use ${isMac ? 'Cmd' : 'Ctrl'} + K to focus)`"
:value="slotValue"
:input-props="{ autocomplete: 'disabled' }"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -56,9 +108,3 @@ function onSelect(path: string) {
</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

@@ -0,0 +1,100 @@
<template>
<div style="overflow-x: hidden; width: 100%">
<n-card class="result-card">
<n-scrollbar
x-scrollable
trigger="none"
:style="height ? `min-height: ${height - 40 /* card padding */ + 10 /* negative margin compensation */}px` : ''"
>
<n-config-provider :hljs="hljs">
<n-code :code="value" :language="language" :trim="false" />
</n-config-provider>
</n-scrollbar>
<n-tooltip v-if="value" trigger="hover">
<template #trigger>
<div class="copy-button" :class="[copyPlacement]">
<n-button secondary circle size="large" @click="onCopyClicked">
<n-icon size="22" :component="Copy" />
</n-button>
</div>
</template>
<span>{{ tooltipText }}</span>
</n-tooltip>
</n-card>
<n-space v-if="copyPlacement === 'outside'" justify="center" style="margin-top: 15px">
<n-button secondary @click="onCopyClicked"> {{ tooltipText }} </n-button>
</n-space>
</div>
</template>
<script setup lang="ts">
import { Copy } from '@vicons/tabler';
import { useClipboard, useElementSize } from '@vueuse/core';
import hljs from 'highlight.js/lib/core';
import jsonHljs from 'highlight.js/lib/languages/json';
import sqlHljs from 'highlight.js/lib/languages/sql';
import xmlHljs from 'highlight.js/lib/languages/xml';
import { ref, toRefs } from 'vue';
hljs.registerLanguage('sql', sqlHljs);
hljs.registerLanguage('json', jsonHljs);
hljs.registerLanguage('html', xmlHljs);
const props = withDefaults(
defineProps<{
value: string;
followHeightOf?: HTMLElement | null;
language?: string;
copyPlacement?: 'top-right' | 'bottom-right' | 'outside' | 'none';
copyMessage?: string;
}>(),
{
followHeightOf: null,
language: 'txt',
copyPlacement: 'top-right',
copyMessage: 'Copy to clipboard',
},
);
const { value, language, followHeightOf, copyPlacement, copyMessage } = toRefs(props);
const { height } = followHeightOf ? useElementSize(followHeightOf) : { height: ref(null) };
const { copy } = useClipboard({ source: value });
const tooltipText = ref(copyMessage.value);
function onCopyClicked() {
copy();
tooltipText.value = 'Copied !';
setTimeout(() => {
tooltipText.value = copyMessage.value;
}, 2000);
}
</script>
<style lang="less" scoped>
::v-deep(.n-scrollbar) {
padding-bottom: 10px;
margin-bottom: -10px;
}
.result-card {
position: relative;
.copy-button {
position: absolute;
opacity: 1;
&.top-right {
top: 10px;
right: 10px;
}
&.bottom-right {
bottom: 10px;
right: 10px;
}
&.outside,
&.none {
display: none;
}
}
}
</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>
@@ -49,7 +54,8 @@ a {
}
.icon {
opacity: 0.7;
opacity: 0.6;
color: v-bind('theme.textColorBase');
}
.title {
@@ -57,7 +63,8 @@ a {
}
.description {
opacity: 0.7;
opacity: 0.6;
color: v-bind('theme.textColorBase');
margin: 5px 0;
}
}

View File

@@ -1,11 +1,36 @@
import { extension as getExtensionFromMime } from 'mime-types';
import type { Ref } from 'vue';
export function useDownloadFileFromBase64({ source, filename = 'file' }: { source: Ref<string>; filename?: string }) {
function getFileExtensionFromBase64({
base64String,
defaultExtension = 'txt',
}: {
base64String: string;
defaultExtension?: string;
}) {
const hasMimeType = base64String.match(/data:(.*?);base64/i);
if (hasMimeType) {
return getExtensionFromMime(hasMimeType[1]) || defaultExtension;
}
return defaultExtension;
}
export function useDownloadFileFromBase64({ source, filename }: { source: Ref<string>; filename?: string }) {
return {
download() {
const base64String = source.value;
if (base64String === '') {
throw new Error('Base64 string is empty');
}
const cleanFileName = filename ?? `file.${getFileExtensionFromBase64({ base64String })}`;
const a = document.createElement('a');
a.href = source.value;
a.download = filename;
a.href = base64String;
a.download = cleanFileName;
a.click();
},
};

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

@@ -0,0 +1,29 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { describe, expect, it } from 'vitest';
import { isFalsyOrHasThrown } from './validation';
describe('useValidation', () => {
describe('isFalsyOrHasThrown', () => {
it('should return true if the callback return nil, false or throw', () => {
expect(isFalsyOrHasThrown(() => false)).toBe(true);
expect(isFalsyOrHasThrown(() => null)).toBe(true);
expect(isFalsyOrHasThrown(() => undefined)).toBe(true);
expect(isFalsyOrHasThrown(() => {})).toBe(true);
expect(
isFalsyOrHasThrown(() => {
throw new Error();
}),
).toBe(true);
});
it('should return true for any truthy values and empty string and 0 values', () => {
expect(isFalsyOrHasThrown(() => true)).toBe(false);
expect(isFalsyOrHasThrown(() => 'string')).toBe(false);
expect(isFalsyOrHasThrown(() => 1)).toBe(false);
expect(isFalsyOrHasThrown(() => 0)).toBe(false);
expect(isFalsyOrHasThrown(() => '')).toBe(false);
expect(isFalsyOrHasThrown(() => [])).toBe(false);
expect(isFalsyOrHasThrown(() => ({}))).toBe(false);
});
});
});

View File

@@ -1,38 +1,65 @@
import _ from 'lodash';
import { reactive, watch, type Ref } from 'vue';
type UseValidationRule<T> = {
validator: (value: T) => boolean;
message: string;
};
type ValidatorReturnType = unknown;
function isFalsyOrHasThrown(cb: () => boolean) {
interface UseValidationRule<T> {
validator: (value: T) => ValidatorReturnType;
message: string;
}
export function isFalsyOrHasThrown(cb: () => ValidatorReturnType): boolean {
try {
return !cb();
const returnValue = cb();
if (_.isNil(returnValue)) return true;
return returnValue === false;
} catch (_) {
return true;
}
}
type ValidationAttrs = {
feedback: string;
validationStatus: string | undefined;
};
export function useValidation<T>({ source, rules }: { source: Ref<T>; rules: UseValidationRule<T>[] }) {
const state = reactive<{
message: string;
status: undefined | 'error';
isValid: boolean;
attrs: ValidationAttrs;
}>({
message: '',
status: undefined,
isValid: false,
attrs: {
validationStatus: undefined,
feedback: '',
},
});
watch([source], () => {
state.message = '';
state.status = undefined;
watch(
[source],
() => {
state.message = '';
state.status = undefined;
for (const rule of rules) {
if (isFalsyOrHasThrown(() => rule.validator(source.value))) {
state.message = rule.message;
state.status = 'error';
for (const rule of rules) {
if (isFalsyOrHasThrown(() => rule.validator(source.value))) {
state.message = rule.message;
state.status = 'error';
}
}
}
});
state.isValid = state.status !== 'error';
state.attrs.feedback = state.message;
state.attrs.validationStatus = state.status;
},
{ immediate: true },
);
return state;
}

View File

@@ -1,17 +1,19 @@
<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 SearchBar from '../components/SearchBar.vue';
import { useStyleStore } from '@/stores/style.store';
import { config } from '@/config';
import MenuIconItem from '@/components/MenuIconItem.vue';
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';
import NavbarButtons from '../components/NavbarButtons.vue';
import { config } from '@/config';
import MenuIconItem from '@/components/MenuIconItem.vue';
import type { ITool } from '@/tools/tool';
const themeVars = useThemeVars();
const route = useRoute();
@@ -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>
@@ -107,7 +120,7 @@ const menuOptions: MenuGroupOption[] = toolsByCategory.map((category) => ({
:size="styleStore.isSmallScreen ? 'medium' : 'large'"
circle
quaternary
aria-label="Toogle menu"
aria-label="Toggle menu"
@click="styleStore.isMenuCollapsed = !styleStore.isMenuCollapsed"
>
<n-icon size="25" :component="Menu2" />
@@ -134,23 +147,27 @@ const menuOptions: MenuGroupOption[] = toolsByCategory.map((category) => ({
<search-bar />
<navbar-buttons v-if="!styleStore.isSmallScreen" />
<n-tooltip trigger="hover">
<template #trigger>
<n-button
round
type="primary"
tag="a"
href="https://github.com/sponsors/CorentinTh"
rel="noopener"
target="_blank"
class="support-button"
:bordered="false"
@click="() => tracker.trackEvent({ eventName: 'Support button clicked' })"
>
<n-icon v-if="!styleStore.isSmallScreen" :component="Heart" style="margin-right: 5px" />
Sponsor
Buy me a coffee
<n-icon v-if="!styleStore.isSmallScreen" :component="Heart" style="margin-left: 5px" />
</n-button>
</template>
Support IT Tools developement !
Support IT Tools development !
</n-tooltip>
<navbar-buttons v-if="!styleStore.isSmallScreen" />
</div>
<slot />
</template>
@@ -169,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

@@ -1,24 +1,24 @@
<script lang="ts" setup>
import { useRoute } from 'vue-router';
import BaseLayout from './base.layout.vue';
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

@@ -1,6 +1,7 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { createHead } from '@vueuse/head';
// eslint-disable-next-line import/no-unresolved
import { registerSW } from 'virtual:pwa-register';
import { plausible } from './plugins/plausible.plugin';

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

@@ -6,7 +6,7 @@ useHead({ title: 'Page not found - IT Tools' });
<template>
<div class="e404-wrapper">
<n-result status="404" title="404 Not Found" description="Sorry, this page does not seem to extist">
<n-result status="404" title="404 Not Found" description="Sorry, this page does not seem to exist">
<template #footer>
<router-link to="/" #="{ navigate, href }" custom>
<n-button tag="a" :href="href" secondary type="success" @click="navigate"> Back home </n-button>

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>
@@ -11,11 +13,11 @@ useHead({ title: 'About - IT Tools' });
This wonderful website, made with by
<n-button text tag="a" href="https://github.com/CorentinTh" target="_blank" rel="noopener" type="primary">
Corentin Thomasset </n-button
>, aggregates useful tools for developer and people working in IT. If you find it usefull, please fell free to
share it to people you think may find it usefull too and dont forget to pin it in your shortcut bar !
>, aggregates useful tools for developer and people working in IT. If you find it useful, please fell free to
share it to people you think may find it useful too and don't forget to pin it in your shortcut bar !
</n-p>
<n-p>
IT Tools is opensource (under the MIT license) and free, and will always be, but it cost me money to host and
IT Tools is open-source (under the MIT license) and free, and will always be, but it cost me money to host and
renew the domain name, if you want to support my work, and encourage me to add more tools, please consider
supporting by
<n-button
@@ -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
>.
@@ -33,7 +36,7 @@ useHead({ title: 'About - IT Tools' });
<n-h2>Technologies</n-h2>
<n-p>
IT Tools is made in Vue JS (vue 3) with the the naive-ui component library and is hosted and continuously deployed
by Vercel. Third party opensource libraries are used in some tools, you may find the complete list in the
by Vercel. Third party open-source libraries are used in some tools, you may find the complete list in the
<n-button
type="primary"
tag="a"
@@ -47,7 +50,7 @@ useHead({ title: 'About - IT Tools' });
file of the repository.
</n-p>
<n-h2>Found a bug ? A tool is missing ?</n-h2>
<n-h2>Found a bug? A tool is missing?</n-h2>
<n-p>
If you need a tool that is currently not present here, and you think can be relevant, you are welcome to submit a
feature request in the

View File

@@ -1,24 +1,71 @@
<script setup lang="ts">
import { toolsWithCategory } from '@/tools';
import ToolCard from '../components/ToolCard.vue';
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
v-for="tool in [
...toolsWithCategory.filter(({ isNew }) => isNew),
...toolsWithCategory.filter(({ isNew }) => !isNew),
]"
: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>
@@ -26,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

@@ -1,59 +1,66 @@
import {
create,
NAlert,
NAutoComplete,
NButton,
NConfigProvider,
NCard,
NInput,
NColorPicker,
NInputNumber,
NSpace,
NH1,
NForm,
NFormItem,
NTimePicker,
NText,
NIcon,
NSwitch,
NCollapseTransition,
NGrid,
NGridItem,
NPopconfirm,
NSlider,
NCode,
NCollapse,
NCollapseItem,
NProgress,
NAutoComplete,
NSelect,
NUpload,
NEmpty,
NModal,
NTooltip,
NAlert,
NP,
NH2,
NCollapseTransition,
NColorPicker,
NConfigProvider,
NDatePicker,
NDivider,
NDropdown,
NDynamicInput,
NEllipsis,
NEmpty,
NForm,
NFormItem,
NGradientText,
NGrid,
NGridItem,
NH1,
NH2,
NH3,
NIcon,
NImage,
NInput,
NInputGroup,
NInputGroupLabel,
NInputNumber,
NLayout,
NLayoutSider,
NMenu,
NMessageProvider,
NModal,
NP,
NPageHeader,
NPopconfirm,
NProgress,
NResult,
NH3,
NEllipsis,
NTag,
NInputGroup,
NInputGroupLabel,
NDivider,
NStatistic,
NTable,
NUploadDragger,
NImage,
NScrollbar,
NGradientText,
NCode,
NSelect,
NSlider,
NSpace,
NStatistic,
NSwitch,
NTable,
NTag,
NText,
NTimePicker,
NTooltip,
NUpload,
NUploadDragger,
NPopover,
NCheckbox,
} from 'naive-ui';
const components = [
NCheckbox,
NDynamicInput,
NDatePicker,
NCode,
NGradientText,
NScrollbar,
@@ -105,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

@@ -1,5 +1,5 @@
import { layouts } from './layouts/index';
import { createRouter, createWebHistory } from 'vue-router';
import { layouts } from './layouts/index';
import HomePage from './pages/Home.page.vue';
import NotFound from './pages/404.page.vue';
import { tools } from './tools';

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',
},
@@ -30,4 +42,9 @@ export const darkThemeOverrides: GlobalThemeOverrides = {
color: '#1e1e1e',
borderColor: 'transparent',
},
Table: {
tdColor: '#1e1e1e',
thColor: '#353535',
},
};

View File

@@ -1,9 +1,16 @@
<template>
<n-card title="Text to base64">
<n-input v-model:value="textInput" type="textarea" placeholder="Put your string here..." />
<n-input :value="textBase64" type="textarea" readonly />
<n-card title="Base64 to file">
<n-form-item
:feedback="base64InputValidation.message"
:validation-status="base64InputValidation.status"
:show-label="false"
>
<n-input v-model:value="base64Input" type="textarea" placeholder="Put your base64 file string here..." rows="5" />
</n-form-item>
<n-space justify="center">
<n-button secondary @click="copyTextBase64()"> Copy </n-button>
<n-button :disabled="base64Input === '' || !base64InputValidation.isValid" secondary @click="downloadFile()">
Download file
</n-button>
</n-space>
</n-card>
@@ -17,7 +24,7 @@
</n-upload-dragger>
</n-upload>
<n-input :value="fileBase64" type="textarea" readonly />
<n-input :value="fileBase64" type="textarea" readonly placeholder="File in base64 will be here" />
<n-space justify="center">
<n-button secondary @click="copyFileBase64()"> Copy </n-button>
</n-space>
@@ -26,14 +33,35 @@
<script setup lang="ts">
import { useCopy } from '@/composable/copy';
import { useBase64 } from '@vueuse/core';
import { useDownloadFileFromBase64 } from '@/composable/downloadBase64';
import { useValidation } from '@/composable/validation';
import { isValidBase64 } from '@/utils/base64';
import { Upload } from '@vicons/tabler';
import { ref, type Ref } from 'vue';
import { useBase64 } from '@vueuse/core';
import type { UploadFileInfo } from 'naive-ui';
import { ref, type Ref } from 'vue';
const textInput = ref('');
const { base64: textBase64 } = useBase64(textInput);
const { copy: copyTextBase64 } = useCopy({ source: textBase64, text: 'Base64 string copied to the clipboard' });
const base64Input = ref('');
const { download } = useDownloadFileFromBase64({ source: base64Input });
const base64InputValidation = useValidation({
source: base64Input,
rules: [
{
message: 'Invalid base 64 string',
validator: (value) => isValidBase64(value.trim()),
},
],
});
function downloadFile() {
if (!base64InputValidation.isValid) return;
try {
download();
} catch (_) {
//
}
}
const fileList = ref();
const fileInput = ref() as Ref<File>;

View File

@@ -2,11 +2,10 @@ import { FileDigit } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Base64 converter',
path: '/base64-converter',
name: 'Base64 file converter',
path: '/base64-file-converter',
description: "Convert string, files or images into a it's base64 representation.",
keywords: ['base64', 'converter', 'upload', 'image', 'file', 'conversion', 'web', 'data', 'format'],
component: () => import('./base64-converter.vue'),
component: () => import('./base64-file-converter.vue'),
icon: FileDigit,
redirectFrom: ['/file-to-base64', '/base64-string-converter'],
});

View File

@@ -0,0 +1,55 @@
<template>
<n-card title="String to base64">
<n-form-item label="String to encode">
<n-input v-model:value="textInput" type="textarea" placeholder="Put your string here..." rows="5" />
</n-form-item>
<n-form-item label="Base64 of string">
<n-input
:value="base64Output"
type="textarea"
readonly
placeholder="The base64 encoding of your string will be here"
rows="5"
/>
</n-form-item>
<n-space justify="center">
<n-button secondary @click="copyTextBase64()"> Copy base64 </n-button>
</n-space>
</n-card>
<n-card title="Base64 to string">
<n-form-item label="Base64 string to decode" v-bind="b64Validation.attrs">
<n-input v-model:value="base64Input" type="textarea" placeholder="Your base64 string..." rows="5" />
</n-form-item>
<n-form-item label="Decoded string">
<n-input :value="textOutput" type="textarea" readonly placeholder="The decoded string will be here" rows="5" />
</n-form-item>
<n-space justify="center">
<n-button secondary @click="copyText()"> Copy decoded string </n-button>
</n-space>
</n-card>
</template>
<script setup lang="ts">
import { useCopy } from '@/composable/copy';
import { useValidation } from '@/composable/validation';
import { base64ToText, isValidBase64, textToBase64 } from '@/utils/base64';
import { withDefaultOnError } from '@/utils/defaults';
import { computed, ref } from 'vue';
const textInput = ref('');
const base64Output = computed(() => textToBase64(textInput.value));
const { copy: copyTextBase64 } = useCopy({ source: base64Output, text: 'Base64 string copied to the clipboard' });
const base64Input = ref('');
const textOutput = computed(() => withDefaultOnError(() => base64ToText(base64Input.value.trim()), ''));
const { copy: copyText } = useCopy({ source: textOutput, text: 'String copied to the clipboard' });
const b64Validation = useValidation({
source: base64Input,
rules: [{ message: 'Invalid base64 string', validator: (value) => isValidBase64(value.trim()) }],
});
</script>

View File

@@ -0,0 +1,12 @@
import { FileDigit } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Base64 string encoder/decoder',
path: '/base64-string-converter',
description: 'Simply encode and decode string into a their base64 representation.',
keywords: ['base64', 'converter', 'conversion', 'web', 'data', 'format', 'atob', 'btoa'],
component: () => import('./base64-string-converter.vue'),
icon: FileDigit,
redirectFrom: ['/file-to-base64', '/base64-converter'],
});

View File

@@ -0,0 +1,49 @@
<template>
<div>
<n-form-item label="Username">
<n-input v-model:value="username" placeholder="Your username..." clearable />
</n-form-item>
<n-form-item label="Password">
<n-input
v-model:value="password"
placeholder="Your password..."
type="password"
show-password-on="click"
clearable
/>
</n-form-item>
<br />
<n-card>
<n-statistic label="Authorization header:" class="header">
<n-scrollbar x-scrollable style="max-width: 550px; margin-bottom: -10px; padding-bottom: 10px" trigger="none">
{{ header }}
</n-scrollbar>
</n-statistic>
</n-card>
<br />
<n-space justify="center">
<n-button secondary @click="copy">Copy header</n-button>
</n-space>
</div>
</template>
<script setup lang="ts">
import { useCopy } from '@/composable/copy';
import { textToBase64 } from '@/utils/base64';
import { computed, ref } from 'vue';
const username = ref('');
const password = ref('');
const header = computed(() => `Authorization: Basic ${textToBase64(`${username.value}:${password.value}`)}`);
const { copy } = useCopy({ source: header, text: 'Header copied to the clipboard' });
</script>
<style lang="less" scoped>
::v-deep(.n-statistic-value__content) {
font-family: monospace;
font-size: 17px !important;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,21 @@
import { PasswordRound } from '@vicons/material';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Basic auth generator',
path: '/basic-auth-generator',
description: 'Generate a base64 basic auth header from an username and a password.',
keywords: [
'basic',
'auth',
'generator',
'username',
'password',
'base64',
'authentication',
'header',
'authorization',
],
component: () => import('./basic-auth-generator.vue'),
icon: PasswordRound,
});

View File

@@ -59,24 +59,26 @@
<script setup lang="ts">
import { useCopy } from '@/composable/copy';
import { ref, computed } from 'vue';
import { useValidation } from '@/composable/validation';
import { isNotThrowing } from '@/utils/boolean';
import { withDefaultOnError } from '@/utils/defaults';
import {
entropyToMnemonic,
englishWordList,
chineseSimplifiedWordList,
chineseTraditionalWordList,
czechWordList,
englishWordList,
entropyToMnemonic,
frenchWordList,
generateEntropy,
italianWordList,
japaneseWordList,
koreanWordList,
mnemonicToEntropy,
portugueseWordList,
spanishWordList,
generateEntropy,
mnemonicToEntropy,
} from '@it-tools/bip39';
import { Copy, Refresh } from '@vicons/tabler';
import { useValidation } from '@/composable/validation';
import { computed, ref } from 'vue';
const languages = {
English: englishWordList,
@@ -97,20 +99,11 @@ const passphraseInput = ref('');
const language = ref<keyof typeof languages>('English');
const passphrase = computed({
get() {
try {
return entropyToMnemonic(entropy.value, languages[language.value]);
} catch (_) {
return passphraseInput.value;
}
return withDefaultOnError(() => entropyToMnemonic(entropy.value, languages[language.value]), passphraseInput.value);
},
set(value: string) {
passphraseInput.value = value;
try {
entropy.value = mnemonicToEntropy(value, languages[language.value]);
} catch (_) {
entropy.value = '';
}
entropy.value = withDefaultOnError(() => mnemonicToEntropy(value, languages[language.value]), '');
},
});
@@ -123,7 +116,7 @@ const entropyValidation = useValidation({
},
{
validator: (value) => /^[a-fA-F0-9]*$/.test(value),
message: 'Entropy should an hexadecimal number',
message: 'Entropy should be an hexadecimal string',
},
],
});
@@ -132,14 +125,7 @@ const mnemonicValidation = useValidation({
source: passphrase,
rules: [
{
validator: (value) => {
try {
mnemonicToEntropy(value);
return true;
} catch (_) {
return false;
}
},
validator: (value) => isNotThrowing(() => mnemonicToEntropy(value, languages[language.value])),
message: 'Invalid mnemonic',
},
],

View File

@@ -46,8 +46,6 @@
<script setup lang="ts">
import { ref } from 'vue';
import InputCopyable from '../../components/InputCopyable.vue';
import {
camelCase,
capitalCase,
@@ -61,6 +59,7 @@ import {
sentenceCase,
snakeCase,
} from 'change-case';
import InputCopyable from '../../components/InputCopyable.vue';
const input = ref('lorem ipsum dolor sit amet');
</script>

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

@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { formatMs } from './chronometer.service';
describe('chronometer', () => {
describe('formatChronometerTime', () => {
it('format the elapsed time', () => {
expect(formatMs(0)).toEqual('00:00.000');
expect(formatMs(1)).toEqual('00:00.001');
expect(formatMs(123456)).toEqual('02:03.456');
expect(formatMs(12345600)).toEqual('03:25:45.600');
});
});
});

View File

@@ -0,0 +1,11 @@
export function formatMs(msTotal: number) {
const ms = msTotal % 1000;
const secs = ((msTotal - ms) / 1000) % 60;
const mins = (((msTotal - ms) / 1000 - secs) / 60) % 60;
const hrs = (((msTotal - ms) / 1000 - secs) / 60 - mins) / 60;
const hrsString = hrs > 0 ? `${hrs.toString().padStart(2, '0')}:` : '';
return `${hrsString}${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms
.toString()
.padStart(3, '0')}`;
}

View File

@@ -0,0 +1,53 @@
<template>
<div>
<n-card>
<div class="duration">{{ formatMs(counter) }}</div>
</n-card>
<br />
<n-space justify="center">
<n-button v-if="!isRunning" secondary type="primary" @click="resume">Start</n-button>
<n-button v-else secondary type="warning" @click="pause">Stop</n-button>
<n-button secondary @click="counter = 0">Reset</n-button>
</n-space>
</div>
</template>
<script setup lang="ts">
import { useRafFn } from '@vueuse/core';
import { ref } from 'vue';
import { formatMs } from './chronometer.service';
const isRunning = ref(false);
const counter = ref(0);
let previousRafDate = Date.now();
const { pause: pauseRaf, resume: resumeRaf } = useRafFn(
() => {
const deltaMs = Date.now() - previousRafDate;
previousRafDate = Date.now();
counter.value += deltaMs;
},
{ immediate: false },
);
function resume() {
previousRafDate = Date.now();
resumeRaf();
isRunning.value = true;
}
function pause() {
pauseRaf();
isRunning.value = false;
}
</script>
<style lang="less" scoped>
.duration {
text-align: center;
font-size: 40px;
font-family: monospace;
margin: 20px 0;
}
</style>

View File

@@ -0,0 +1,11 @@
import { TimerOutlined } from '@vicons/material';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Chronometer',
path: '/chronometer',
description: 'Monitor the duration of a thing. Basically a chronometer with simple chronometer features.',
keywords: ['chronometer', 'time', 'lap', 'duration', 'measure', 'pause', 'resume', 'stopwatch'],
component: () => import('./chronometer.vue'),
icon: TimerOutlined,
});

View File

@@ -36,12 +36,12 @@
<script setup lang="ts">
import { ref } from 'vue';
import { colord, extend } from 'colord';
import InputCopyable from '../../components/InputCopyable.vue';
import cmykPlugin from 'colord/plugins/cmyk';
import hwbPlugin from 'colord/plugins/hwb';
import namesPlugin from 'colord/plugins/names';
import lchPlugin from 'colord/plugins/lch';
import InputCopyable from '../../components/InputCopyable.vue';
extend([cmykPlugin, hwbPlugin, namesPlugin, lchPlugin]);

View File

@@ -132,5 +132,10 @@ const formats: Format[] = [
fromDate: (date) => date.toUTCString(),
toDate,
},
{
name: 'Mongo ObjectID',
fromDate: (date) => Math.floor(date.getTime() / 1000).toString(16) + '0000000000000000',
toDate: (objectId) => new Date(parseInt(objectId.substring(0, 8), 16) * 1000),
},
];
</script>

View File

@@ -1,16 +1,17 @@
<template>
<n-card v-for="{ name, information } in sections" :key="name" :title="name">
<n-grid cols="1 400:2" x-gap="12" y-gap="12">
<n-gi v-for="{ label, value } in information" :key="label" class="information">
<n-gi v-for="{ label, value: { value } } in information" :key="label" class="information">
<n-card :bordered="false" embedded>
<div class="label">
{{ label }}
</div>
<div class="value">
<n-ellipsis>
{{ value.value }}
<n-ellipsis v-if="value">
{{ value }}
</n-ellipsis>
<div v-else class="undefined-value">unknown</div>
</div>
</n-card>
</n-gi>
@@ -89,7 +90,10 @@ const sections = [
.value {
font-size: 20px;
font-weight: 400;
line-height: 1;
}
.undefined-value {
opacity: 0.8;
}
}
</style>

View File

@@ -0,0 +1,16 @@
import { formatDuration } from 'date-fns';
export function formatMsDuration(duration: number) {
const ms = Math.floor(duration % 1000);
const secs = Math.floor(((duration - ms) / 1000) % 60);
const mins = Math.floor((((duration - ms) / 1000 - secs) / 60) % 60);
const hrs = Math.floor((((duration - ms) / 1000 - secs) / 60 - mins) / 60);
return (
formatDuration({
hours: hrs,
minutes: mins,
seconds: secs,
}) + (ms > 0 ? ` ${ms} ms` : '')
);
}

View File

@@ -0,0 +1,83 @@
<template>
<div>
<n-text depth="3" style="text-align: justify; width: 100%; display: inline-block">
With a concrete example, if you wash 3 plates in 5 minutes and you have 500 plates to wash, it will take you 5
hours and 10 minutes to wash them all, and if you start now, you'll end
{{ endAt }}.
</n-text>
<br />
<n-divider />
<n-space item-style="flex:1 1 0">
<div>
<n-space item-style="flex:1 1 0">
<n-form-item label="Amount of element to consume">
<n-input-number v-model:value="unitCount" :min="1" />
</n-form-item>
<n-form-item label="The consumption started at">
<n-date-picker v-model:value="startedAt" type="datetime" />
</n-form-item>
</n-space>
<n-form-item label="Amount of unit consumed by time span" :show-feedback="false">
<n-input-number v-model:value="unitPerTimeSpan" :min="1" />
<span style="margin: 0 10px">in</span>
<n-input-group>
<n-input-number v-model:value="timeSpan" :min="1" />
<n-select
v-model:value="timeSpanUnitMultiplier"
:options="[
{ label: 'milliseconds', value: 1 },
{ label: 'seconds', value: 1000 },
{ label: 'minutes', value: 1000 * 60 },
{ label: 'hours', value: 1000 * 60 * 60 },
{ label: 'days', value: 1000 * 60 * 60 * 24 },
]"
></n-select>
</n-input-group>
</n-form-item>
<n-divider />
<n-space vertical>
<n-card>
<n-statistic label="Total duration">{{ formatMsDuration(durationMs) }}</n-statistic>
</n-card>
<n-card>
<n-statistic label="It will end ">{{ endAt }}</n-statistic>
</n-card>
</n-space>
</div>
</n-space>
</div>
</template>
<script setup lang="ts">
// Duplicate issue with sub directory
// eslint-disable-next-line import/no-duplicates
import { addMilliseconds, formatRelative } from 'date-fns';
// eslint-disable-next-line import/no-duplicates
import { enGB } from 'date-fns/locale';
import { computed, ref } from 'vue';
import { formatMsDuration } from './eta-calculator.service';
const unitCount = ref(3 * 62);
const unitPerTimeSpan = ref(3);
const timeSpan = ref(5);
const timeSpanUnitMultiplier = ref(60000);
const startedAt = ref(Date.now());
const durationMs = computed(() => {
const timeSpanMs = timeSpan.value * timeSpanUnitMultiplier.value;
return unitCount.value / (unitPerTimeSpan.value / timeSpanMs);
});
const endAt = computed(() =>
formatRelative(addMilliseconds(startedAt.value, durationMs.value), Date.now(), { locale: enGB }),
);
</script>
<style lang="less" scoped>
.n-input-number,
.n-date-picker {
width: 100%;
}
</style>

View File

@@ -0,0 +1,12 @@
import { Hourglass } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'ETA calculator',
path: '/eta-calculator',
description:
'An ETA (Estimated Time of Arrival) calculator to know the approximate end time of a task, for example the moment of ending of a download.',
keywords: ['eta', 'calculator', 'estimated', 'time', 'arrival', 'average'],
component: () => import('./eta-calculator.vue'),
icon: Hourglass,
});

View File

@@ -5,8 +5,8 @@
</template>
<script setup lang="ts">
import Memo from './git-memo.md';
import { useThemeVars } from 'naive-ui';
import Memo from './git-memo.md';
const themeVars = useThemeVars();
</script>

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { convertHexToBin } from './hash-text.service';
describe('hash text', () => {
describe('convertHexToBin', () => {
it('convert hex to bin', () => {
expect(convertHexToBin('')).toEqual('');
expect(convertHexToBin('FF')).toEqual('11111111');
expect(convertHexToBin('F'.repeat(200))).toEqual('1111'.repeat(200));
expect(convertHexToBin('2123006AD00F694CE120')).toEqual(
'00100001001000110000000001101010110100000000111101101001010011001110000100100000',
);
});
});
});

View File

@@ -0,0 +1,7 @@
export function convertHexToBin(hex: string) {
return hex
.trim()
.split('')
.map((byte) => parseInt(byte, 16).toString(2).padStart(4, '0'))
.join('');
}

View File

@@ -1,10 +1,34 @@
<template>
<div>
<n-card>
<n-input v-model:value="clearText" type="textarea" placeholder="Your string..." :autosize="{ minRows: 3 }" />
<n-input v-model:value="clearText" type="textarea" placeholder="Your string to hash..." rows="3" />
<n-divider />
<n-form-item label="Digest encoding">
<n-select
v-model:value="encoding"
:options="[
{
label: 'Binary (base 2)',
value: 'Bin',
},
{
label: 'Hexadecimal (base 16)',
value: 'Hex',
},
{
label: 'Base64 (base 64)',
value: 'Base64',
},
{
label: 'Base64url (base 64 with url safe chars)',
value: 'Base64url',
},
]"
/>
</n-form-item>
<div v-for="algo in algoNames" :key="algo" style="margin: 5px 0">
<n-input-group>
<n-input-group-label style="flex: 0 0 120px"> {{ algo }} </n-input-group-label>
@@ -16,9 +40,10 @@
</template>
<script setup lang="ts">
import InputCopyable from '../../components/InputCopyable.vue';
import { enc, lib, MD5, RIPEMD160, SHA1, SHA224, SHA256, SHA3, SHA384, SHA512 } from 'crypto-js';
import { ref } from 'vue';
import { MD5, SHA1, SHA256, SHA224, SHA512, SHA384, SHA3, RIPEMD160 } from 'crypto-js';
import InputCopyable from '../../components/InputCopyable.vue';
import { convertHexToBin } from './hash-text.service';
const algos = {
MD5,
@@ -32,10 +57,18 @@ const algos = {
} as const;
type AlgoNames = keyof typeof algos;
type Encoding = keyof typeof enc | 'Bin';
const algoNames = Object.keys(algos) as AlgoNames[];
const encoding = ref<Encoding>('Hex');
const clearText = ref('');
const clearText = ref(
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lacus metus blandit dolor lacus natoque ad fusce aliquam velit.',
);
const hashText = (algo: AlgoNames, value: string) => algos[algo](value).toString();
function formatWithEncoding(words: lib.WordArray, encoding: Encoding) {
if (encoding === 'Bin') {
return convertHexToBin(words.toString(enc.Hex));
}
return words.toString(enc[encoding]);
}
const hashText = (algo: AlgoNames, value: string) => formatWithEncoding(algos[algo](value), encoding.value);
</script>

View File

@@ -0,0 +1,96 @@
<template>
<div>
<n-form-item label="Plain text to compute the hash">
<n-input v-model:value="plainText" type="textarea" placeholder="Enter the text to compute the hash..." />
</n-form-item>
<n-form-item label="Secret key">
<n-input v-model:value="secret" placeholder="Enter the secret key..." />
</n-form-item>
<n-space item-style="flex:1 1 0">
<n-form-item label="Hashing function">
<n-select
v-model:value="hashFunction"
placeholder="Select an hashing function..."
:options="Object.keys(algos).map((label) => ({ label, value: label }))"
/>
</n-form-item>
<n-form-item label="Output encoding">
<n-select
v-model:value="encoding"
placeholder="Select the result encoding..."
:options="[
{
label: 'Binary (base 2)',
value: 'Bin',
},
{
label: 'Hexadecimal (base 16)',
value: 'Hex',
},
{
label: 'Base64 (base 64)',
value: 'Base64',
},
{
label: 'Base64-url (base 64 with url safe chars)',
value: 'Base64url',
},
]"
/>
</n-form-item>
</n-space>
<n-form-item label="HMAC of your text">
<n-input readonly :value="hmac" type="textarea" placeholder="The result of the HMAC..." />
</n-form-item>
<n-space justify="center">
<n-button secondary @click="copy()">Copy HMAC</n-button>
</n-space>
</div>
</template>
<script setup lang="ts">
import { useCopy } from '@/composable/copy';
import {
enc,
HmacMD5,
HmacRIPEMD160,
HmacSHA1,
HmacSHA224,
HmacSHA256,
HmacSHA3,
HmacSHA384,
HmacSHA512,
lib,
} from 'crypto-js';
import { computed, ref } from 'vue';
import { convertHexToBin } from '../hash-text/hash-text.service';
const algos = {
MD5: HmacMD5,
RIPEMD160: HmacRIPEMD160,
SHA1: HmacSHA1,
SHA3: HmacSHA3,
SHA224: HmacSHA224,
SHA256: HmacSHA256,
SHA384: HmacSHA384,
SHA512: HmacSHA512,
} as const;
type Encoding = keyof typeof enc | 'Bin';
function formatWithEncoding(words: lib.WordArray, encoding: Encoding) {
if (encoding === 'Bin') {
return convertHexToBin(words.toString(enc.Hex));
}
return words.toString(enc[encoding]);
}
const plainText = ref('');
const secret = ref('');
const hashFunction = ref<keyof typeof algos>('SHA256');
const encoding = ref<Encoding>('Hex');
const hmac = computed(() =>
formatWithEncoding(algos[hashFunction.value](plainText.value, secret.value), encoding.value),
);
const { copy } = useCopy({ source: hmac });
</script>

View File

@@ -0,0 +1,12 @@
import { ShortTextRound } from '@vicons/material';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Hmac generator',
path: '/hmac-generator',
description:
'Computes a hash-based message authentication code (HMAC) using a secret key and your favorite hashing function.',
keywords: ['hmac', 'generator', 'MD5', 'SHA1', 'SHA256', 'SHA224', 'SHA512', 'SHA384', 'SHA3', 'RIPEMD160'],
component: () => import('./hmac-generator.vue'),
icon: ShortTextRound,
});

View File

@@ -1,67 +1,91 @@
import { LockOpen } from '@vicons/tabler';
import type { ToolCategory } from './tool';
import { tool as mathEvaluator } from './math-evaluator';
import { tool as jsonViewer } from './json-viewer';
import { tool as htmlEntities } from './html-entities';
import { tool as urlParser } from './url-parser';
import { tool as deviceInformation } from './device-information';
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 qrCodeGenerator } from './qr-code-generator';
import { tool as base64Converter } from './base64-converter';
import { tool as crontabGenerator } from './crontab-generator';
import { tool as dateTimeConverter } from './date-time-converter';
import { tool as deviceInformation } from './device-information';
import { tool as cypher } from './encryption';
import { tool as etaCalculator } from './eta-calculator';
import { tool as gitMemo } from './git-memo';
import { tool as hashText } from './hash-text';
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 { tool as hashText } from './hash-text';
import { tool as uuidGenerator } from './uuid-generator';
import { tool as romanNumeralConverter } from './roman-numeral-converter';
import { tool as cypher } from './encryption';
import { tool as bip39 } from './bip39-generator';
import { tool as dateTimeConverter } from './date-time-converter';
import { tool as gitMemo } from './git-memo';
import { tool as baseConverter } from './integer-base-converter';
import type { ToolCategory } from './tools.types';
import { tool as urlEncoder } from './url-encoder';
import { tool as randomPortGenerator } from './random-port-generator';
import { tool as loremIpsumGenerator } from './lorem-ipsum-generator';
import { tool as urlParser } from './url-parser';
import { tool as uuidGenerator } from './uuid-generator';
export const toolsByCategory: ToolCategory[] = [
{
name: 'Crypto',
icon: LockOpen,
components: [tokenGenerator, hashText, bcrypt, uuidGenerator, cypher, bip39],
components: [tokenGenerator, hashText, bcrypt, uuidGenerator, cypher, bip39, hmacGenerator],
},
{
name: 'Converter',
icon: LockOpen,
components: [
dateTimeConverter,
baseConverter,
romanNumeralConverter,
base64Converter,
base64StringConverter,
base64FileConverter,
colorConverter,
caseConverter,
],
},
{
name: 'Web',
icon: LockOpen,
components: [urlEncoder, htmlEntities, qrCodeGenerator, urlParser, deviceInformation],
components: [
urlEncoder,
htmlEntities,
urlParser,
deviceInformation,
basicAuthGenerator,
metaTagGenerator,
otpCodeGeneratorAndValidator,
mimeTypes,
jwtParser,
],
},
{
name: 'Images',
components: [qrCodeGenerator, svgPlaceholderGenerator],
},
{
name: 'Development',
icon: LockOpen,
components: [gitMemo, randomPortGenerator, crontabGenerator, jsonViewer],
components: [gitMemo, randomPortGenerator, crontabGenerator, jsonViewer, sqlPrettify, chmodCalculator],
},
{
name: 'Math',
icon: LockOpen,
components: [mathEvaluator],
components: [mathEvaluator, etaCalculator],
},
{
name: 'Measurement',
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';
import { useStyleStore } from '@/stores/style.store';
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

@@ -2,10 +2,11 @@ import { Braces } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'JSON viewer',
path: '/json-viewer',
description: 'Prettify JSON string to a human friendly readable format.',
name: 'JSON prettify and format',
path: '/json-prettify',
description: 'Prettify your JSON string to a human friendly readable format.',
keywords: ['json', 'viewer', 'prettify', 'format'],
component: () => import('./json-viewer.vue'),
icon: Braces,
redirectFrom: ['/json-viewer'],
});

View File

@@ -1,66 +1,56 @@
<template>
<n-card>
<n-form-item
label="Your raw json:"
:feedback="rawJsonValidation.message"
:validation-status="rawJsonValidation.status"
>
<n-input
v-model:value="rawJson"
class="json-input"
type="textarea"
placeholder="Paste your raw json here..."
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/>
</n-form-item>
<n-space justify="center">
<n-button secondary @click="rawJson = ''">Clear</n-button>
</n-space>
</n-card>
<n-card v-if="cleanJson.length > 0">
<n-scrollbar :x-scrollable="true">
<n-config-provider :hljs="hljs">
<n-code :code="cleanJson" language="json" />
</n-config-provider>
</n-scrollbar>
</n-card>
<n-form-item
label="Your raw json"
:feedback="rawJsonValidation.message"
:validation-status="rawJsonValidation.status"
>
<n-input
ref="inputElement"
v-model:value="rawJson"
placeholder="Paste your raw json here..."
type="textarea"
rows="20"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
/>
</n-form-item>
<n-form-item label="Prettify version of your json">
<textarea-copyable :value="cleanJson" language="json" :follow-height-of="inputElement" />
</n-form-item>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import hljs from 'highlight.js/lib/core';
import json from 'highlight.js/lib/languages/json';
import TextareaCopyable from '@/components/TextareaCopyable.vue';
import { useValidation } from '@/composable/validation';
import { withDefaultOnError } from '@/utils/defaults';
import JSON5 from 'json5';
import { computed, ref } from 'vue';
hljs.registerLanguage('json', json);
const inputElement = ref<HTMLElement>();
const rawJson = ref('');
const cleanJson = computed(() => {
try {
return JSON.stringify(JSON.parse(rawJson.value), null, 3);
} catch (_) {
return '';
}
});
const rawJson = ref('{"hello": "world"}');
const cleanJson = computed(() => withDefaultOnError(() => JSON.stringify(JSON5.parse(rawJson.value), null, 3), ''));
const rawJsonValidation = useValidation({
source: rawJson,
rules: [
{
validator: (v) => v === '' || JSON.parse(v),
message: 'Invalid json string',
validator: (v) => v === '' || JSON5.parse(v),
message: 'Provided JSON is not valid.',
},
],
});
</script>
<style lang="less" scoped>
.json-input ::v-deep(.n-input-wrapper) {
resize: both !important;
.result-card {
position: relative;
.copy-button {
position: absolute;
top: 10px;
right: 10px;
}
}
</style>

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

@@ -30,8 +30,8 @@
<script setup lang="ts">
import { useCopy } from '@/composable/copy';
import { ref, computed } from 'vue';
import { generateLoremIpsum } from './lorem-ipsum-generator.service';
import { randIntFromInterval } from '@/utils/random';
import { generateLoremIpsum } from './lorem-ipsum-generator.service';
const paragraphs = ref(1);
const sentences = ref([3, 8]);

View File

@@ -21,18 +21,11 @@
</template>
<script setup lang="ts">
import { withDefaultOnError } from '@/utils/defaults';
import { evaluate } from 'mathjs';
import { computed, ref } from 'vue';
const expression = ref('');
const result = computed(() => {
try {
return evaluate(expression.value) ?? '';
} catch (_) {
return '';
}
});
const result = computed(() => withDefaultOnError(() => evaluate(expression.value) ?? '', ''));
</script>
<style lang="less" scoped></style>

View File

@@ -0,0 +1,27 @@
import type { SelectGroupOption, SelectOption } from 'naive-ui';
export type { OGSchemaType, OGSchemaTypeElementInput, OGSchemaTypeElementSelect, OGSchemaTypeElementInputMultiple };
interface OGSchemaTypeElementBase {
key: string;
label: string;
placeholder: string;
}
interface OGSchemaTypeElementInput extends OGSchemaTypeElementBase {
type: 'input';
}
interface OGSchemaTypeElementInputMultiple extends OGSchemaTypeElementBase {
type: 'input-multiple';
}
interface OGSchemaTypeElementSelect extends OGSchemaTypeElementBase {
type: 'select';
options: Array<SelectOption | SelectGroupOption>;
}
interface OGSchemaType {
name: string;
elements: (OGSchemaTypeElementSelect | OGSchemaTypeElementInput | OGSchemaTypeElementInputMultiple)[];
}

View File

@@ -0,0 +1,25 @@
import { Tags } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Open graph meta generator',
path: '/og-meta-generator',
description: 'Generate open-graph and socials html meta tags for your website.',
keywords: [
'meta',
'tag',
'generator',
'social',
'title',
'description',
'image',
'share',
'online',
'website',
'open',
'graph',
'og',
],
component: () => import('./meta-tag-generator.vue'),
icon: Tags,
});

View File

@@ -0,0 +1,94 @@
<template>
<div>
<div v-for="{ name, elements } of sections" :key="name" style="margin-bottom: 15px">
<n-form-item :label="name" :show-feedback="false"> </n-form-item>
<n-input-group v-for="{ key, type, label, placeholder, ...element } of elements" :key="key">
<n-input-group-label style="flex: 0 0 110px">{{ label }}</n-input-group-label>
<n-input v-if="type === 'input'" v-model:value="metadata[key]" :placeholder="placeholder" />
<n-dynamic-input
v-else-if="type === 'input-multiple'"
v-model:value="metadata[key]"
:min="1"
:placeholder="placeholder"
:default-value="['']"
:show-sort-button="true"
/>
<n-select
v-else-if="type === 'select'"
v-model:value="metadata[key]"
:placeholder="placeholder"
:options="(element as OGSchemaTypeElementSelect).options"
/>
</n-input-group>
</div>
</div>
<div>
<n-form-item label="Your meta tags">
<textarea-copyable :value="metaTags" language="html" />
</n-form-item>
</div>
</template>
<script setup lang="ts">
import TextareaCopyable from '@/components/TextareaCopyable.vue';
import { generateMeta } from '@it-tools/oggen';
import _ from 'lodash';
import { computed, ref, watch } from 'vue';
import { image, ogSchemas, twitter, website } from './og-schemas';
import type { OGSchemaType, OGSchemaTypeElementSelect } from './OGSchemaType.type';
// Since type guards do not work in template
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const metadata = ref<{ type: string; [k: string]: any }>({
type: 'website',
'twitter:card': 'summary_large_image',
});
watch(
() => ref(metadata.value.type),
(_ignored, prevSection) => {
const section = ogSchemas[prevSection.value];
if (!section) return;
section.elements.forEach(({ key }) => {
metadata.value[key] = '';
});
},
);
const sections = computed(() => {
const secs: OGSchemaType[] = [website, image, twitter];
const additionalSchema = ogSchemas[metadata.value.type];
if (additionalSchema) secs.push(additionalSchema);
return secs;
});
const metaTags = computed(() => {
const twitterMeta = _.chain(metadata.value)
.pickBy((_value, k) => k.startsWith('twitter:'))
.mapKeys((_value, k) => k.replace(/^twitter:/, ''))
.value();
const otherMeta = _.pickBy(metadata.value, (_value, k) => !k.startsWith('twitter:'));
return generateMeta({ ...otherMeta, twitter: twitterMeta }, { generateTwitterCompatibleMeta: true });
});
</script>
<style lang="less" scoped>
.n-input-group {
margin-bottom: 5px;
}
::v-deep(.n-form-item-blank) {
min-height: 0 !important;
}
::v-deep(.n-dynamic-input-item) {
margin-bottom: 5px;
}
</style>

View File

@@ -0,0 +1,33 @@
import type { OGSchemaType } from '../OGSchemaType.type';
export const article: OGSchemaType = {
name: 'Article',
elements: [
{
type: 'input',
label: 'Publishing date',
key: 'article:published_time',
placeholder: 'When the article was first published...',
},
{
type: 'input',
label: 'Modification date',
key: 'article:modified_time',
placeholder: 'When the article was last changed...',
},
{
type: 'input',
label: 'Expiration date',
key: 'article:expiration_time',
placeholder: 'When the article is out of date after...',
},
{ type: 'input', label: 'Author', key: 'article:author', placeholder: 'Writers of the article...' },
{
type: 'input',
label: 'Section',
key: 'article:section',
placeholder: 'A high-level section name. E.g. Technology..',
},
{ type: 'input', label: 'Tag', key: 'article:tag', placeholder: 'Tag words associated with this article...' },
],
};

View File

@@ -0,0 +1,16 @@
import type { OGSchemaType } from '../OGSchemaType.type';
export const book: OGSchemaType = {
name: 'Book',
elements: [
{ type: 'input', label: 'Author', key: 'book:author', placeholder: 'Who wrote this book...' },
{ type: 'input', label: 'ISBN', key: 'book:isbn', placeholder: 'The International Standard Book Number...' },
{
type: 'input',
label: 'Release date',
key: 'book:release_date',
placeholder: 'The date the book was released...',
},
{ type: 'input', label: 'Tag', key: 'book:tag', placeholder: 'Tag words associated with this book...' },
],
};

View File

@@ -0,0 +1,31 @@
import type { OGSchemaType } from '../OGSchemaType.type';
export const image: OGSchemaType = {
name: 'Image',
elements: [
{
type: 'input',
label: 'Image url',
placeholder: 'The url of your website social image...',
key: 'image',
},
{
type: 'input',
label: 'Image alt',
placeholder: 'The alternative text of your website social image...',
key: 'image:alt',
},
{
type: 'input',
label: 'Width',
placeholder: 'Width in px of your website social image...',
key: 'image:width',
},
{
type: 'input',
label: 'Height',
placeholder: 'Height in px of your website social image...',
key: 'image:height',
},
],
};

View File

@@ -0,0 +1,31 @@
import type { OGSchemaType } from '../OGSchemaType.type';
import { article } from './article';
import { book } from './book';
import { musicAlbum } from './musicAlbum';
import { musicPlaylist } from './musicPlaylist';
import { musicRadioStation } from './musicRadioStation';
import { musicSong } from './musicSong';
import { profile } from './profile';
import { videoEpisode } from './videoEpisode';
import { videoMovie } from './videoMovie';
import { videoOther } from './videoOther';
import { videoTVShow } from './videoTVShow';
export * from './image';
export * from './twitter';
export * from './website';
export const ogSchemas: Record<string, OGSchemaType> = {
'music.song': musicSong,
'music.album': musicAlbum,
'music.playlist': musicPlaylist,
'music.radio_station': musicRadioStation,
'video.movie': videoMovie,
'video.episode': videoEpisode,
'video.tv_show': videoTVShow,
'video.other': videoOther,
profile,
article,
book,
};

View File

@@ -0,0 +1,27 @@
import type { OGSchemaType } from '../OGSchemaType.type';
export const musicAlbum: OGSchemaType = {
name: 'Album details',
elements: [
{ type: 'input', label: 'Song', key: 'music:song', placeholder: 'The song on this album...' },
{
type: 'input',
label: 'Disc',
key: 'music:song:disc',
placeholder: 'The same as music:album:disc but in reverse...',
},
{
type: 'input',
label: 'Track',
key: 'music:song:track',
placeholder: 'The same as music:album:track but in reverse...',
},
{ type: 'input', label: 'Musician', key: 'music:musician', placeholder: 'The musician that made this song...' },
{
type: 'input',
label: 'Release date',
key: 'music:release_date',
placeholder: 'The date the album was released...',
},
],
};

View File

@@ -0,0 +1,21 @@
import type { OGSchemaType } from '../OGSchemaType.type';
export const musicPlaylist: OGSchemaType = {
name: 'Playlist details',
elements: [
{ type: 'input', label: 'Song', key: 'music:song', placeholder: 'The song on this album...' },
{
type: 'input',
label: 'Disc',
key: 'music:song:disc',
placeholder: 'The same as music:album:disc but in reverse...',
},
{
type: 'input',
label: 'Track',
key: 'music:song:track',
placeholder: 'The same as music:album:track but in reverse...',
},
{ type: 'input', label: 'Creator', key: 'music:creator', placeholder: 'The creator of this playlist...' },
],
};

View File

@@ -0,0 +1,8 @@
import type { OGSchemaType } from '../OGSchemaType.type';
export const musicRadioStation: OGSchemaType = {
name: 'Radio station details',
elements: [
{ type: 'input', label: 'Creator', key: 'music:creator', placeholder: 'The creator of this radio station...' },
],
};

View File

@@ -0,0 +1,22 @@
import type { OGSchemaType } from '../OGSchemaType.type';
export const musicSong: OGSchemaType = {
name: 'Song details',
elements: [
{ type: 'input', label: 'Duration', placeholder: 'The duration of the song...', key: 'music:duration' },
{ type: 'input', label: 'Album', placeholder: 'The album this song is from...', key: 'music:album' },
{
type: 'input',
label: 'Disc',
placeholder: 'Which disc of the album this song is on...',
key: 'music:album:disk',
},
{ type: 'input', label: 'Track', placeholder: ' Which track this song is...', key: 'music:album:track' },
{
type: 'input-multiple',
label: 'Musician',
placeholder: 'The musician that made this song...',
key: 'music:musician',
},
],
};

View File

@@ -0,0 +1,21 @@
import type { OGSchemaType } from '../OGSchemaType.type';
export const profile: OGSchemaType = {
name: 'Profile',
elements: [
{
type: 'input',
label: 'First name',
placeholder: 'Enter the first name of the person...',
key: 'profile:first_name',
},
{
type: 'input',
label: 'Last name',
placeholder: 'Enter the last name of the person...',
key: 'profile:last_name',
},
{ type: 'input', label: 'Username', placeholder: 'Enter the username of the person...', key: 'profile:username' },
{ type: 'input', label: 'Gender', placeholder: 'Enter the gender of the person...', key: 'profile:gender' },
],
};

View File

@@ -0,0 +1,31 @@
import type { OGSchemaType } from '../OGSchemaType.type';
export const twitter: OGSchemaType = {
name: 'Twitter',
elements: [
{
type: 'select',
options: [
{ label: 'Summary', value: 'summary' },
{ label: 'Summary with large image', value: 'summary_large_image' },
{ label: 'Application', value: 'app' },
{ label: 'Player', value: 'player' },
],
label: 'Card type',
placeholder: 'The twitter card type...',
key: 'twitter:card',
},
{
type: 'input',
label: 'Site account',
placeholder: 'The name of the twitter account of the site (ex: @ittoolsdottech)...',
key: 'twitter:site',
},
{
type: 'input',
label: 'Creator acc.',
placeholder: 'The name of the twitter account of the creator (ex: @cthmsst)...',
key: 'twitter:creator',
},
],
};

View File

@@ -0,0 +1,10 @@
import type { OGSchemaType } from '../OGSchemaType.type';
import { videoMovie } from './videoMovie';
export const videoEpisode: OGSchemaType = {
name: 'Video episode details',
elements: [
...videoMovie.elements,
{ type: 'input', label: 'Series', key: 'video:series', placeholder: 'Which series this episode belongs to...' },
],
};

View File

@@ -0,0 +1,29 @@
import type { OGSchemaType } from '../OGSchemaType.type';
export const videoMovie: OGSchemaType = {
name: 'Movie details',
elements: [
{
type: 'input-multiple',
label: 'Actor',
key: 'video:actor',
placeholder: 'Name of the actress/actor...',
},
// { type: 'input', label: 'Actor role', key: 'video:actor:role', placeholder: 'The role they played...' },
{
type: 'input-multiple',
label: 'Director',
key: 'video:director',
placeholder: 'Name of the director...',
},
{ type: 'input-multiple', label: 'Writer', key: 'video:writer', placeholder: 'Writers of the movie...' },
{ type: 'input', label: 'Duration', key: 'video:duration', placeholder: "The movie's length in seconds..." },
{
type: 'input',
label: 'Release date',
key: 'video:release_date',
placeholder: 'The date the movie was released...',
},
{ type: 'input', label: 'Tag', key: 'video:tag', placeholder: 'Tag words associated with this movie...' },
],
};

View File

@@ -0,0 +1,7 @@
import type { OGSchemaType } from '../OGSchemaType.type';
import { videoMovie } from './videoMovie';
export const videoOther: OGSchemaType = {
name: 'Other video details',
elements: [...videoMovie.elements],
};

View File

@@ -0,0 +1,7 @@
import type { OGSchemaType } from '../OGSchemaType.type';
import { videoMovie } from './videoMovie';
export const videoTVShow: OGSchemaType = {
name: 'TV show details',
elements: [...videoMovie.elements],
};

View File

@@ -0,0 +1,56 @@
import type { OGSchemaType } from '../OGSchemaType.type';
const typeOptions = [
{ label: 'Website', value: 'website' },
{ label: 'Article', value: 'article' },
{ label: 'Book', value: 'book' },
{ label: 'Profile', value: 'profile' },
{
type: 'group',
label: 'Music',
key: 'Music',
children: [
{ label: 'Song', value: 'music.song' },
{ label: 'Music album', value: 'music.album' },
{ label: 'Playlist', value: 'music.playlist' },
{ label: 'Radio station', value: 'music.radio_station' },
],
},
{
type: 'group',
label: 'Video',
key: 'Video',
children: [
{ label: 'Movie', value: 'video.movie' },
{ label: 'Episode', value: 'video.episode' },
{ label: 'TV show', value: 'video.tv_show' },
{ label: 'Other video', value: 'video.other' },
],
},
];
export const website: OGSchemaType = {
name: 'General information',
elements: [
{
type: 'select',
label: 'Page type',
placeholder: 'Select the type of your website...',
key: 'type',
options: typeOptions,
},
{ type: 'input', label: 'Title', placeholder: 'Enter the title of your website...', key: 'title' },
{
type: 'input',
label: 'Description',
placeholder: 'Enter the description of your website...',
key: 'description',
},
{
type: 'input',
label: 'Page URL',
placeholder: 'Enter the url of your website...',
key: 'url',
},
],
};

View File

@@ -0,0 +1,11 @@
import { World } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Mime types',
path: '/mime-types',
description: 'Convert mime types to extensions and vice-versa.',
keywords: ['mime', 'types', 'extension', 'content', 'type'],
component: () => import('./mime-types.vue'),
icon: World,
});

View File

@@ -0,0 +1,97 @@
<template>
<n-card>
<n-h2 style="margin-bottom: 0">Mime type to extension</n-h2>
<div style="opacity: 0.8">Now witch file extensions are associated to a mime-type</div>
<n-form-item>
<n-select
v-model:value="selectedMimeType"
filterable
:options="mimeToExtensionsOptions"
size="large"
placeholder="Select your mimetype here... (ex: application/pdf)"
/>
</n-form-item>
<div v-if="extensionsFound.length > 0">
Extensions of files with the <n-tag round :bordered="false">{{ selectedMimeType }}</n-tag> mime-type:
<div style="margin-top: 10px">
<n-tag
v-for="extension of extensionsFound"
:key="extension"
round
:bordered="false"
type="primary"
style="margin-right: 10px"
>
.{{ extension }}
</n-tag>
</div>
</div>
</n-card>
<n-card>
<n-h2 style="margin-bottom: 0">File extension to mime type</n-h2>
<div style="opacity: 0.8">Now witch mime type is associated to a file extension</div>
<n-form-item>
<n-select
v-model:value="selectedExtension"
filterable
:options="extensionToMimeTypeOptions"
size="large"
placeholder="Select your mimetype here... (ex: application/pdf)"
/>
</n-form-item>
<div v-if="selectedExtension">
Mime type associated to the extension <n-tag round :bordered="false">{{ selectedExtension }}</n-tag> file
extension:
<div style="margin-top: 10px">
<n-tag round :bordered="false" type="primary" style="margin-right: 10px">
{{ mimeTypeFound }}
</n-tag>
</div>
</div>
</n-card>
<div>
<n-table>
<thead>
<tr>
<th>Mime types</th>
<th>Extensions</th>
</tr>
</thead>
<tbody>
<tr v-for="{ mimeType, extensions } of mimeInfos" :key="mimeType">
<td>{{ mimeType }}</td>
<td>
<n-tag v-for="extension of extensions" :key="extension" round :bordered="false" style="margin-right: 10px">
.{{ extension }}
</n-tag>
</td>
</tr>
</tbody>
</n-table>
</div>
</template>
<script setup lang="ts">
import { types as extensionToMimeType, extensions as mimeTypeToExtension } from 'mime-types';
import { computed, ref } from 'vue';
const mimeInfos = Object.entries(mimeTypeToExtension).map(([mimeType, extensions]) => ({ mimeType, extensions }));
const mimeToExtensionsOptions = Object.keys(mimeTypeToExtension).map((label) => ({ label, value: label }));
const selectedMimeType = ref(undefined);
const extensionsFound = computed(() => (selectedMimeType.value ? mimeTypeToExtension[selectedMimeType.value] : []));
const extensionToMimeTypeOptions = Object.keys(extensionToMimeType).map((label) => {
const extension = `.${label}`;
return { label: extension, value: label };
});
const selectedExtension = ref(undefined);
const mimeTypeFound = computed(() => (selectedExtension.value ? extensionToMimeType[selectedExtension.value] : []));
</script>

View File

@@ -0,0 +1,27 @@
import { DeviceMobile } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'OTP code generator',
path: '/otp-generator',
description: 'Generate and validate time-based OTP (one time password) for multi-factor authentication.',
keywords: [
'otp',
'code',
'generator',
'validator',
'one',
'time',
'password',
'authentication',
'MFA',
'mobile',
'device',
'security',
'TOTP',
'Time',
'HMAC',
],
component: () => import('./otp-code-generator-and-validator.vue'),
icon: DeviceMobile,
});

View File

@@ -0,0 +1,140 @@
<template>
<div style="max-width: 350px">
<n-form-item label="Secret" v-bind="secretValidationAttrs">
<n-input v-model:value="secret" placeholder="Paste your TOTP secret...">
<template #suffix>
<n-tooltip trigger="hover">
<template #trigger>
<n-button quaternary circle @click="refreshSecret">
<n-icon :component="Refresh" />
</n-button>
</template>
Generate secret token
</n-tooltip>
</template>
</n-input>
</n-form-item>
<div>
<token-display :tokens="tokens" style="margin-top: 2px" />
<n-progress :percentage="(100 * interval) / 30" :color="theme.primaryColor" :show-indicator="false" />
<div style="text-align: center">Next in {{ String(Math.floor(30 - interval)).padStart(2, '0') }}s</div>
</div>
<n-space justify="center" vertical align="center" style="margin-top: 10px">
<n-image :src="qrcode"></n-image>
<n-button secondary tag="a" :href="keyUri" target="_blank">Open Key URI in new tab</n-button>
</n-space>
</div>
<div style="max-width: 350px">
<n-form-item label="Secret in hexadecimal">
<input-copyable :value="base32toHex(secret)" readonly placeholder="Secret in hex will be displayed here" />
</n-form-item>
<n-form-item label="Epoch">
<input-copyable
:value="Math.floor(now / 1000).toString()"
readonly
placeholder="Epoch in sec will be displayed here"
/>
</n-form-item>
<n-form-item label="Iteration" :show-feedback="false">
<n-input-group>
<n-input-group-label style="width: 110px">Count:</n-input-group-label>
<input-copyable
:value="String(getCounterFromTime({ now, timeStep: 30 }))"
readonly
placeholder="Iteration count will be displayed here"
/>
</n-input-group>
</n-form-item>
<n-form-item label="Iteration" :show-label="false" style="margin-top: 5px">
<n-input-group>
<n-input-group-label style="width: 110px">Padded hex:</n-input-group-label>
<input-copyable
:value="getCounterFromTime({ now, timeStep: 30 }).toString(16).padStart(16, '0')"
readonly
placeholder="Iteration count in hex will be displayed here"
/>
</n-input-group>
</n-form-item>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Refresh } from '@vicons/tabler';
import { useTimestamp, whenever } from '@vueuse/core';
import { useThemeVars } from 'naive-ui';
import { useStyleStore } from '@/stores/style.store';
import InputCopyable from '@/components/InputCopyable.vue';
import { useValidation } from '@/composable/validation';
import { generateTOTP, buildKeyUri, generateSecret, base32toHex, getCounterFromTime } from './otp.service';
import { useQRCode } from '../qr-code-generator/useQRCode';
import TokenDisplay from './token-display.vue';
const now = useTimestamp();
const interval = computed(() => (now.value / 1000) % 30);
const theme = useThemeVars();
const styleStore = useStyleStore();
const secret = ref(generateSecret());
const tokens = ref(buildTokens());
const keyUri = computed(() => buildKeyUri({ secret: secret.value }));
const { qrcode } = useQRCode({
text: keyUri,
color: { background: '#00000000', foreground: computed(() => (styleStore.isDarkTheme ? '#ffffff' : '#000000')) },
options: { width: 210 },
});
const { attrs: secretValidationAttrs } = useValidation({
source: secret,
rules: [
{
message: 'Secret should be a base32 string',
validator: (value) => value.match(/^[A-Z234567]+$/),
},
{
message: 'Please set a secret',
validator: (value) => value !== '',
},
],
});
// watch + whenever to prevent token to be refresh every raf
watch([secret], refreshToken);
whenever(() => Math.floor(interval.value) === 0, refreshToken);
function refreshSecret() {
secret.value = generateSecret();
}
function refreshToken() {
tokens.value = buildTokens();
}
function buildTokens() {
return {
previous: generateTOTP({ key: secret.value, now: now.value - 30000 }),
current: generateTOTP({ key: secret.value, now: now.value }),
next: generateTOTP({ key: secret.value, now: now.value + 30000 }),
};
}
</script>
<style lang="less" scoped>
.n-progress {
margin-top: 10px;
::v-deep(.n-progress-graph-line-fill) {
transition-duration: 0.05s !important;
}
}
.token {
text-align: center;
&.token-current {
font-size: 20px;
}
}
</style>

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest';
import {
generateHOTP,
hexToBytes,
verifyHOTP,
generateTOTP,
verifyTOTP,
buildKeyUri,
base32toHex,
} from './otp.service';
describe('otp functions', () => {
describe('hexToBytes', () => {
it('convert an hexstring to a byte array', () => {
expect(hexToBytes('1')).to.eql([1]);
expect(hexToBytes('ffffff')).to.eql([255, 255, 255]);
expect(hexToBytes('000000000')).to.eql([0, 0, 0, 0, 0]);
expect(hexToBytes('a3218bcef89')).to.eql([163, 33, 139, 206, 248, 9]);
expect(hexToBytes('063679ca')).toEqual([6, 54, 121, 202]);
expect(hexToBytes('0102030405060708090a0b0c0d0e0f')).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]);
});
});
describe('base32tohex', () => {
it('convert a base32 to hex string', () => {
expect(base32toHex('ABCDEF')).to.eql('00443205');
expect(base32toHex('7777')).to.eql('ffff0f');
expect(base32toHex('JBSWY3DPEHPK3PXP')).to.eql('48656c6c6f21deadbeef');
});
});
describe('generateHOTP', () => {
it('generates HOTP codes for a given counter', () => {
const key = 'JBSWY3DPEHPK3PXP';
const hotpCodes = ['282760', '996554', '602287', '143627', '960129'];
for (const [counter, code] of hotpCodes.entries()) {
expect(generateHOTP({ key, counter })).to.eql(code);
}
});
});
describe('verifyHOTP', () => {
it('validate hotp for a given secret', () => {
const key = 'JBSWY3DPEHPK3PXP';
const hotpCodes = ['282760', '996554', '602287', '143627', '960129'];
for (const [counter, token] of hotpCodes.entries()) {
expect(verifyHOTP({ token, key, counter, window: 0 })).to.eql(true);
}
expect(verifyHOTP({ token: 'INVALID', key })).to.eql(false);
});
it('does not validate hotp out of sync', () => {
const key = 'JBSWY3DPEHPK3PXP';
const token = '282760';
expect(verifyHOTP({ token, key, counter: 5, window: 2 })).to.eql(false);
expect(verifyHOTP({ token, key, counter: 5, window: 5 })).to.eql(true);
});
});
describe('generateTOTP', () => {
it('generates TOTP codes', () => {
const key = 'JBSWY3DPEHPK3PXP';
const codes = [
{ token: '282760', now: 0 },
{ token: '341128', now: 1465324707000 },
{ token: '089029', now: 1365324707000 },
];
for (const { token, now } of codes) {
expect(generateTOTP({ key, now })).to.eql(token);
}
});
});
describe('verifyTOTP', () => {
it('verify TOTP in sync codes against a key', () => {
const key = 'JBSWY3DPEHPK3PXP';
const codes = [
{ token: '282760', now: 0 },
{ token: '341128', now: 1465324707000 },
{ token: '089029', now: 1365324707000 },
];
for (const { token, now } of codes) {
expect(verifyTOTP({ key, token, now })).to.eql(true);
}
});
it('does not validate totp out of sync', () => {
const key = 'JBSWY3DPEHPK3PXP';
const token = '635183';
const now = 1661266455000;
expect(verifyTOTP({ key, token, now, window: 2 })).to.eql(true);
expect(verifyTOTP({ key, token, now, window: 1 })).to.eql(false);
});
});
describe('buildKeyUri', () => {
it('build a key uri string', () => {
expect(buildKeyUri({ secret: 'JBSWY3DPEHPK3PXP' })).to.eql(
'otpauth://totp/IT-Tools:demo-user?issuer=IT-Tools&secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&period=30',
);
expect(
buildKeyUri({
secret: 'JBSWY3DPEHPK3PXP',
app: 'app-name',
account: 'account',
algorithm: 'algo',
digits: 7,
period: 10,
}),
).to.eql(
'otpauth://totp/app-name:account?issuer=app-name&secret=JBSWY3DPEHPK3PXP&algorithm=algo&digits=7&period=10',
);
});
});
});

Some files were not shown because too many files have changed in this diff Show More