Compare commits

...

79 Commits

Author SHA1 Message Date
Corentin Thomasset
4a5734d4a3 chore(release): 2.18.0 2023-02-04 09:59:56 +01:00
Tsonglew
f708f5091e feat(new-tool): json minify (#265)
Co-authored-by: Corentin THOMASSET <corentin.thomasset74@gmail.com>
2023-02-04 09:56:17 +01:00
Corentin Thomasset
db817a2459 refactor(tools): config in query params 2023-01-28 21:06:16 +01:00
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
120 changed files with 11022 additions and 20143 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,215 @@
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.18.0](https://github.com/CorentinTh/it-tools/compare/v2.17.0...v2.18.0) (2023-02-04)
### Features
* **new-tool:** json minify ([#265](https://github.com/CorentinTh/it-tools/issues/265)) ([f708f50](https://github.com/CorentinTh/it-tools/commit/f708f5091e2182fc88e7cf3e7d23b3d05edc04da))
### Refactors
* **tools:** config in query params ([db817a2](https://github.com/CorentinTh/it-tools/commit/db817a2459e23bd096274a7f91815d613d5f7ff4))
## [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)

View File

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

View File

@@ -31,17 +31,19 @@
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: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: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>

19645
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.9.1",
"version": "2.18.0",
"description": "Collection of handy online tools for developers, with great UX. ",
"keywords": [
"productivity",
@@ -32,62 +32,74 @@
},
"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",
"@vueuse/router": "^9.11.0",
"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",
"mathjs": "^10.6.4",
"mime-types": "^2.1.35",
"naive-ui": "^2.31.0",
"pinia": "^2.0.11",
"plausible-tracker": "^0.3.5",
"qrcode": "^1.5.0",
"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/jsdom": "^16.2.15",
"@types/lodash": "^4.14.188",
"@types/mime-types": "^2.1.1",
"@types/node": "^16.11.25",
"@types/qrcode": "^1.4.2",
"@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"
}
}

7421
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,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

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

View File

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

View File

@@ -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,16 +1,35 @@
import { extension as getExtensionFromMime } from 'mime-types';
import type { Ref } from 'vue';
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 base64 = source.value;
const mimeType = base64.match(/data:(.*?);base64/i)?.[1] ?? 'text/plain';
console.log({ mimeType });
const cleanFileName = filename ?? `file.${getExtensionFromMime(mimeType)}`;
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.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,35 @@
import { useRouteQuery } from '@vueuse/router';
import { computed } from 'vue';
export { useQueryParam };
const transformers = {
number: {
fromQuery: (value: string) => Number(value),
toQuery: (value: number) => String(value),
},
string: {
fromQuery: (value: string) => value,
toQuery: (value: string) => value,
},
boolean: {
fromQuery: (value: string) => value.toLowerCase() === 'true',
toQuery: (value: boolean) => (value ? 'true' : 'false'),
},
};
function useQueryParam<T>({ name, defaultValue }: { name: string; defaultValue: T }) {
const type = typeof defaultValue;
const transformer = transformers[type as keyof typeof transformers] ?? transformers.string;
const proxy = useRouteQuery(name, transformer.toQuery(defaultValue as never));
return computed<T>({
get() {
return transformer.fromQuery(proxy.value) as T;
},
set(value) {
proxy.value = transformer.toQuery(value as never);
},
});
}

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>
@@ -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 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

@@ -1,7 +1,9 @@
<script setup lang="ts">
import { useTracker } from '@/modules/tracker/tracker.services';
import { useHead } from '@vueuse/head';
useHead({ title: 'About - IT Tools' });
const { tracker } = useTracker();
</script>
<template>
@@ -25,6 +27,7 @@ useHead({ title: 'About - IT Tools' });
href="https://github.com/sponsors/CorentinTh"
rel="noopener"
target="_blank"
@click="() => tracker.trackEvent({ eventName: 'Support button clicked' })"
>
sponsoring me </n-button
>.
@@ -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,60 +1,65 @@
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,
NDatePicker,
NSelect,
NSlider,
NSpace,
NStatistic,
NSwitch,
NTable,
NTag,
NText,
NTimePicker,
NTooltip,
NUpload,
NUploadDragger,
NPopover,
NCheckbox,
} from 'naive-ui';
const components = [
NCheckbox,
NDynamicInput,
NDatePicker,
NCode,
NGradientText,
@@ -107,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,8 +1,16 @@
<template>
<n-card title="Base64 to file">
<n-input v-model:value="base64Input" type="textarea" placeholder="Put your base64 file string here..." rows="5" />
<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="download()"> Download file </n-button>
<n-button :disabled="base64Input === '' || !base64InputValidation.isValid" secondary @click="downloadFile()">
Download file
</n-button>
</n-space>
</n-card>
@@ -16,7 +24,7 @@
</n-upload-dragger>
</n-upload>
<n-input :value="fileBase64" type="textarea" readonly placeholder="File in ase64 will be here" />
<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,6 +34,8 @@
<script setup lang="ts">
import { useCopy } from '@/composable/copy';
import { useDownloadFileFromBase64 } from '@/composable/downloadBase64';
import { useValidation } from '@/composable/validation';
import { isValidBase64 } from '@/utils/base64';
import { Upload } from '@vicons/tabler';
import { useBase64 } from '@vueuse/core';
import type { UploadFileInfo } from 'naive-ui';
@@ -33,6 +43,25 @@ import { ref, type Ref } from 'vue';
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

@@ -3,10 +3,9 @@ import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Base64 file converter',
path: '/base64-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-file-converter.vue'),
icon: FileDigit,
redirectFrom: ['/file-to-base64', '/base64-string-converter'],
});

View File

@@ -20,11 +20,7 @@
</n-card>
<n-card title="Base64 to string">
<n-form-item
label="Base64 string to decode"
:validation-status="b64Validation.status"
:feedback="b64Validation.message"
>
<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>
@@ -41,25 +37,19 @@
<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(() => window.btoa(textInput.value));
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(() => {
try {
return window.atob(base64Input.value);
} catch (_) {
return '';
}
});
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) => true || window.atob(value) }],
rules: [{ message: 'Invalid base64 string', validator: (value) => isValidBase64(value.trim()) }],
});
</script>
<style lang="less" scoped></style>

View File

@@ -30,11 +30,12 @@
<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 ${window.btoa(`${username.value}:${password.value}`)}`);
const header = computed(() => `Authorization: Basic ${textToBase64(`${username.value}:${password.value}`)}`);
const { copy } = useCopy({ source: header, text: 'Header copied to the clipboard' });
</script>

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

@@ -1,12 +1,13 @@
import { describe, expect, it } from 'vitest';
import { formatChronometerTime } from './chronometer.service';
import { formatMs } from './chronometer.service';
describe('chronometer', () => {
describe('formatChronometerTime', () => {
it('format the elapsed time', () => {
expect(formatChronometerTime({ elapsed: 123456 })).toEqual('02:03.456');
expect(formatChronometerTime({ elapsed: 123456, msPerUnit: 100 })).toEqual('03:25:45.600');
expect(formatChronometerTime({ elapsed: 12345600 })).toEqual('03:25:45.600');
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

@@ -1,10 +1,8 @@
export function formatChronometerTime({ elapsed, msPerUnit = 1 }: { elapsed: number; msPerUnit?: number }) {
const elapsedMs = elapsed * msPerUnit;
const ms = elapsedMs % 1000;
const secs = ((elapsedMs - ms) / 1000) % 60;
const mins = (((elapsedMs - ms) / 1000 - secs) / 60) % 60;
const hrs = (((elapsedMs - ms) / 1000 - secs) / 60 - mins) / 60;
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

View File

@@ -1,11 +1,11 @@
<template>
<div>
<n-card>
<div class="duration">{{ formatChronometerTime({ elapsed: counter, msPerUnit }) }}</div>
<div class="duration">{{ formatMs(counter) }}</div>
</n-card>
<br />
<n-space justify="center">
<n-button v-if="!isActive" secondary type="primary" @click="resume">Start</n-button>
<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>
@@ -14,12 +14,33 @@
</template>
<script setup lang="ts">
import { useInterval } from '@vueuse/core';
import { formatChronometerTime } from './chronometer.service';
import { useRafFn } from '@vueuse/core';
import { ref } from 'vue';
import { formatMs } from './chronometer.service';
const msPerUnit = 10;
const isRunning = ref(false);
const counter = ref(0);
const { counter, pause, resume, isActive } = useInterval(msPerUnit, { controls: true, immediate: false });
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>

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

@@ -11,7 +11,7 @@
<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" />
<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" />
@@ -19,10 +19,10 @@
</n-space>
<n-form-item label="Amount of unit consumed by time span" :show-feedback="false">
<n-input-number v-model:value="unitPerTimeSpan" />
<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" />
<n-input-number v-model:value="timeSpan" :min="1" />
<n-select
v-model:value="timeSpanUnitMultiplier"
:options="[
@@ -51,7 +51,10 @@
</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';

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,11 @@
</template>
<script setup lang="ts">
import InputCopyable from '../../components/InputCopyable.vue';
import { useQueryParam } from '@/composable/queryParams';
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 +58,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 = useQueryParam<Encoding>({ defaultValue: 'Hex', name: 'encoding' });
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,12 +1,11 @@
import { LockOpen } from '@vicons/tabler';
import type { ToolCategory } from './tool';
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 jsonMinify } from './json-minify';
import { tool as bcrypt } from './bcrypt';
import { tool as bip39 } from './bip39-generator';
import { tool as caseConverter } from './case-converter';
import { tool as chmodCalculator } from './chmod-calculator';
import { tool as chronometer } from './chronometer';
import { tool as colorConverter } from './color-converter';
import { tool as crontabGenerator } from './crontab-generator';
@@ -16,17 +15,25 @@ 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 type { ToolCategory } from './tools.types';
import { tool as urlEncoder } from './url-encoder';
import { tool as urlParser } from './url-parser';
import { tool as uuidGenerator } from './uuid-generator';
@@ -34,12 +41,10 @@ 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,
@@ -52,27 +57,36 @@ export const toolsByCategory: ToolCategory[] = [
},
{
name: 'Web',
icon: LockOpen,
components: [urlEncoder, htmlEntities, qrCodeGenerator, urlParser, deviceInformation, basicAuthGenerator],
components: [
urlEncoder,
htmlEntities,
urlParser,
deviceInformation,
basicAuthGenerator,
metaTagGenerator,
otpCodeGeneratorAndValidator,
mimeTypes,
jwtParser,
],
},
{
name: 'Images',
components: [qrCodeGenerator, svgPlaceholderGenerator],
},
{
name: 'Development',
icon: LockOpen,
components: [gitMemo, randomPortGenerator, crontabGenerator, jsonViewer, sqlPrettify],
components: [gitMemo, randomPortGenerator, crontabGenerator, jsonViewer, jsonMinify, sqlPrettify, chmodCalculator],
},
{
name: 'Math',
icon: LockOpen,
components: [mathEvaluator, etaCalculator],
},
{
name: 'Measurement',
icon: LockOpen,
components: [chronometer],
components: [chronometer, temperatureConverter],
},
{
name: 'Text',
icon: LockOpen,
components: [loremIpsumGenerator, textStatistics],
},
];

View File

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

View File

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

@@ -0,0 +1,11 @@
import { Braces } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'JSON minify',
path: '/json-minify',
description: 'Minify and compress your JSON by removing unnecessary white spaces.',
keywords: ['json', 'minify', 'format'],
component: () => import('./json-minify.vue'),
icon: Braces,
});

View File

@@ -0,0 +1,57 @@
<template>
<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="Minify version of your JSON">
<textarea-copyable :value="cleanJson" language="json" :follow-height-of="inputElement" />
</n-form-item>
</template>
<script setup lang="ts">
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';
const inputElement = ref<HTMLElement>();
const rawJson = ref('{\n\t"hello": [\n\t\t"world"\n\t]\n}');
const cleanJson = computed(() => withDefaultOnError(() => JSON.stringify(JSON5.parse(rawJson.value), null, 0), ''));
const rawJsonValidation = useValidation({
source: rawJson,
rules: [
{
validator: (v) => v === '' || JSON5.parse(v),
message: 'Provided JSON is not valid.',
},
],
});
</script>
<style lang="less" scoped>
.result-card {
position: relative;
.copy-button {
position: absolute;
top: 10px;
right: 10px;
}
}
</style>

View File

@@ -17,44 +17,28 @@
/>
</n-form-item>
<n-form-item label="Prettify version of your json">
<n-card class="result-card" :style="`min-height: ${inputElementHeight ?? 400}px`">
<n-config-provider :hljs="hljs">
<n-code :code="cleanJson" language="json" :trim="false" />
</n-config-provider>
<n-button v-if="cleanJson" class="copy-button" secondary @click="copy">Copy</n-button>
</n-card>
<textarea-copyable :value="cleanJson" language="json" :follow-height-of="inputElement" />
</n-form-item>
</template>
<script setup lang="ts">
import { useCopy } from '@/composable/copy';
import TextareaCopyable from '@/components/TextareaCopyable.vue';
import { useValidation } from '@/composable/validation';
import { useElementSize } from '@vueuse/core';
import hljs from 'highlight.js/lib/core';
import json from 'highlight.js/lib/languages/json';
import { withDefaultOnError } from '@/utils/defaults';
import JSON5 from 'json5';
import { computed, ref } from 'vue';
hljs.registerLanguage('json', json);
const inputElement = ref<HTMLElement>();
const { height: inputElementHeight } = useElementSize(inputElement);
const rawJson = ref('{"hello": "world"}');
const cleanJson = computed(() => {
try {
return JSON.stringify(JSON.parse(rawJson.value), null, 3);
} catch (_) {
return '';
}
});
const { copy } = useCopy({ source: cleanJson });
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',
validator: (v) => v === '' || JSON5.parse(v),
message: 'Provided JSON is not valid.',
},
],
});

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',
);
});
});
});

View File

@@ -0,0 +1,139 @@
import { enc, HmacSHA1 } from 'crypto-js';
import _ from 'lodash';
import { createToken } from '../token-generator/token-generator.service';
export {
generateHOTP,
hexToBytes,
verifyHOTP,
generateTOTP,
verifyTOTP,
buildKeyUri,
generateSecret,
base32toHex,
getCounterFromTime,
};
function hexToBytes(hex: string) {
return (hex.match(/.{1,2}/g) ?? []).map((char) => parseInt(char, 16));
}
function computeHMACSha1(message: string, key: string) {
return HmacSHA1(enc.Hex.parse(message), enc.Hex.parse(base32toHex(key))).toString(enc.Hex);
}
function base32toHex(base32: string) {
const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const bits = base32
.replace(/=+$/, '')
.split('')
.map((value) => base32Chars.indexOf(value).toString(2).padStart(5, '0'))
.join('');
const hex = (bits.match(/.{1,8}/g) ?? []).map((chunk) => parseInt(chunk, 2).toString(16).padStart(2, '0')).join('');
return hex;
}
function generateHOTP({ key, counter = 0 }: { key: string; counter?: number }) {
// Compute HMACdigest
const digest = computeHMACSha1(counter.toString(16).padStart(16, '0'), key);
// Get byte array
const bytes = hexToBytes(digest);
// Truncate
const offset = bytes[19] & 0xf;
const v =
((bytes[offset] & 0x7f) << 24) |
((bytes[offset + 1] & 0xff) << 16) |
((bytes[offset + 2] & 0xff) << 8) |
(bytes[offset + 3] & 0xff);
const code = String(v % 1000000).padStart(6, '0');
return code;
}
function verifyHOTP({
token,
key,
window = 0,
counter = 0,
}: {
token: string;
key: string;
window?: number;
counter?: number;
}) {
for (let i = counter - window; i <= counter + window; ++i) {
if (generateHOTP({ key, counter: i }) === token) {
return true;
}
}
return false;
}
function getCounterFromTime({ now, timeStep }: { now: number; timeStep: number }) {
return Math.floor(now / 1000 / timeStep);
}
function generateTOTP({ key, now = Date.now(), timeStep = 30 }: { key: string; now?: number; timeStep?: number }) {
const counter = getCounterFromTime({ now, timeStep });
return generateHOTP({ key, counter });
}
function verifyTOTP({
key,
token,
window = 0,
now = Date.now(),
timeStep = 30,
}: {
token: string;
key: string;
window?: number;
now?: number;
timeStep?: number;
}) {
const counter = getCounterFromTime({ now, timeStep });
return verifyHOTP({ token, key, window, counter });
}
function buildKeyUri({
secret,
app = 'IT-Tools',
account = 'demo-user',
algorithm = 'SHA1',
digits = 6,
period = 30,
}: {
secret: string;
app?: string;
account?: string;
algorithm?: string;
digits?: number;
period?: number;
}) {
const params = {
issuer: app,
secret,
algorithm,
digits,
period,
};
const paramsString = _(params)
.map((value, key) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
return `otpauth://totp/${encodeURIComponent(app)}:${encodeURIComponent(account)}?${paramsString}`;
}
function generateSecret() {
return createToken({ length: 16, alphabet: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' });
}

View File

@@ -0,0 +1,66 @@
<template>
<div>
<n-space class="labels" item-style="flex: 1 1 0" style="width: 100%" align="center">
<div style="text-align: left">Previous</div>
<div style="text-align: center">Current OTP</div>
<div style="text-align: right">Next</div>
</n-space>
<n-input-group>
<n-tooltip trigger="hover" placement="bottom">
<template #trigger>
<n-button secondary @click.prevent="copyPrevious(tokens.previous)">{{ tokens.previous }}</n-button>
</template>
<div>{{ previousCopied ? 'Copied !' : 'Copy previous OTP' }}</div>
</n-tooltip>
<n-tooltip trigger="hover" placement="bottom">
<template #trigger>
<n-button tertiary type="primary" class="current-otp" @click.prevent="copyCurrent(tokens.current)">
{{ tokens.current }}
</n-button>
</template>
<div>{{ currentCopied ? 'Copied !' : 'Copy current OTP' }}</div>
</n-tooltip>
<n-tooltip trigger="hover" placement="bottom">
<template #trigger>
<n-button secondary @click.prevent="copyNext(tokens.next)">{{ tokens.next }}</n-button>
</template>
<div>{{ nextCopied ? 'Copied !' : 'Copy next OTP' }}</div>
</n-tooltip>
</n-input-group>
</div>
</template>
<script setup lang="ts">
import { useClipboard } from '@vueuse/core';
import { toRefs } from 'vue';
const { copy: copyPrevious, copied: previousCopied } = useClipboard();
const { copy: copyCurrent, copied: currentCopied } = useClipboard();
const { copy: copyNext, copied: nextCopied } = useClipboard();
const props = defineProps<{ tokens: { previous: string; current: string; next: string } }>();
const { tokens } = toRefs(props);
</script>
<style scoped lang="less">
.current-otp {
font-size: 22px;
flex: 1 0 35% !important;
}
.n-button {
height: 45px;
}
.labels {
div {
text-align: center;
padding: 0 2px 6px 2px;
line-height: 1.25;
}
}
.n-input-group > * {
flex: 1 0 0;
}
</style>

View File

@@ -32,9 +32,9 @@
<script setup lang="ts">
import { useDownloadFileFromBase64 } from '@/composable/downloadBase64';
import { useQRCode } from './useQRCode';
import { ref } from 'vue';
import type { QRCodeErrorCorrectionLevel } from 'qrcode';
import { useQRCode } from './useQRCode';
const foreground = ref('#000000ff');
const background = ref('#ffffffff');

View File

@@ -1,5 +1,6 @@
import { get, type MaybeRef } from '@vueuse/core';
import QRCode, { type QRCodeErrorCorrectionLevel, type QRCodeToDataURLOptions } from 'qrcode';
import { ref, watch, type Ref } from 'vue';
import { ref, watch, isRef } from 'vue';
export function useQRCode({
text,
@@ -7,24 +8,24 @@ export function useQRCode({
errorCorrectionLevel,
options,
}: {
text: Ref<string>;
color: { foreground: Ref<string>; background: Ref<string> };
errorCorrectionLevel: Ref<QRCodeErrorCorrectionLevel>;
text: MaybeRef<string>;
color: { foreground: MaybeRef<string>; background: MaybeRef<string> };
errorCorrectionLevel?: MaybeRef<QRCodeErrorCorrectionLevel>;
options?: QRCodeToDataURLOptions;
}) {
const qrcode = ref('');
watch(
[text, background, foreground, errorCorrectionLevel],
[text, background, foreground, errorCorrectionLevel].filter(isRef),
async () => {
if (text.value)
qrcode.value = await QRCode.toDataURL(text.value, {
if (get(text))
qrcode.value = await QRCode.toDataURL(get(text), {
color: {
dark: foreground.value,
light: background.value,
dark: get(foreground),
light: get(background),
...options?.color,
},
errorCorrectionLevel: errorCorrectionLevel.value,
errorCorrectionLevel: get(errorCorrectionLevel) ?? 'M',
...options,
});
},

View File

@@ -58,27 +58,17 @@
/>
</n-form-item>
<n-form-item label="Prettify version of your query">
<n-card class="result-card" :style="`min-height: ${inputElementHeight ?? 400}px`">
<n-config-provider :hljs="hljs">
<n-code :code="prettySQL" language="sql" :trim="false" />
</n-config-provider>
<n-button v-if="prettySQL" class="copy-button" secondary @click="copy">Copy</n-button>
</n-card>
<textarea-copyable :value="prettySQL" language="sql" :follow-height-of="inputElement" />
</n-form-item>
</template>
<script setup lang="ts">
import { useCopy } from '@/composable/copy';
import TextareaCopyable from '@/components/TextareaCopyable.vue';
import { useStyleStore } from '@/stores/style.store';
import { useElementSize } from '@vueuse/core';
import hljs from 'highlight.js/lib/core';
import sqlHljs from 'highlight.js/lib/languages/sql';
import { format as formatSQL, type FormatFnOptions } from 'sql-formatter';
import { computed, reactive, ref } from 'vue';
hljs.registerLanguage('sql', sqlHljs);
const inputElement = ref<HTMLElement>();
const { height: inputElementHeight } = useElementSize(inputElement);
const styleStore = useStyleStore();
const config = reactive<Partial<FormatFnOptions>>({
keywordCase: 'upper',
@@ -90,7 +80,6 @@ const config = reactive<Partial<FormatFnOptions>>({
const rawSQL = ref('select field1,field2,field3 from my_table where my_condition;');
const prettySQL = computed(() => formatSQL(rawSQL.value, config));
const { copy } = useCopy({ source: prettySQL });
</script>
<style lang="less" scoped>

View File

@@ -0,0 +1,11 @@
import { ImageOutlined } from '@vicons/material';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'SVG placeholder generator',
path: '/svg-placeholder-generator',
description: 'Generate svg images to use as placeholder in your applications.',
keywords: ['svg', 'placeholder', 'generator', 'image', 'size', 'mockup'],
component: () => import('./svg-placeholder-generator.vue'),
icon: ImageOutlined,
});

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