Merge branch 'main' into html-md-converter

# Conflicts:
#	components.d.ts
#	package.json
#	pnpm-lock.yaml
This commit is contained in:
Leon Letto 2024-01-15 09:21:24 -08:00
commit dc21e503c5
118 changed files with 3814 additions and 1859 deletions

5
.dockerignore Normal file
View file

@ -0,0 +1,5 @@
node_modules
playwright-report
coverage
dist
test-results

View file

@ -57,26 +57,26 @@ jobs:
uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile

View file

@ -6,7 +6,7 @@ on:
- main
jobs:
test:
timeout-minutes: 60
timeout-minutes: 10
runs-on: ubuntu-latest
strategy:
matrix:

View file

@ -16,26 +16,26 @@ jobs:
uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile

2
.nvmrc
View file

@ -1 +1 @@
18.18.0
18.18.2

View file

@ -2,6 +2,109 @@
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.
## Version 2023.12.21-5ed3693
### Features
- **i18n**: improve chinese i18n (#757) (2e56641)
- **i18n**: add tooltip and favoriteButton i18n (#756) (a1037cf)
- **i18n**: add Chinese translation base (#718) (8f99eb6)
- **new tool**: pdf signature checker (#745) (4781920)
- **new tool**: numeronym generator (#729) (e07e2ae)
### Bug fixes
- **jwt-parser**: jwt claim array support (#799) (5ed3693)
- **camera-recorder**: stop camera on navigation (#782) (80e46c9)
- **doc**: updated create new tool command in readme (#762) (7a70dbb)
- **base64-file-converter**: fix downloading of index.html content without data preambula (#750) (043e4f0)
- **docker**: rollback armv7 in docker releases (#741) (205e360)
- **eta**: corrected example (#737) (821cbea)
### Refactoring
- **about, i18n**: improved i18n dx with markdown (#753) (bd3edcb)
- **token, i18n**: complete fr translation (#752) (de1ee69)
- **uuid generator**: uuid version picker (#751) (38586ca)
- **case converter**: no split on lowercase, uppercase and mocking case (#748) (ca43a25)
- **ui**: replaced legacy n-upload with c-file-upload (#747) (7fe47b3)
- **token**: added password in token generator keywords (#746) (16ffe6b)
- **bcrypt**: fix input label align (#721) (093ff31)
### Chores
- **deps**: switched from oui to oui-data for mac address lookup (#693) (0fe9a20)
- **deps**: update unocss monorepo to ^0.57.0 (#638) (2e396d8)
- **docker**: added armv7 plateform for docker releases (#722) (fe1de8c)
## Version 2023.11.02-7d94e11
### Features
- **i18n**: language selector (#710) (e86fd96)
### Bug fixes
- **dockerfile**: revert replacement of nginx image with non-privileged one (#716) (7d94e11)
- **encryption**: alert on decryption error (#711) (02b0d0d)
### Refactoring
- **math-evaluator**: improved description (e87f4b1)
- **math-evaluator**: improved search and UX (#713) (58de897)
## Version 2023.11.01-e164afb
### Features
- **command-palette**: clear prompt on palette close (#708) (d013696)
- **command-palette**: added about page in command palette (99b1eb9)
- **new tool**: random MAC address generator (#657) (cc3425d)
- **case-converter**: added mocking case (#705) (681f7bf)
- **date-converter**: added excel date time format (#704) (f5eb7a8)
- **i18n**: token generator (#688) (02e68d3)
- **i18n**: home page (#687) (00562ed)
- **i18n**: support for i18n in .ts files (#683) (ebb4ec4)
- **i18n**: tool card (#682) (84a4a64)
- **i18n**: about page (#680) (a2b53c2)
- **i18n**: 404 page (#679) (35563b8)
- **new tool**: text to ascii converter (#669) (b2ad4f7)
- **new tool**: ULID generator (#623) (5c4d775)
- **new tool**: add wifi qr code generator (#599) (0eedce6)
- **new tool**: iban validation and parser (#591) (3a63837)
- **new tool**: text diff and comparator (#588) (81bfe57)
### Bug fixes
- **deps**: fix issue on slugify (#593) (#673) (720201a)
- **deps**: update dependency monaco-editor to ^0.43.0 (#620) (e371ef7)
- **deps**: update dependency sql-formatter to v13 (#606) (c7d4562)
### Refactoring
- **ui**: better ui demo preview menu (#664) (015c673)
- **color-converter**: improved color-converter UX (#701) (abb8335)
- **docker**: improved docker config (#700) (020e9cb)
- **c-table**: added description on c-table for accessibility (b408df8)
- **ci**: reduced timeout in e2e (#666) (88b8818)
- **ui**: new c-table ui component (#665) (ee4c853)
- **ui**: removed n-page-header component in user-agent parser (#663) (cbf58fd)
- **ui**: removed n-p components in about page (#662) (a757a51)
- **ui**: switched naive tooltip components to custom ones (#661) (025f556)
- **spelling**: minor corrections to phrasing/spelling (#596) (8a30b6b)
- **i18n**: merge tools scoped locales with global ones (#612) (233d556)
- **c-key-value-list**: got rid of table for layout (#611) (7ab9204)
- **CI**: run e2e against built app and no longer vercel (#610) (18dd140)
- **bcrypt**: fix typo (#604) (e18bae1)
### Chores
- **deps**: clean unused dependencies (#709) (e164afb)
- **deps**: update docker/setup-qemu-action action to v3 (#627) (4365226)
- **deps**: update docker/setup-buildx-action action to v3 (#626) (57ecda1)
- **deps**: update docker/login-action action to v3 (#625) (d8d7a3b)
- **deps**: update docker/build-push-action action to v5 (#624) (d36b18f)
- **deps**: update dependency node to v18.18.2 (#674) (eea9f91)
- **deps**: update dependency node to v18.18.0 (#636) (2d2dffb)
- **deps**: update actions/checkout action to v4 (#613) (4972159)
- **deps**: update dependency unplugin-icons to ^0.17.0 (#609) (f035f48)
- **deps**: update dependency @intlify/unplugin-vue-i18n to ^0.13.0 (#597) (d1dff42)
- **deps**: update dependency @antfu/eslint-config to ^0.41.0 (#585) (a9cd91c)
- **deps**: update dependency typescript to ~5.2.0 (#587) (f3e14fc)
### Doc
- **readme**: added contributors list (#622) (557b304)
- **hosting**: added cloudron in the other hosting solutions section (#589) (06c3547)
## Version 2023.08.21-6f93cba
### Features

View file

@ -1,9 +1,12 @@
# build stage
FROM node:lts-alpine AS build-stage
# Set environment variables for non-interactive npm installs
ENV NPM_CONFIG_LOGLEVEL warn
ENV CI true
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm i --frozen-lockfile
COPY . .
RUN npm install -g pnpm
RUN pnpm i --frozen-lockfile
RUN pnpm build
# production stage

View file

@ -100,7 +100,7 @@ pnpm lint
To create a new tool, there is a script that generate the boilerplate of the new tool, simply run:
```sh
pnpm run script:create-new-tool my-tool-name
pnpm run script:create:tool my-tool-name
```
It will create a directory in `src/tools` with the correct files, and a the import in `src/tools/index.ts`. You will just need to add the imported tool in the proper category and develop the tool.

View file

@ -0,0 +1,6 @@
---
to: src/ui/<%= h.changeCase.param(name) %>/<%= h.changeCase.param(name) %>.demo.vue
---
<template>
<<%= h.changeCase.param(name) %> />
</template>

View file

@ -0,0 +1,13 @@
---
to: src/ui/<%= h.changeCase.param(name) %>/<%= h.changeCase.param(name) %>.vue
---
<script lang="ts" setup>
const props = withDefaults(defineProps<{ prop?: string }>(), { prop: '' });
const { prop } = toRefs(props);
</script>
<template>
<div>
{{ prop }}
</div>
</template>

52
components.d.ts vendored
View file

@ -29,7 +29,11 @@ declare module '@vue/runtime-core' {
'CButtonsSelect.demo': typeof import('./src/ui/c-buttons-select/c-buttons-select.demo.vue')['default']
CCard: typeof import('./src/ui/c-card/c-card.vue')['default']
'CCard.demo': typeof import('./src/ui/c-card/c-card.demo.vue')['default']
CCollapse: typeof import('./src/ui/c-collapse/c-collapse.vue')['default']
'CCollapse.demo': typeof import('./src/ui/c-collapse/c-collapse.demo.vue')['default']
CDiffEditor: typeof import('./src/ui/c-diff-editor/c-diff-editor.vue')['default']
CFileUpload: typeof import('./src/ui/c-file-upload/c-file-upload.vue')['default']
'CFileUpload.demo': typeof import('./src/ui/c-file-upload/c-file-upload.demo.vue')['default']
ChmodCalculator: typeof import('./src/tools/chmod-calculator/chmod-calculator.vue')['default']
Chronometer: typeof import('./src/tools/chronometer/chronometer.vue')['default']
CInputText: typeof import('./src/ui/c-input-text/c-input-text.vue')['default']
@ -39,8 +43,12 @@ declare module '@vue/runtime-core' {
CLabel: typeof import('./src/ui/c-label/c-label.vue')['default']
CLink: typeof import('./src/ui/c-link/c-link.vue')['default']
'CLink.demo': typeof import('./src/ui/c-link/c-link.demo.vue')['default']
CMarkdown: typeof import('./src/ui/c-markdown/c-markdown.vue')['default']
'CMarkdown.demo': typeof import('./src/ui/c-markdown/c-markdown.demo.vue')['default']
CModal: typeof import('./src/ui/c-modal/c-modal.vue')['default']
'CModal.demo': typeof import('./src/ui/c-modal/c-modal.demo.vue')['default']
CModalValue: typeof import('./src/ui/c-modal-value/c-modal-value.vue')['default']
'CModalValue.demo': typeof import('./src/ui/c-modal-value/c-modal-value.demo.vue')['default']
CollapsibleToolMenu: typeof import('./src/components/CollapsibleToolMenu.vue')['default']
ColorConverter: typeof import('./src/tools/color-converter/color-converter.vue')['default']
ColoredCard: typeof import('./src/components/ColoredCard.vue')['default']
@ -49,6 +57,8 @@ declare module '@vue/runtime-core' {
CrontabGenerator: typeof import('./src/tools/crontab-generator/crontab-generator.vue')['default']
CSelect: typeof import('./src/ui/c-select/c-select.vue')['default']
'CSelect.demo': typeof import('./src/ui/c-select/c-select.demo.vue')['default']
CTable: typeof import('./src/ui/c-table/c-table.vue')['default']
'CTable.demo': typeof import('./src/ui/c-table/c-table.demo.vue')['default']
CTextCopyable: typeof import('./src/ui/c-text-copyable/c-text-copyable.vue')['default']
'CTextCopyable.demo': typeof import('./src/ui/c-text-copyable/c-text-copyable.demo.vue')['default']
CTooltip: typeof import('./src/ui/c-tooltip/c-tooltip.vue')['default']
@ -80,11 +90,27 @@ declare module '@vue/runtime-core' {
IbanValidatorAndParser: typeof import('./src/tools/iban-validator-and-parser/iban-validator-and-parser.vue')['default']
'IconMdi:brushVariant': typeof import('~icons/mdi/brush-variant')['default']
'IconMdi:kettleSteamOutline': typeof import('~icons/mdi/kettle-steam-outline')['default']
IconMdiArrowDown: typeof import('~icons/mdi/arrow-down')['default']
IconMdiArrowRight: typeof import('~icons/mdi/arrow-right')['default']
IconMdiArrowRightBottom: typeof import('~icons/mdi/arrow-right-bottom')['default']
IconMdiCamera: typeof import('~icons/mdi/camera')['default']
IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
IconMdiClose: typeof import('~icons/mdi/close')['default']
IconMdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
IconMdiDownload: typeof import('~icons/mdi/download')['default']
IconMdiEye: typeof import('~icons/mdi/eye')['default']
IconMdiEyeOff: typeof import('~icons/mdi/eye-off')['default']
IconMdiHeart: typeof import('~icons/mdi/heart')['default']
IconMdiPause: typeof import('~icons/mdi/pause')['default']
IconMdiPlay: typeof import('~icons/mdi/play')['default']
IconMdiRecord: typeof import('~icons/mdi/record')['default']
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
IconMdiSearch: typeof import('~icons/mdi/search')['default']
IconMdiTranslate: typeof import('~icons/mdi/translate')['default']
IconMdiTriangleDown: typeof import('~icons/mdi/triangle-down')['default']
IconMdiVideo: typeof import('~icons/mdi/video')['default']
InputCopyable: typeof import('./src/components/InputCopyable.vue')['default']
IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default']
Ipv4AddressConverter: typeof import('./src/tools/ipv4-address-converter/ipv4-address-converter.vue')['default']
@ -100,7 +126,9 @@ declare module '@vue/runtime-core' {
JwtParser: typeof import('./src/tools/jwt-parser/jwt-parser.vue')['default']
KeycodeInfo: typeof import('./src/tools/keycode-info/keycode-info.vue')['default']
ListConverter: typeof import('./src/tools/list-converter/list-converter.vue')['default']
LocaleSelector: typeof import('./src/modules/i18n/components/locale-selector.vue')['default']
LoremIpsumGenerator: typeof import('./src/tools/lorem-ipsum-generator/lorem-ipsum-generator.vue')['default']
MacAddressGenerator: typeof import('./src/tools/mac-address-generator/mac-address-generator.vue')['default']
MacAddressLookup: typeof import('./src/tools/mac-address-lookup/mac-address-lookup.vue')['default']
MathEvaluator: typeof import('./src/tools/math-evaluator/math-evaluator.vue')['default']
MenuBar: typeof import('./src/tools/html-wysiwyg-editor/editor/menu-bar.vue')['default']
@ -109,24 +137,47 @@ declare module '@vue/runtime-core' {
MenuLayout: typeof import('./src/components/MenuLayout.vue')['default']
MetaTagGenerator: typeof import('./src/tools/meta-tag-generator/meta-tag-generator.vue')['default']
MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NCode: typeof import('naive-ui')['NCode']
NCollapseTransition: typeof import('naive-ui')['NCollapseTransition']
NColorPicker: typeof import('naive-ui')['NColorPicker']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDatePicker: typeof import('naive-ui')['NDatePicker']
NDivider: typeof import('naive-ui')['NDivider']
NDynamicInput: typeof import('naive-ui')['NDynamicInput']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NGi: typeof import('naive-ui')['NGi']
NGrid: typeof import('naive-ui')['NGrid']
NH1: typeof import('naive-ui')['NH1']
NH2: typeof import('naive-ui')['NH2']
NH3: typeof import('naive-ui')['NH3']
NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']
NInputGroup: typeof import('naive-ui')['NInputGroup']
NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout']
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NMenu: typeof import('naive-ui')['NMenu']
NProgress: typeof import('naive-ui')['NProgress']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSlider: typeof import('naive-ui')['NSlider']
NStatistic: typeof import('naive-ui')['NStatistic']
NSwitch: typeof import('naive-ui')['NSwitch']
NTable: typeof import('naive-ui')['NTable']
NTag: typeof import('naive-ui')['NTag']
NTooltip: typeof import('naive-ui')['NTooltip']
NumeronymGenerator: typeof import('./src/tools/numeronym-generator/numeronym-generator.vue')['default']
NUpload: typeof import('naive-ui')['NUpload']
NUploadDragger: typeof import('naive-ui')['NUploadDragger']
OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default']
PasswordStrengthAnalyser: typeof import('./src/tools/password-strength-analyser/password-strength-analyser.vue')['default']
PdfSignatureChecker: typeof import('./src/tools/pdf-signature-checker/pdf-signature-checker.vue')['default']
PdfSignatureDetails: typeof import('./src/tools/pdf-signature-checker/components/pdf-signature-details.vue')['default']
PercentageCalculator: typeof import('./src/tools/percentage-calculator/percentage-calculator.vue')['default']
PhoneParserAndFormatter: typeof import('./src/tools/phone-parser-and-formatter/phone-parser-and-formatter.vue')['default']
QrCodeGenerator: typeof import('./src/tools/qr-code-generator/qr-code-generator.vue')['default']
@ -145,6 +196,7 @@ declare module '@vue/runtime-core' {
TextareaCopyable: typeof import('./src/components/TextareaCopyable.vue')['default']
TextDiff: typeof import('./src/tools/text-diff/text-diff.vue')['default']
TextStatistics: typeof import('./src/tools/text-statistics/text-statistics.vue')['default']
TextToBinary: typeof import('./src/tools/text-to-binary/text-to-binary.vue')['default']
TextToNatoAlphabet: typeof import('./src/tools/text-to-nato-alphabet/text-to-nato-alphabet.vue')['default']
TokenDisplay: typeof import('./src/tools/otp-code-generator-and-validator/token-display.vue')['default']
'TokenGenerator.tool': typeof import('./src/tools/token-generator/token-generator.tool.vue')['default']

View file

@ -1,4 +1,71 @@
home:
categories:
newestTools: Newest tools
favoriteTools: 'Your favorite tools'
allTools: 'All the tools'
subtitle: 'Handy tools for developers'
toggleMenu: 'Toggle menu'
home: Home
uiLib: 'UI Lib'
support: 'Support IT Tools development'
buyMeACoffee: 'Buy me a coffee'
follow:
title: 'You like it-tools?'
p1: 'Give us a star on'
githubRepository: 'IT-Tools GitHub repository'
p2: 'or follow us on'
twitterAccount: 'IT-Tools Twitter account'
thankYou: 'Thank you !'
nav:
github: 'GitHub repository'
githubRepository: 'IT-Tools GitHub repository'
twitter: 'Twitter account'
twitterAccount: 'IT Tools Twitter account'
about: 'About IT-Tools'
aboutLabel: 'About'
darkMode: 'Dark mode'
lightMode: 'Light mode'
mode: 'Toggle dark/light mode'
about:
content: >
# About IT-Tools
This wonderful website, made with ❤ by [Corentin Thomasset](https://github.com/CorentinTh) , aggregates useful tools for developer and people working in IT. If you find it useful, please feel free to share it to people you think may find it useful too and don't forget to bookmark it in your shortcut bar!
IT Tools is open-source (under the MIT license) and free, and will always be, but it costs me money to host and renew the domain name. If you want to support my work, and encourage me to add more tools, please consider supporting by [sponsoring me](https://www.buymeacoffee.com/cthmsst).
## Technologies
IT Tools is made in Vue.js (Vue 3) with the the Naive UI component library and is hosted and continuously deployed by Vercel. Third-party open-source libraries are used in some tools, you may find the complete list in the [package.json](https://github.com/CorentinTh/it-tools/blob/main/package.json) file of the repository.
## Found a bug? A tool is missing?
If you need a tool that is currently not present here, and you think can be useful, you are welcome to submit a feature request in the [issues section](https://github.com/CorentinTh/it-tools/issues/new/choose) in the GitHub repository.
And if you found a bug, or something doesn't work as expected, please file a bug report in the [issues section](https://github.com/CorentinTh/it-tools/issues/new/choose) in the GitHub repository.
404:
notFound: '404 Not Found'
sorry: 'Sorry, this page does not seem to exist'
maybe: 'Maybe the cache is doing tricky things, try force-refreshing?'
backHome: 'Back home'
favoriteButton:
remove: 'Remove from favorites'
add: 'Add to favorites'
toolCard:
new: New
search:
label: Search
tools:
categories:
favorite-tools: 'Your favorite tools'
crypto: Crypto
converter: Converter
web: Web
images and videos: 'Images & Videos'
development: Development
network: Network
math: Math
measurement: Measurement
text: Text
data: Data

View file

@ -1,3 +1,65 @@
home:
categories:
newestTools: "Nouveaux outils"
newestTools: 'Les nouveaux outils'
favoriteTools: 'Vos outils favoris'
allTools: 'Tous les outils'
subtitle: 'Outils pour les développeurs'
toggleMenu: 'Menu'
home: Accueil
uiLib: 'UI Lib'
buyMeACoffee: 'Soutenez IT-Tools'
follow:
title: 'Vous aimez it-tools ?'
p1: 'Soutenez-nous avec une star sur'
githubRepository: "le dépôt GitHub d'IT-Tools"
p2: 'ou suivez-nous sur'
twitterAccount: "le compte Twitter d'IT-Tools"
thankYou: 'Merci !'
nav:
github: 'Dépôt GitHub'
githubRepository: "Dépôt GitHub d'IT-Tools"
twitter: 'Compte Twitter'
twitterAccount: "Compte Twitter d'IT-Tools"
about: "À propos d'IT-Tools"
aboutLabel: 'À propos'
darkMode: 'Mode sombre'
lightMode: 'Mode clair'
mode: 'Basculer le mode sombre/clair'
about:
content: >
# À propos de IT-Tools
Ce merveilleux site, fait avec ❤ par [Corentin Thomasset](https://github.com/CorentinTh), regroupe des outils utiles pour les développeurs et les personnes travaillant dans l'informatique. Si vous le trouvez utile, n'hésitez pas à le partager et n'oubliez pas de le mettre dans vos favoris !
IT Tools est open-source (sous licence MIT) et gratuit, et le restera toujours, mais cela me coûte de l'argent pour l'héberger et renouveler le nom de domaine. Si vous voulez soutenir mon travail, et m'encourager à ajouter plus d'outils, n'hésitez pas à me [soutenir](https://www.buymeacoffee.com/cthmsst).
## Technologies
IT Tools est fait en Vue.js (Vue 3) avec la bibliothèque de composants Naive UI et est hébergé et déployé en continu par Vercel. Des bibliothèques open-source tierces sont utilisées dans certains outils, vous pouvez trouver la liste complète dans le fichier [package.json](https://github.com/CorentinTh/it-tools/blob/main/package.json) du dépôt.
## Vous avez trouvé un bug ? Un outil manque ?
Si vous avez besoin d'un outil qui n'est pas encore présent ici, et que vous pensez qu'il peut être utile, vous êtes invité à soumettre une demande de fonctionnalité dans la [section issue](https://github.com/CorentinTh/it-tools/issues/new/choose) du dépôt GitHub.
404:
notFound: '404 Not Found'
sorry: "Désolé, cette page n'existe pas"
maybe: 'Peut-être que le cache fait des siennes, essayez de forcer le rafraîchissement ?'
backHome: "Retour à l'accueil"
toolCard:
new: Nouveau
search:
label: Rechercher
tools:
categories:
favorite-tools: 'Vos outils favoris'
crypto: Cryptographie
converter: Convertisseur
web: Web
images and videos: 'Images & Vidéos'
development: Développement
network: Réseau
math: Math
measurement: Mesure
text: Texte
data: Données

71
locales/zh.yml Normal file
View file

@ -0,0 +1,71 @@
home:
categories:
newestTools: '最新工具'
favoriteTools: '我的收藏'
allTools: '全部工具'
subtitle: '助力开发人员和 IT 工作者'
toggleMenu: '切换菜单'
home: '主页'
uiLib: 'UI 库'
support: '支持 IT 工具开发'
buyMeACoffee: '赞助'
follow:
title: '关注我们'
p1: '给我们 Star'
githubRepository: 'GitHub 仓库'
p2: '关注我们的'
twitterAccount: 'Twitter'
thankYou: '感谢您的支持!'
nav:
github: 'GitHub 仓库'
githubRepository: 'GitHub 仓库'
twitter: 'Twitter 账号'
twitterAccount: 'Twitter 账号'
about: '关于 IT-Tools'
aboutLabel: '关于'
darkMode: '深色模式'
lightMode: '浅色模式'
mode: '颜色模式'
about:
content: >
# 关于 IT-Tools
IT-Tools 由 [Corentin Thomasset](https://github.com/CorentinTh) 用 ❤ 开发,汇集了对开发人员和 IT 从业者有用的工具。如果对您有帮助,请将其分享给您的朋友,并且添加到收藏夹中!
IT-Tools 永久免费且开源MIT 许可证),但需要资金用于托管和续订域名。如果您想支持我的工作,并鼓励我添加更多工具,请考虑通过 [赞助我](https://www.buymeacoffee.com/cthmsst) 进行支持。
## 技术
IT-Tools 采用 Vue.jsVue 3和 Naive UI 组件库开发,并由 Vercel 托管和持续部署。某些工具使用了第三方开源库,您可以在仓库的 [package.json](https://github.com/CorentinTh/it-tools/blob/main/package.json) 文件中找到完整的列表。
## 发现了 Bug缺少工具
如果目前这里没有您需要的工具,并且您认为它可能有用,欢迎在 GitHub 仓库的 [issues](https://github.com/CorentinTh/it-tools/issues/new/choose) 中提交新增功能的请求。
如果您发现了 Bug或者某些功能未能按预期工作请在 GitHub 仓库的 [issues](https://github.com/CorentinTh/it-tools/issues/new/choose) 中提交错误报告。
404:
notFound: '404 页面不存在'
sorry: '抱歉,该页面似乎不存在'
maybe: '也许缓存出现了一些问题,试试强制刷新页面?'
backHome: '返回主页'
favoriteButton:
remove: '取消收藏'
add: '加入收藏'
toolCard:
new: '新'
search:
label: '搜索'
tools:
categories:
favorite-tools: '我的收藏'
crypto: '加密'
converter: '转换器'
web: Web
images and videos: '图片和视频'
development: '开发'
network: '网络'
math: '数学'
measurement: '测量'
text: '文本'
data: '数据'

View file

@ -1,6 +1,6 @@
{
"name": "it-tools",
"version": "2023.8.21-6f93cba",
"version": "2023.12.21-5ed3693",
"description": "Collection of handy online tools for developers, with great UX. ",
"keywords": [
"productivity",
@ -30,17 +30,17 @@
"coverage": "vitest run --coverage",
"typecheck": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint src --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --ignore-path .gitignore",
"script:create-new-tool": "node scripts/create-tool.mjs",
"script:create:tool": "node scripts/create-tool.mjs",
"script:create:ui": "hygen generator ui-component",
"release": "node ./scripts/release.mjs"
},
"dependencies": {
"@it-tools/bip39": "^0.0.4",
"@it-tools/oggen": "^1.3.0",
"@sindresorhus/slugify": "^2.2.0",
"@tiptap/pm": "^2.1.6",
"@tiptap/starter-kit": "^2.1.6",
"@tiptap/vue-3": "^2.0.3",
"@types/marked": "^5.0.1",
"@sindresorhus/slugify": "^2.2.1",
"@tiptap/pm": "2.1.6",
"@tiptap/starter-kit": "2.1.6",
"@tiptap/vue-3": "2.0.3",
"@vicons/material": "^0.12.0",
"@vicons/tabler": "^0.12.0",
"@vueuse/core": "^10.3.0",
@ -55,6 +55,7 @@
"cronstrue": "^2.26.0",
"crypto-js": "^4.1.1",
"date-fns": "^2.29.3",
"dompurify": "^3.0.6",
"emojilib": "^3.0.10",
"figue": "^1.2.0",
"fuse.js": "^6.6.2",
@ -65,20 +66,19 @@
"jwt-decode": "^3.1.2",
"libphonenumber-js": "^1.10.28",
"lodash": "^4.17.21",
"marked": "^9.0.3",
"marked": "^10.0.0",
"mathjs": "^11.9.1",
"mime-types": "^2.1.35",
"monaco-editor": "^0.43.0",
"naive-ui": "^2.34.3",
"naive-ui": "^2.35.0",
"netmask": "^2.0.2",
"node-forge": "^1.3.1",
"oui": "^12.0.52",
"oui-data": "^1.0.10",
"pdf-signature-reader": "^1.4.2",
"pinia": "^2.0.34",
"plausible-tracker": "^0.3.8",
"qrcode": "^1.5.1",
"randombytes": "^2.1.0",
"sql-formatter": "^13.0.0",
"turndown": "^7.1.2",
"ua-parser-js": "^1.0.35",
"ulid": "^2.3.0",
"unicode-emoji-json": "^0.4.0",
@ -100,6 +100,7 @@
"@tsconfig/node18": "^18.2.0",
"@types/bcryptjs": "^2.4.2",
"@types/crypto-js": "^4.1.1",
"@types/dompurify": "^3.0.5",
"@types/jsdom": "^21.0.0",
"@types/lodash": "^4.14.192",
"@types/marked": "^5.0.1",
@ -108,25 +109,23 @@
"@types/node": "^18.15.11",
"@types/node-forge": "^1.3.2",
"@types/qrcode": "^1.5.0",
"@types/randombytes": "^2.0.0",
"@types/turndown": "^5.0.2",
"@types/ua-parser-js": "^0.7.36",
"@types/uuid": "^9.0.0",
"@unocss/eslint-config": "^0.55.0",
"@unocss/eslint-config": "^0.57.0",
"@vitejs/plugin-vue": "^4.3.2",
"@vitejs/plugin-vue-jsx": "^3.0.2",
"@vue/compiler-sfc": "^3.2.47",
"@vue/runtime-dom": "^3.3.4",
"@vue/test-utils": "^2.3.2",
"@vue/tsconfig": "^0.4.0",
"c8": "^8.0.0",
"consola": "^3.0.2",
"eslint": "^8.47.0",
"hygen": "^6.2.11",
"jsdom": "^22.0.0",
"less": "^4.1.3",
"prettier": "^3.0.0",
"typescript": "~5.2.0",
"unocss": "^0.55.0",
"unocss": "^0.57.0",
"unocss-preset-scrollbar": "^0.2.1",
"unplugin-icons": "^0.17.0",
"unplugin-vue-components": "^0.25.0",

2821
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,13 @@ const styleStore = useStyleStore();
const theme = computed(() => (styleStore.isDarkTheme ? darkTheme : null));
const themeOverrides = computed(() => (styleStore.isDarkTheme ? darkThemeOverrides : lightThemeOverrides));
const { locale } = useI18n();
syncRef(
locale,
useStorage('locale', locale),
);
</script>
<template>

View file

@ -36,7 +36,7 @@ const menuOptions = computed(() =>
tools: components.map(tool => ({
label: makeLabel(tool),
icon: makeIcon(tool),
key: tool.name,
key: tool.path,
})),
})),
);
@ -62,7 +62,7 @@ const themeVars = useThemeVars();
<n-menu
class="menu"
:value="route.name as string"
:value="route.path"
:collapsed-width="64"
:collapsed-icon-size="22"
:options="tools"

View file

@ -1,6 +1,4 @@
<script setup lang="ts">
import { FavoriteFilled } from '@vicons/material';
import { useToolStore } from '@/tools/tools.store';
import type { Tool } from '@/tools/tools.types';
@ -26,8 +24,7 @@ function toggleFavorite(event: MouseEvent) {
</script>
<template>
<n-tooltip trigger="hover">
<template #trigger>
<c-tooltip :tooltip="isFavorite ? $t('favoriteButton.remove') : $t('favoriteButton.add') ">
<c-button
variant="text"
circle
@ -35,9 +32,7 @@ function toggleFavorite(event: MouseEvent) {
:style="{ opacity: isFavorite ? 1 : 0.2 }"
@click="toggleFavorite"
>
<n-icon :component="FavoriteFilled" />
<icon-mdi-heart />
</c-button>
</template>
{{ isFavorite ? 'Remove from favorites' : 'Add to favorites' }}
</n-tooltip>
</c-tooltip>
</template>

View file

@ -13,14 +13,11 @@ const tooltipText = computed(() => isJustCopied.value ? 'Copied!' : 'Copy to cli
<template>
<c-input-text v-model:value="value">
<template #suffix>
<n-tooltip trigger="hover">
<template #trigger>
<c-tooltip :tooltip="tooltipText">
<c-button circle variant="text" size="small" @click="copy()">
<icon-mdi-content-copy />
</c-button>
</template>
{{ tooltipText }}
</n-tooltip>
</c-tooltip>
</template>
</c-input-text>
</template>

View file

@ -7,56 +7,43 @@ const { isDarkTheme } = toRefs(styleStore);
</script>
<template>
<n-tooltip trigger="hover">
<template #trigger>
<c-tooltip :tooltip="$t('home.nav.github')" position="bottom">
<c-button
circle
variant="text"
href="https://github.com/CorentinTh/it-tools"
target="_blank"
rel="noopener noreferrer"
aria-label="IT-Tools' GitHub repository"
:aria-label="$t('home.nav.githubRepository')"
>
<n-icon size="25" :component="BrandGithub" />
</c-button>
</template>
Github repository
</n-tooltip>
</c-tooltip>
<n-tooltip trigger="hover">
<template #trigger>
<c-tooltip :tooltip="$t('home.nav.twitter')" position="bottom">
<c-button
circle
variant="text"
href="https://twitter.com/ittoolsdottech"
rel="noopener"
target="_blank"
aria-label="IT Tools' Twitter account"
:aria-label="$t('home.nav.twitterAccount')"
>
<n-icon size="25" :component="BrandTwitter" />
</c-button>
</template>
IT Tools' Twitter account
</n-tooltip>
</c-tooltip>
<n-tooltip trigger="hover">
<template #trigger>
<c-button circle variant="text" to="/about" aria-label="About">
<c-tooltip :tooltip="$t('home.nav.about')" position="bottom">
<c-button circle variant="text" to="/about" :aria-label="$t('home.nav.aboutLabel')">
<n-icon size="25" :component="InfoCircle" />
</c-button>
</template>
About
</n-tooltip>
<n-tooltip trigger="hover">
<template #trigger>
<c-button circle variant="text" aria-label="Toggle dark/light mode" @click="() => styleStore.toggleDark()">
</c-tooltip>
<c-tooltip :tooltip="isDarkTheme ? $t('home.nav.lightMode') : $t('home.nav.darkMode')" position="bottom">
<c-button circle variant="text" :aria-label="$t('home.nav.mode')" @click="() => styleStore.toggleDark()">
<n-icon v-if="isDarkTheme" size="25" :component="Sun" />
<n-icon v-else size="25" :component="Moon" />
</c-button>
</template>
<span v-if="isDarkTheme">Light mode</span>
<span v-else>Dark mode</span>
</n-tooltip>
</c-tooltip>
</template>
<style lang="less" scoped>

View file

@ -11,17 +11,7 @@ const tooltipText = computed(() => isJustCopied.value ? 'Copied!' : initialText)
</script>
<template>
<n-tooltip trigger="hover">
<template #trigger>
<span class="value" @click="copy()">{{ value }}</span>
<c-tooltip :tooltip="tooltipText">
<span cursor-pointer font-mono @click="copy()">{{ value }}</span>
</c-tooltip>
</template>
{{ tooltipText }}
</n-tooltip>
</template>
<style scoped lang="less">
.value {
cursor: pointer;
font-family: monospace;
}
</style>

View file

@ -40,7 +40,7 @@ const tooltipText = computed(() => isJustCopied.value ? 'Copied!' : copyMessage.
<template>
<div style="overflow-x: hidden; width: 100%">
<c-card class="result-card">
<c-card relative>
<n-scrollbar
x-scrollable
trigger="none"
@ -50,16 +50,13 @@ const tooltipText = computed(() => isJustCopied.value ? 'Copied!' : copyMessage.
<n-code :code="value" :language="language" :trim="false" data-test-id="area-content" />
</n-config-provider>
</n-scrollbar>
<n-tooltip v-if="value" trigger="hover">
<template #trigger>
<div class="copy-button" :class="[copyPlacement]">
<div absolute right-10px top-10px>
<c-tooltip v-if="value" :tooltip="tooltipText" position="left">
<c-button circle important:h-10 important:w-10 @click="copy()">
<n-icon size="22" :component="Copy" />
</c-button>
</c-tooltip>
</div>
</template>
<span>{{ tooltipText }}</span>
</n-tooltip>
</c-card>
<div v-if="copyPlacement === 'outside'" mt-4 flex justify-center>
<c-button @click="copy()">
@ -74,25 +71,4 @@ const tooltipText = computed(() => isJustCopied.value ? 'Copied!' : copyMessage.
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

@ -26,7 +26,7 @@ const appTheme = useAppTheme();
:bordered="false"
:color="{ color: theme.primaryColor, textColor: theme.tagColor }"
>
New
{{ $t('toolCard.new') }}
</n-tag>
<FavoriteButton :tool="tool" />

View file

@ -0,0 +1,22 @@
import { type Ref, ref, watchEffect } from 'vue';
export { computedCatch };
function computedCatch<T, D>(getter: () => T, { defaultValue }: { defaultValue: D; defaultErrorMessage?: string }): [Ref<T | D>, Ref<string | undefined>];
function computedCatch<T, D>(getter: () => T, { defaultValue, defaultErrorMessage = 'Unknown error' }: { defaultValue?: D; defaultErrorMessage?: string } = {}) {
const error = ref<string | undefined>();
const value = ref<T | D | undefined>();
watchEffect(() => {
try {
error.value = undefined;
value.value = getter();
}
catch (err) {
error.value = err instanceof Error ? err.message : err?.toString() ?? defaultErrorMessage;
value.value = defaultValue;
}
});
return [value, error] as const;
}

View file

@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { getMimeTypeFromBase64 } from './downloadBase64';
describe('downloadBase64', () => {
describe('getMimeTypeFromBase64', () => {
it('when the base64 string has a data URI, it returns the mime type', () => {
expect(getMimeTypeFromBase64({ base64String: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA' })).to.deep.equal({ mimeType: 'image/png' });
expect(getMimeTypeFromBase64({ base64String: 'data:image/jpg;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA' })).to.deep.equal({ mimeType: 'image/jpg' });
});
it('when the base64 string has no data URI, it try to infer the mime type from the signature', () => {
// https://en.wikipedia.org/wiki/List_of_file_signatures
// PNG
expect(getMimeTypeFromBase64({ base64String: 'iVBORw0KGgoAAAANSUhEUgAAAAUA' })).to.deep.equal({ mimeType: 'image/png' });
// GIF
expect(getMimeTypeFromBase64({ base64String: 'R0lGODdh' })).to.deep.equal({ mimeType: 'image/gif' });
expect(getMimeTypeFromBase64({ base64String: 'R0lGODlh' })).to.deep.equal({ mimeType: 'image/gif' });
// JPG
expect(getMimeTypeFromBase64({ base64String: '/9j/' })).to.deep.equal({ mimeType: 'image/jpg' });
// PDF
expect(getMimeTypeFromBase64({ base64String: 'JVBERi0' })).to.deep.equal({ mimeType: 'application/pdf' });
});
it('when the base64 string has no data URI and no signature, it returns an undefined mimeType', () => {
expect(getMimeTypeFromBase64({ base64String: 'JVBERi' })).to.deep.equal({ mimeType: undefined });
});
});
});

View file

@ -1,32 +1,60 @@
import { extension as getExtensionFromMime } from 'mime-types';
import type { Ref } from 'vue';
import _ from 'lodash';
function getFileExtensionFromBase64({
base64String,
export { getMimeTypeFromBase64, useDownloadFileFromBase64 };
const commonMimeTypesSignatures = {
'JVBERi0': 'application/pdf',
'R0lGODdh': 'image/gif',
'R0lGODlh': 'image/gif',
'iVBORw0KGgo': 'image/png',
'/9j/': 'image/jpg',
};
function getMimeTypeFromBase64({ base64String }: { base64String: string }) {
const [,mimeTypeFromBase64] = base64String.match(/data:(.*?);base64/i) ?? [];
if (mimeTypeFromBase64) {
return { mimeType: mimeTypeFromBase64 };
}
const inferredMimeType = _.find(commonMimeTypesSignatures, (_mimeType, signature) => base64String.startsWith(signature));
if (inferredMimeType) {
return { mimeType: inferredMimeType };
}
return { mimeType: undefined };
}
function getFileExtensionFromMimeType({
mimeType,
defaultExtension = 'txt',
}: {
base64String: string
mimeType: string | undefined
defaultExtension?: string
}) {
const hasMimeType = base64String.match(/data:(.*?);base64/i);
if (hasMimeType) {
return getExtensionFromMime(hasMimeType[1]) || defaultExtension;
if (mimeType) {
return getExtensionFromMime(mimeType) ?? defaultExtension;
}
return defaultExtension;
}
export function useDownloadFileFromBase64({ source, filename }: { source: Ref<string>; filename?: string }) {
function useDownloadFileFromBase64({ source, filename }: { source: Ref<string>; filename?: string }) {
return {
download() {
const base64String = source.value;
if (base64String === '') {
if (source.value === '') {
throw new Error('Base64 string is empty');
}
const cleanFileName = filename ?? `file.${getFileExtensionFromBase64({ base64String })}`;
const { mimeType } = getMimeTypeFromBase64({ base64String: source.value });
const base64String = mimeType
? source.value
: `data:text/plain;base64,${source.value}`;
const cleanFileName = filename ?? `file.${getFileExtensionFromMimeType({ mimeType })}`;
const a = document.createElement('a');
a.href = base64String;

View file

@ -4,10 +4,10 @@ import { NIcon, useThemeVars } from 'naive-ui';
import { RouterLink } from 'vue-router';
import { Heart, Home2, Menu2 } from '@vicons/tabler';
import { storeToRefs } from 'pinia';
import HeroGradient from '../assets/hero-gradient.svg?component';
import MenuLayout from '../components/MenuLayout.vue';
import NavbarButtons from '../components/NavbarButtons.vue';
import { toolsByCategory } from '@/tools';
import { useStyleStore } from '@/stores/style.store';
import { config } from '@/config';
import type { ToolCategory } from '@/tools/tools.types';
@ -21,12 +21,14 @@ const version = config.app.version;
const commitSha = config.app.lastCommitSha.slice(0, 7);
const { tracker } = useTracker();
const { t } = useI18n();
const toolStore = useToolStore();
const { favoriteTools, toolsByCategory } = storeToRefs(toolStore);
const tools = computed<ToolCategory[]>(() => [
...(toolStore.favoriteTools.length > 0 ? [{ name: 'Your favorite tools', components: toolStore.favoriteTools }] : []),
...toolsByCategory,
...(favoriteTools.value.length > 0 ? [{ name: t('tools.categories.favorite-tools'), components: favoriteTools.value }] : []),
...toolsByCategory.value,
]);
</script>
@ -41,15 +43,19 @@ const tools = computed<ToolCategory[]>(() => [
</div>
<div class="divider" />
<div class="subtitle">
Handy tools for developers
{{ $t('home.subtitle') }}
</div>
</div>
</RouterLink>
<div class="sider-content">
<div v-if="styleStore.isSmallScreen" flex justify-center>
<div v-if="styleStore.isSmallScreen" flex flex-col items-center>
<locale-selector w="90%" />
<div flex justify-center>
<NavbarButtons />
</div>
</div>
<CollapsibleToolMenu :tools-by-category="tools" />
@ -88,33 +94,33 @@ const tools = computed<ToolCategory[]>(() => [
<c-button
circle
variant="text"
aria-label="Toggle menu"
:aria-label="$t('home.toggleMenu')"
@click="styleStore.isMenuCollapsed = !styleStore.isMenuCollapsed"
>
<NIcon size="25" :component="Menu2" />
</c-button>
<n-tooltip trigger="hover">
<template #trigger>
<c-button to="/" circle variant="text" aria-label="Home">
<c-tooltip :tooltip="$t('home.home')" position="bottom">
<c-button to="/" circle variant="text" :aria-label="$t('home.home')">
<NIcon size="25" :component="Home2" />
</c-button>
</template>
Home
</n-tooltip>
</c-tooltip>
<c-button v-if="config.app.env === 'development'" to="/c-lib" circle variant="text" aria-label="UI Lib">
<c-tooltip :tooltip="$t('home.uiLib')" position="bottom">
<c-button v-if="config.app.env === 'development'" to="/c-lib" circle variant="text" :aria-label="$t('home.uiLib')">
<icon-mdi:brush-variant text-20px />
</c-button>
</c-tooltip>
<command-palette />
<locale-selector v-if="!styleStore.isSmallScreen" />
<div>
<NavbarButtons v-if="!styleStore.isSmallScreen" />
</div>
<n-tooltip trigger="hover">
<template #trigger>
<c-tooltip position="bottom" :tooltip="$t('home.support')">
<c-button
round
href="https://www.buymeacoffee.com/cthmsst"
@ -124,12 +130,10 @@ const tools = computed<ToolCategory[]>(() => [
:bordered="false"
@click="() => tracker.trackEvent({ eventName: 'Support button clicked' })"
>
Buy me a coffee
{{ $t('home.buyMeACoffee') }}
<NIcon v-if="!styleStore.isSmallScreen" :component="Heart" ml-2 />
</c-button>
</template>
Support IT Tools development !
</n-tooltip>
</c-tooltip>
</div>
<slot />
</template>

View file

@ -9,6 +9,7 @@ import SunIcon from '~icons/mdi/white-balance-sunny';
import GithubIcon from '~icons/mdi/github';
import BugIcon from '~icons/mdi/bug-outline';
import DiceIcon from '~icons/mdi/dice-5';
import InfoIcon from '~icons/mdi/information-outline';
export const useCommandPaletteStore = defineStore('command-palette', () => {
const toolStore = useToolStore();
@ -61,6 +62,14 @@ export const useCommandPaletteStore = defineStore('command-palette', () => {
keywords: ['report', 'issue', 'bug', 'problem', 'error'],
icon: BugIcon,
},
{
name: 'About',
description: 'Learn more about IT-Tools.',
to: '/about',
category: 'Pages',
keywords: ['about', 'learn', 'more', 'info', 'information'],
icon: InfoIcon,
},
];
const { searchResult } = useFuzzySearch({

View file

@ -37,6 +37,7 @@ function open() {
function close() {
isModalOpen.value = false;
searchPrompt.value = '';
}
const selectedOptionIndex = ref(0);
@ -115,7 +116,7 @@ function activateOption(option: PaletteOption) {
<span flex items-center gap-3 op-40>
<icon-mdi-search />
Search...
{{ $t('search.label') }}
<span hidden flex-1 border border-current border-op-40 rounded border-solid px-5px py-3px sm:inline>
{{ isMac ? 'Cmd' : 'Ctrl' }}&nbsp;+&nbsp;K

View file

@ -0,0 +1,28 @@
<script setup lang="ts">
const { availableLocales, locale } = useI18n();
const localesLong: Record<string, string> = {
en: 'English',
es: 'Español',
fr: 'Français',
pt: 'Português',
ru: 'Русский',
zh: '中文',
};
const localeOptions = computed(() =>
availableLocales.map(locale => ({
label: localesLong[locale] ?? locale,
value: locale,
})),
);
</script>
<template>
<c-select
v-model:value="locale"
:options="localeOptions"
placeholder="Select a language"
w-100px
/>
</template>

View file

@ -11,17 +11,17 @@ useHead({ title: 'Page not found - IT Tools' });
</span>
<h1 m-0 mt-3>
404 Not Found
{{ $t('404.notFound') }}
</h1>
<div mt-4 op-60>
Sorry, this page does not seem to exist
{{ $t('404.sorry') }}
</div>
<div mb-8 op-60>
Maybe the cache is doing tricky things, try force-refreshing?
{{ $t('404.maybe') }}
</div>
<c-button to="/">
Back home
{{ $t('404.backHome') }}
</c-button>
</div>
</template>

View file

@ -1,85 +1,9 @@
<script setup lang="ts">
import { useHead } from '@vueuse/head';
import { useTracker } from '@/modules/tracker/tracker.services';
useHead({ title: 'About - IT Tools' });
const { tracker } = useTracker();
</script>
<template>
<div class="about-page">
<n-h1>About</n-h1>
<n-p>
This wonderful website, made with by
<c-link href="https://github.com/CorentinTh" target="_blank" rel="noopener">
Corentin Thomasset
</c-link>,
aggregates useful tools for developer and people working in IT. If you find it useful, please feel free to share
it to people you think may find it useful too and don't forget to bookmark it in your shortcut bar!
</n-p>
<n-p>
IT Tools is open-source (under the MIT license) and free, and will always be, but it costs me money to host and
renew the domain name. If you want to support my work, and encourage me to add more tools, please consider
supporting by
<c-link
href="https://www.buymeacoffee.com/cthmsst"
rel="noopener"
target="_blank"
@click="() => tracker.trackEvent({ eventName: 'Support button clicked' })"
>
sponsoring me
</c-link>.
</n-p>
<n-h2>Technologies</n-h2>
<n-p>
IT Tools is made in Vue.js (Vue 3) with the the Naive UI component library and is hosted and continuously deployed
by Vercel. Third-party open-source libraries are used in some tools, you may find the complete list in the
<c-link href="https://github.com/CorentinTh/it-tools/blob/main/package.json" rel="noopener" target="_blank">
package.json
</c-link>
file of the repository.
</n-p>
<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 useful, you are welcome to submit a
feature request in the
<c-link
href="https://github.com/CorentinTh/it-tools/issues/new/choose"
rel="noopener"
target="_blank"
>
issues section
</c-link>
in the GitHub repository.
</n-p>
<n-p>
And if you found a bug, or something doesn't work as expected, please file a bug report in the
<c-link
href="https://github.com/CorentinTh/it-tools/issues/new/choose"
rel="noopener"
target="_blank"
>
issues section
</c-link>
in the GitHub repository.
</n-p>
</div>
<c-markdown :markdown="$t('about.content')" mx-auto mt-50px max-w-600px />
</template>
<style scoped lang="less">
.about-page {
max-width: 600px;
margin: 50px auto;
box-sizing: border-box;
.n-h2 {
margin-bottom: 0px;
}
.n-p {
text-align: justify;
}
}
</style>

View file

@ -17,21 +17,22 @@ const { t } = useI18n();
<div class="grid-wrapper">
<n-grid v-if="config.showBanner" x-gap="12" y-gap="12" cols="1 400:2 800:3 1200:4 2000:8">
<n-gi>
<ColoredCard title="You like it-tools?" :icon="Heart">
Give us a star on
<ColoredCard :title="$t('home.follow.title')" :icon="Heart">
{{ $t('home.follow.p1') }}
<a
href="https://github.com/CorentinTh/it-tools"
rel="noopener"
target="_blank"
aria-label="IT-Tools' GitHub repository"
:aria-label="$t('home.follow.githubRepository')"
>GitHub</a>
or follow us on
{{ $t('home.follow.p2') }}
<a
href="https://twitter.com/ittoolsdottech"
rel="noopener"
target="_blank"
aria-label="IT-Tools' Twitter account"
>Twitter</a>! Thank you
:aria-label="$t('home.follow.twitterAccount')"
>Twitter</a>.
{{ $t('home.follow.thankYou') }}
<n-icon :component="Heart" />
</ColoredCard>
</n-gi>
@ -39,7 +40,7 @@ const { t } = useI18n();
<transition name="height">
<div v-if="toolStore.favoriteTools.length > 0">
<n-h3>Your favorite tools</n-h3>
<n-h3>{{ $t('home.categories.favoriteTools') }}</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">
<ToolCard :tool="tool" />
@ -57,7 +58,7 @@ const { t } = useI18n();
</n-grid>
</div>
<n-h3>All the tools</n-h3>
<n-h3>{{ $t('home.categories.allTools') }}</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>

View file

@ -29,3 +29,9 @@ export const i18nPlugin: Plugin = {
app.use(i18n);
},
};
export const translate = function (localeKey: string) {
// @ts-expect-error global
const hasKey = i18n.global.te(localeKey, i18n.global.locale);
return hasKey ? i18n.global.t(localeKey) : localeKey;
};

6
src/shims.d.ts vendored
View file

@ -33,3 +33,9 @@ declare module 'unicode-emoji-json' {
export default emoji;
}
declare module 'pdf-signature-reader' {
const verifySignature: (pdf: ArrayBuffer) => ({signatures: SignatureInfo[]});
export default verifySignature;
}

View file

@ -1,7 +1,5 @@
<script setup lang="ts">
import { Upload } from '@vicons/tabler';
import { useBase64 } from '@vueuse/core';
import type { UploadFileInfo } from 'naive-ui';
import type { Ref } from 'vue';
import { useCopy } from '@/composable/copy';
import { useDownloadFileFromBase64 } from '@/composable/downloadBase64';
@ -33,14 +31,12 @@ function downloadFile() {
}
}
const fileList = ref();
const fileInput = ref() as Ref<File>;
const { base64: fileBase64 } = useBase64(fileInput);
const { copy: copyFileBase64 } = useCopy({ source: fileBase64, text: 'Base64 string copied to the clipboard' });
async function onUpload({ file: { file } }: { file: UploadFileInfo }) {
async function onUpload(file: File) {
if (file) {
fileList.value = [];
fileInput.value = file;
}
}
@ -65,18 +61,8 @@ async function onUpload({ file: { file } }: { file: UploadFileInfo }) {
</c-card>
<c-card title="File to base64">
<n-upload v-model:file-list="fileList" :show-file-list="true" :on-before-upload="onUpload" list-type="image">
<n-upload-dragger>
<div mb-2>
<n-icon size="35" :depth="3" :component="Upload" />
</div>
<div op-60>
Click or drag a file to this area to upload
</div>
</n-upload-dragger>
</n-upload>
<c-input-text :value="fileBase64" multiline readonly placeholder="File in base64 will be here" rows="5" mb-2 />
<c-file-upload title="Drag and drop a file here, or click to select a file" @file-upload="onUpload" />
<c-input-text :value="fileBase64" multiline readonly placeholder="File in base64 will be here" rows="5" my-2 />
<div flex justify-center>
<c-button @click="copyFileBase64()">

View file

@ -23,6 +23,7 @@ const compareMatch = computed(() => compareSync(compareString.value, compareHash
raw-text
label="Your string: "
label-position="left"
label-align="right"
label-width="120px"
mb-2
/>

View file

@ -51,11 +51,11 @@ const results = computed(() => {
const { copy } = useCopy({ createToast: false });
const header = {
position: 'Position',
title: 'Suite',
size: 'Samples',
mean: 'Mean',
variance: 'Variance',
position: 'Position',
};
function copyAsMarkdown() {
@ -131,26 +131,8 @@ function copyAsBulletList() {
</c-button>
</div>
<n-table>
<thead>
<tr>
<th>{{ header.position }}</th>
<th>{{ header.title }}</th>
<th>{{ header.size }}</th>
<th>{{ header.mean }}</th>
<th>{{ header.variance }}</th>
</tr>
</thead>
<tbody>
<tr v-for="{ title, size, mean, variance, position } of results" :key="title">
<td>{{ position }}</td>
<td>{{ title }}</td>
<td>{{ size }}</td>
<td>{{ mean }}</td>
<td>{{ variance }}</td>
</tr>
</tbody>
</n-table>
<c-table :data="results" :headers="header" />
<div mt-5 flex justify-center gap-3>
<c-button @click="copyAsMarkdown()">
Copy as markdown table

View file

@ -39,14 +39,11 @@ function onInputEnter(index: number) {
autofocus
@keydown.enter="onInputEnter(index)"
/>
<n-tooltip>
<template #trigger>
<c-tooltip tooltip="Delete this value">
<c-button circle variant="text" @click="values.splice(index, 1)">
<n-icon :component="Trash" depth="3" size="18" />
</c-button>
</template>
Delete value
</n-tooltip>
</c-tooltip>
</div>
<c-button @click="addValue">

View file

@ -28,6 +28,7 @@ const permissionCannotBePrompted = ref(false);
const {
stream,
start,
stop,
enabled: isMediaStreamAvailable,
} = useUserMedia({
constraints: computed(() => ({
@ -83,6 +84,8 @@ watchEffect(() => {
}
});
onBeforeUnmount(() => stop());
async function requestPermissions() {
try {
await ensurePermissions();

View file

@ -23,11 +23,11 @@ const input = ref('lorem ipsum dolor sit amet');
const formats = computed(() => [
{
label: 'Lowercase:',
value: noCase(input.value, baseConfig).toLocaleLowerCase(),
value: input.value.toLocaleLowerCase(),
},
{
label: 'Uppercase:',
value: noCase(input.value, baseConfig).toLocaleUpperCase(),
value: input.value.toLocaleUpperCase(),
},
{
label: 'Camelcase:',
@ -73,6 +73,13 @@ const formats = computed(() => [
label: 'Snakecase:',
value: snakeCase(input.value, baseConfig),
},
{
label: 'Mockingcase:',
value: input.value
.split('')
.map((char, index) => (index % 2 === 0 ? char.toUpperCase() : char.toLowerCase()))
.join(''),
},
]);
const inputLabelAlignmentConfig = {

View file

@ -0,0 +1,23 @@
import { expect, test } from '@playwright/test';
test.describe('Tool - Color converter', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/color-converter');
});
test('Has title', async ({ page }) => {
await expect(page).toHaveTitle('Color converter - IT Tools');
});
test('Color is converted from its name to other formats', async ({ page }) => {
await page.getByTestId('input-name').fill('olive');
expect(await page.getByTestId('input-name').inputValue()).toEqual('olive');
expect(await page.getByTestId('input-hex').inputValue()).toEqual('#808000');
expect(await page.getByTestId('input-rgb').inputValue()).toEqual('rgb(128, 128, 0)');
expect(await page.getByTestId('input-hsl').inputValue()).toEqual('hsl(60, 100%, 25%)');
expect(await page.getByTestId('input-hwb').inputValue()).toEqual('hwb(60 0% 50%)');
expect(await page.getByTestId('input-cmyk').inputValue()).toEqual('device-cmyk(0% 0% 100% 50%)');
expect(await page.getByTestId('input-lch').inputValue()).toEqual('lch(52.15% 56.81 99.57)');
});
});

View file

@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { removeAlphaChannelWhenOpaque } from './color-converter.models';
describe('color-converter models', () => {
describe('removeAlphaChannelWhenOpaque', () => {
it('remove alpha channel of an hex color when it is opaque (alpha = 1)', () => {
expect(removeAlphaChannelWhenOpaque('#000000ff')).toBe('#000000');
expect(removeAlphaChannelWhenOpaque('#ffffffFF')).toBe('#ffffff');
expect(removeAlphaChannelWhenOpaque('#000000FE')).toBe('#000000FE');
expect(removeAlphaChannelWhenOpaque('#00000000')).toBe('#00000000');
});
});
});

View file

@ -0,0 +1,52 @@
import { type Colord, colord } from 'colord';
import { withDefaultOnError } from '@/utils/defaults';
import { useValidation } from '@/composable/validation';
export { removeAlphaChannelWhenOpaque, buildColorFormat };
function removeAlphaChannelWhenOpaque(hexColor: string) {
return hexColor.replace(/^(#(?:[0-9a-f]{3}){1,2})ff$/i, '$1');
}
function buildColorFormat({
label,
parse = value => colord(value),
format,
placeholder,
invalidMessage = `Invalid ${label.toLowerCase()} format.`,
type = 'text',
}: {
label: string
parse?: (value: string) => Colord
format: (value: Colord) => string
placeholder?: string
invalidMessage?: string
type?: 'text' | 'color-picker'
}) {
const value = ref('');
return {
type,
label,
parse: (v: string) => withDefaultOnError(() => parse(v), undefined),
format,
placeholder,
value,
validation: useValidation({
source: value,
rules: [
{
message: invalidMessage,
validator: v => withDefaultOnError(() => {
if (v === '') {
return true;
}
return parse(v).isValid();
}, false),
},
],
}),
};
}

View file

@ -1,87 +1,103 @@
<script setup lang="ts">
import type { Colord } from 'colord';
import { colord, extend } from 'colord';
import _ from 'lodash';
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';
import { buildColorFormat } from './color-converter.models';
extend([cmykPlugin, hwbPlugin, namesPlugin, lchPlugin]);
const name = ref('');
const hex = ref('#1ea54cff');
const rgb = ref('');
const hsl = ref('');
const hwb = ref('');
const cmyk = ref('');
const lch = ref('');
const formats = {
picker: buildColorFormat({
label: 'color picker',
format: (v: Colord) => v.toHex(),
type: 'color-picker',
}),
hex: buildColorFormat({
label: 'hex',
format: (v: Colord) => v.toHex(),
placeholder: 'e.g. #ff0000',
}),
rgb: buildColorFormat({
label: 'rgb',
format: (v: Colord) => v.toRgbString(),
placeholder: 'e.g. rgb(255, 0, 0)',
}),
hsl: buildColorFormat({
label: 'hsl',
format: (v: Colord) => v.toHslString(),
placeholder: 'e.g. hsl(0, 100%, 50%)',
}),
hwb: buildColorFormat({
label: 'hwb',
format: (v: Colord) => v.toHwbString(),
placeholder: 'e.g. hwb(0, 0%, 0%)',
}),
lch: buildColorFormat({
label: 'lch',
format: (v: Colord) => v.toLchString(),
placeholder: 'e.g. lch(53.24, 104.55, 40.85)',
}),
cmyk: buildColorFormat({
label: 'cmyk',
format: (v: Colord) => v.toCmykString(),
placeholder: 'e.g. cmyk(0, 100%, 100%, 0)',
}),
name: buildColorFormat({
label: 'name',
format: (v: Colord) => v.toName({ closest: true }) ?? 'Unknown',
placeholder: 'e.g. red',
}),
};
function onInputUpdated(value: string, omit: string) {
try {
const color = colord(value);
updateColorValue(colord('#1ea54c'));
if (omit !== 'name') {
name.value = color.toName({ closest: true }) ?? '';
}
if (omit !== 'hex') {
hex.value = color.toHex();
}
if (omit !== 'rgb') {
rgb.value = color.toRgbString();
}
if (omit !== 'hsl') {
hsl.value = color.toHslString();
}
if (omit !== 'hwb') {
hwb.value = color.toHwbString();
}
if (omit !== 'cmyk') {
cmyk.value = color.toCmykString();
}
if (omit !== 'lch') {
lch.value = color.toLchString();
}
}
catch {
//
}
function updateColorValue(value: Colord | undefined, omitLabel?: string) {
if (value === undefined) {
return;
}
onInputUpdated(hex.value, 'hex');
if (!value.isValid()) {
return;
}
_.forEach(formats, ({ value: valueRef, format }, key) => {
if (key !== omitLabel) {
valueRef.value = format(value);
}
});
}
</script>
<template>
<c-card>
<n-form label-width="100" label-placement="left">
<n-form-item label="color picker:">
<template v-for="({ label, parse, placeholder, validation, type }, key) in formats" :key="key">
<input-copyable
v-if="type === 'text'"
v-model:value="formats[key].value.value"
:test-id="`input-${key}`"
:label="`${label}:`"
label-position="left"
label-width="100px"
label-align="right"
:placeholder="placeholder"
:validation="validation"
raw-text
clearable
mt-2
@update:value="(v:string) => updateColorValue(parse(v), key)"
/>
<n-form-item v-else-if="type === 'color-picker'" :label="`${label}:`" label-width="100" label-placement="left" :show-feedback="false">
<n-color-picker
v-model:value="hex"
v-model:value="formats[key].value.value"
placement="bottom-end"
@update:value="(v: string) => onInputUpdated(v, 'hex')"
@update:value="(v:string) => updateColorValue(parse(v), key)"
/>
</n-form-item>
<n-form-item label="color name:">
<InputCopyable v-model:value="name" @update:value="(v: string) => onInputUpdated(v, 'name')" />
</n-form-item>
<n-form-item label="hex:">
<InputCopyable v-model:value="hex" @update:value="(v: string) => onInputUpdated(v, 'hex')" />
</n-form-item>
<n-form-item label="rgb:">
<InputCopyable v-model:value="rgb" @update:value="(v: string) => onInputUpdated(v, 'rgb')" />
</n-form-item>
<n-form-item label="hsl:">
<InputCopyable v-model:value="hsl" @update:value="(v: string) => onInputUpdated(v, 'hsl')" />
</n-form-item>
<n-form-item label="hwb:">
<InputCopyable v-model:value="hwb" @update:value="(v: string) => onInputUpdated(v, 'hwb')" />
</n-form-item>
<n-form-item label="lch:">
<InputCopyable v-model:value="lch" @update:value="(v: string) => onInputUpdated(v, 'lch')" />
</n-form-item>
<n-form-item label="cmyk:">
<InputCopyable v-model:value="cmyk" @update:value="(v: string) => onInputUpdated(v, 'cmyk')" />
</n-form-item>
</n-form>
</template>
</c-card>
</template>

View file

@ -167,34 +167,8 @@ const cronValidationRules = [
</div>
</c-card>
</div>
<n-table v-else size="small">
<thead>
<tr>
<th class="text-left" scope="col">
Symbol
</th>
<th class="text-left" scope="col">
Meaning
</th>
<th class="text-left" scope="col">
Example
</th>
<th class="text-left" scope="col">
Equivalent
</th>
</tr>
</thead>
<tbody>
<tr v-for="{ symbol, meaning, example, equivalent } in helpers" :key="symbol">
<td>{{ symbol }}</td>
<td>{{ meaning }}</td>
<td>
<code>{{ example }}</code>
</td>
<td>{{ equivalent }}</td>
</tr>
</tbody>
</n-table>
<c-table v-else :data="helpers" />
</c-card>
</template>

View file

@ -29,5 +29,6 @@ test.describe('Date time converter - json to yaml', () => {
expect((await page.getByTestId('Timestamp').inputValue()).trim()).toEqual('1681333824000');
expect((await page.getByTestId('UTC format').inputValue()).trim()).toEqual('Wed, 12 Apr 2023 21:10:24 GMT');
expect((await page.getByTestId('Mongo ObjectID').inputValue()).trim()).toEqual('64371e400000000000000000');
expect((await page.getByTestId('Excel date/time').inputValue()).trim()).toEqual('45028.88222222222');
});
});

View file

@ -1,5 +1,8 @@
import { describe, expect, test } from 'vitest';
import {
dateToExcelFormat,
excelFormatToDate,
isExcelFormat,
isISO8601DateTimeString,
isISO9075DateString,
isMongoObjectId,
@ -139,4 +142,39 @@ describe('date-time-converter models', () => {
expect(isMongoObjectId('')).toBe(false);
});
});
describe('isExcelFormat', () => {
test('an Excel format string is a floating number that can be negative', () => {
expect(isExcelFormat('0')).toBe(true);
expect(isExcelFormat('1')).toBe(true);
expect(isExcelFormat('1.1')).toBe(true);
expect(isExcelFormat('-1.1')).toBe(true);
expect(isExcelFormat('-1')).toBe(true);
expect(isExcelFormat('')).toBe(false);
expect(isExcelFormat('foo')).toBe(false);
expect(isExcelFormat('1.1.1')).toBe(false);
});
});
describe('dateToExcelFormat', () => {
test('a date in Excel format is the number of days since 01/01/1900', () => {
expect(dateToExcelFormat(new Date('2016-05-20T00:00:00.000Z'))).toBe('42510');
expect(dateToExcelFormat(new Date('2016-05-20T12:00:00.000Z'))).toBe('42510.5');
expect(dateToExcelFormat(new Date('2023-10-31T09:26:06.421Z'))).toBe('45230.39312987268');
expect(dateToExcelFormat(new Date('1970-01-01T00:00:00.000Z'))).toBe('25569');
expect(dateToExcelFormat(new Date('1800-01-01T00:00:00.000Z'))).toBe('-36522');
});
});
describe('excelFormatToDate', () => {
test('a date in Excel format is the number of days since 01/01/1900', () => {
expect(excelFormatToDate('0')).toEqual(new Date('1899-12-30T00:00:00.000Z'));
expect(excelFormatToDate('1')).toEqual(new Date('1899-12-31T00:00:00.000Z'));
expect(excelFormatToDate('2')).toEqual(new Date('1900-01-01T00:00:00.000Z'));
expect(excelFormatToDate('4242.4242')).toEqual(new Date('1911-08-12T10:10:50.880Z'));
expect(excelFormatToDate('42738.22626859954')).toEqual(new Date('2017-01-03T05:25:49.607Z'));
expect(excelFormatToDate('-1000')).toEqual(new Date('1897-04-04T00:00:00.000Z'));
});
});
});

View file

@ -9,6 +9,9 @@ export {
isTimestamp,
isUTCDateString,
isMongoObjectId,
dateToExcelFormat,
excelFormatToDate,
isExcelFormat,
};
const ISO8601_REGEX
@ -21,6 +24,8 @@ const RFC3339_REGEX
const RFC7231_REGEX = /^[A-Za-z]{3},\s[0-9]{2}\s[A-Za-z]{3}\s[0-9]{4}\s[0-9]{2}:[0-9]{2}:[0-9]{2}\sGMT$/;
const EXCEL_FORMAT_REGEX = /^-?\d+(\.\d+)?$/;
function createRegexMatcher(regex: RegExp) {
return (date?: string) => !_.isNil(date) && regex.test(date);
}
@ -33,6 +38,8 @@ const isUnixTimestamp = createRegexMatcher(/^[0-9]{1,10}$/);
const isTimestamp = createRegexMatcher(/^[0-9]{1,13}$/);
const isMongoObjectId = createRegexMatcher(/^[0-9a-fA-F]{24}$/);
const isExcelFormat = createRegexMatcher(EXCEL_FORMAT_REGEX);
function isUTCDateString(date?: string) {
if (_.isNil(date)) {
return false;
@ -45,3 +52,11 @@ function isUTCDateString(date?: string) {
return false;
}
}
function dateToExcelFormat(date: Date) {
return String(((date.getTime()) / (1000 * 60 * 60 * 24)) + 25569);
}
function excelFormatToDate(excelFormat: string | number) {
return new Date((Number(excelFormat) - 25569) * 86400 * 1000);
}

View file

@ -14,6 +14,9 @@ import {
} from 'date-fns';
import type { DateFormat, ToDateMapper } from './date-time-converter.types';
import {
dateToExcelFormat,
excelFormatToDate,
isExcelFormat,
isISO8601DateTimeString,
isISO9075DateString,
isMongoObjectId,
@ -85,6 +88,12 @@ const formats: DateFormat[] = [
toDate: objectId => new Date(Number.parseInt(objectId.substring(0, 8), 16) * 1000),
formatMatcher: date => isMongoObjectId(date),
},
{
name: 'Excel date/time',
fromDate: date => dateToExcelFormat(date),
toDate: excelFormatToDate,
formatMatcher: isExcelFormat,
},
];
const formatIndex = ref(6);

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { AES, RC4, Rabbit, TripleDES, enc } from 'crypto-js';
import { computedCatch } from '@/composable/computed/catchedComputed';
const algos = { AES, TripleDES, Rabbit, RC4 };
@ -11,9 +12,10 @@ const cypherOutput = computed(() => algos[cypherAlgo.value].encrypt(cypherInput.
const decryptInput = ref('U2FsdGVkX1/EC3+6P5dbbkZ3e1kQ5o2yzuU0NHTjmrKnLBEwreV489Kr0DIB+uBs');
const decryptAlgo = ref<keyof typeof algos>('AES');
const decryptSecret = ref('my secret key');
const decryptOutput = computed(() =>
algos[decryptAlgo.value].decrypt(decryptInput.value, decryptSecret.value).toString(enc.Utf8),
);
const [decryptOutput, decryptError] = computedCatch(() => algos[decryptAlgo.value].decrypt(decryptInput.value, decryptSecret.value).toString(enc.Utf8), {
defaultValue: '',
defaultErrorMessage: 'Unable to decrypt your text',
});
</script>
<template>
@ -63,7 +65,11 @@ const decryptOutput = computed(() =>
/>
</div>
</div>
<c-alert v-if="decryptError" type="error" mt-12 title="Error while decrypting">
{{ decryptError }}
</c-alert>
<c-input-text
v-else
label="Your decrypted text:"
:value="decryptOutput"
placeholder="Your string hash"

View file

@ -26,8 +26,8 @@ const endAt = computed(() =>
<template>
<div>
<div text-justify op-70>
With a concrete example, if you wash 3 plates in 5 minutes and you have 500 plates to wash, it will take you 5
hours and 10 minutes to wash them all.
With a concrete example, if you wash 5 plates in 3 minutes and you have 500 plates to wash, it will take you 5
hours to wash them all.
</div>
<n-divider />
<div flex gap-2>

View file

@ -6,13 +6,9 @@ const { icon, title, action, isActive } = toRefs(props);
</script>
<template>
<n-tooltip trigger="hover">
<template #trigger>
<c-tooltip :tooltip="title">
<c-button circle variant="text" :type="isActive?.() ? 'primary' : 'default'" @click="action">
<n-icon :component="icon" />
</c-button>
</template>
{{ title }}
</n-tooltip>
</c-tooltip>
</template>

View file

@ -60,9 +60,11 @@ const ibanExamples = [
<div>
<c-input-text v-model:value="rawIban" placeholder="Enter an IBAN to check for validity..." test-id="iban-input" />
<c-key-value-list :items="ibanInfo" my-5 data-test-id="iban-info" />
<c-card v-if="ibanInfo.length > 0" mt-5>
<c-key-value-list :items="ibanInfo" data-test-id="iban-info" />
</c-card>
<c-card title="Valid IBAN examples">
<c-card title="Valid IBAN examples" mt-5>
<div v-for="iban in ibanExamples" :key="iban">
<c-text-copyable :value="iban" font-mono :displayed-value="friendlyFormatIBAN(iban)" />
</div>

View file

@ -1,6 +1,10 @@
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 pdfSignatureChecker } from './pdf-signature-checker';
import { tool as numeronymGenerator } from './numeronym-generator';
import { tool as macAddressGenerator } from './mac-address-generator';
import { tool as textToBinary } from './text-to-binary';
import { tool as ulidGenerator } from './ulid-generator';
import { tool as ibanValidatorAndParser } from './iban-validator-and-parser';
import { tool as stringObfuscator } from './string-obfuscator';
@ -76,7 +80,7 @@ import { tool as htmlMdConverter } from './html-md-converter';
export const toolsByCategory: ToolCategory[] = [
{
name: 'Crypto',
components: [tokenGenerator, hashText, bcrypt, uuidGenerator, ulidGenerator, cypher, bip39, hmacGenerator, rsaKeyPairGenerator, passwordStrengthAnalyser],
components: [tokenGenerator, hashText, bcrypt, uuidGenerator, ulidGenerator, cypher, bip39, hmacGenerator, rsaKeyPairGenerator, passwordStrengthAnalyser, pdfSignatureChecker],
},
{
name: 'Converter',
@ -89,6 +93,7 @@ export const toolsByCategory: ToolCategory[] = [
colorConverter,
caseConverter,
textToNatoAlphabet,
textToBinary,
yamlToJson,
yamlToToml,
jsonToYaml,
@ -140,7 +145,7 @@ export const toolsByCategory: ToolCategory[] = [
},
{
name: 'Network',
components: [ipv4SubnetCalculator, ipv4AddressConverter, ipv4RangeExpander, macAddressLookup, ipv6UlaGenerator],
components: [ipv4SubnetCalculator, ipv4AddressConverter, ipv4RangeExpander, macAddressLookup, macAddressGenerator, ipv6UlaGenerator],
},
{
name: 'Math',
@ -152,7 +157,7 @@ export const toolsByCategory: ToolCategory[] = [
},
{
name: 'Text',
components: [loremIpsumGenerator, textStatistics, emojiPicker, stringObfuscator, textDiff],
components: [loremIpsumGenerator, textStatistics, emojiPicker, stringObfuscator, textDiff, numeronymGenerator],
},
{
name: 'Data',

View file

@ -19,7 +19,7 @@ function decodeJwt({ jwt }: { jwt: string }) {
function parseClaims({ claim, value }: { claim: string; value: unknown }) {
const claimDescription = CLAIM_DESCRIPTIONS[claim];
const formattedValue = _.isPlainObject(value) ? JSON.stringify(value, null, 3) : _.toString(value);
const formattedValue = _.isPlainObject(value) || _.isArray(value) ? JSON.stringify(value, null, 3) : _.toString(value);
const friendlyValue = getFriendlyValue({ claim, value });
return {

View file

@ -0,0 +1,12 @@
import { Devices } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'MAC address generator',
path: '/mac-address-generator',
description: 'Enter the quantity and prefix. MAC addresses will be generated in your chosen case (uppercase or lowercase)',
keywords: ['mac', 'address', 'generator', 'random', 'prefix'],
component: () => import('./mac-address-generator.vue'),
icon: Devices,
createdAt: new Date('2023-11-31'),
});

View file

@ -0,0 +1,11 @@
import { expect, test } from '@playwright/test';
test.describe('Tool - MAC address generator', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/mac-address-generator');
});
test('Has correct title', async ({ page }) => {
await expect(page).toHaveTitle('MAC address generator - IT Tools');
});
});

View file

@ -0,0 +1,103 @@
<script setup lang="ts">
import _ from 'lodash';
import { generateRandomMacAddress } from './mac-adress-generator.models';
import { computedRefreshable } from '@/composable/computedRefreshable';
import { useCopy } from '@/composable/copy';
import { usePartialMacAddressValidation } from '@/utils/macAddress';
const amount = useStorage('mac-address-generator-amount', 1);
const macAddressPrefix = useStorage('mac-address-generator-prefix', '64:16:7F');
const prefixValidation = usePartialMacAddressValidation(macAddressPrefix);
const casesTransformers = [
{ label: 'Uppercase', value: (value: string) => value.toUpperCase() },
{ label: 'Lowercase', value: (value: string) => value.toLowerCase() },
];
const caseTransformer = ref(casesTransformers[0].value);
const separators = [
{
label: ':',
value: ':',
},
{
label: '-',
value: '-',
},
{
label: '.',
value: '.',
},
{
label: 'None',
value: '',
},
];
const separator = useStorage('mac-address-generator-separator', separators[0].value);
const [macAddresses, refreshMacAddresses] = computedRefreshable(() => {
if (!prefixValidation.isValid) {
return '';
}
const ids = _.times(amount.value, () => caseTransformer.value(generateRandomMacAddress({
prefix: macAddressPrefix.value,
separator: separator.value,
})));
return ids.join('\n');
});
const { copy } = useCopy({ source: macAddresses, text: 'MAC addresses copied to the clipboard' });
</script>
<template>
<div flex flex-col justify-center gap-2>
<div flex items-center>
<label w-150px pr-12px text-right> Quantity:</label>
<n-input-number v-model:value="amount" min="1" max="100" flex-1 />
</div>
<c-input-text
v-model:value="macAddressPrefix"
label="MAC address prefix:"
placeholder="Set a prefix, e.g. 64:16:7F"
clearable
label-position="left"
spellcheck="false"
:validation="prefixValidation"
raw-text
label-width="150px"
label-align="right"
/>
<c-buttons-select
v-model:value="caseTransformer"
:options="casesTransformers"
label="Case:"
label-width="150px"
label-align="right"
/>
<c-buttons-select
v-model:value="separator"
:options="separators"
label="Separator:"
label-width="150px"
label-align="right"
/>
<c-card mt-5 flex data-test-id="ulids">
<pre m-0 m-x-auto>{{ macAddresses }}</pre>
</c-card>
<div flex justify-center gap-2>
<c-button data-test-id="refresh" @click="refreshMacAddresses()">
Refresh
</c-button>
<c-button @click="copy()">
Copy
</c-button>
</div>
</div>
</template>

View file

@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { generateRandomMacAddress, splitPrefix } from './mac-adress-generator.models';
describe('mac-adress-generator models', () => {
describe('splitPrefix', () => {
it('a mac address prefix is splitted around non hex characters', () => {
expect(splitPrefix('')).toEqual([]);
expect(splitPrefix('01')).toEqual(['01']);
expect(splitPrefix('01:')).toEqual(['01']);
expect(splitPrefix('01:23')).toEqual(['01', '23']);
expect(splitPrefix('01-23')).toEqual(['01', '23']);
});
it('when a prefix contains only hex characters, they are grouped by 2', () => {
expect(splitPrefix('0123')).toEqual(['01', '23']);
expect(splitPrefix('012345')).toEqual(['01', '23', '45']);
expect(splitPrefix('0123456')).toEqual(['01', '23', '45', '06']);
});
});
describe('generateRandomMacAddress', () => {
const createRandomByteGenerator = () => {
let i = 0;
return () => (i++).toString(16).padStart(2, '0');
};
it('generates a random mac address', () => {
expect(generateRandomMacAddress({ getRandomByte: createRandomByteGenerator() })).toBe('00:01:02:03:04:05');
});
it('generates a random mac address with a prefix', () => {
expect(generateRandomMacAddress({ prefix: 'ff:ee:aa', getRandomByte: createRandomByteGenerator() })).toBe('ff:ee:aa:00:01:02');
expect(generateRandomMacAddress({ prefix: 'ff:ee:a', getRandomByte: createRandomByteGenerator() })).toBe('ff:ee:0a:00:01:02');
});
it('generates a random mac address with a prefix and a different separator', () => {
expect(generateRandomMacAddress({ prefix: 'ff-ee-aa', separator: '-', getRandomByte: createRandomByteGenerator() })).toBe('ff-ee-aa-00-01-02');
expect(generateRandomMacAddress({ prefix: 'ff:ee:aa', separator: '-', getRandomByte: createRandomByteGenerator() })).toBe('ff-ee-aa-00-01-02');
expect(generateRandomMacAddress({ prefix: 'ff-ee:aa', separator: '-', getRandomByte: createRandomByteGenerator() })).toBe('ff-ee-aa-00-01-02');
expect(generateRandomMacAddress({ prefix: 'ff ee:aa', separator: '-', getRandomByte: createRandomByteGenerator() })).toBe('ff-ee-aa-00-01-02');
});
});
});

View file

@ -0,0 +1,18 @@
import _ from 'lodash';
export { splitPrefix, generateRandomMacAddress };
function splitPrefix(prefix: string): string[] {
const base = prefix.match(/[^0-9a-f]/i) === null ? prefix.match(/.{1,2}/g) ?? [] : prefix.split(/[^0-9a-f]/i);
return base.filter(Boolean).map(byte => byte.padStart(2, '0'));
}
function generateRandomMacAddress({ prefix: rawPrefix = '', separator = ':', getRandomByte = () => _.random(0, 255).toString(16).padStart(2, '0') }: { prefix?: string; separator?: string; getRandomByte?: () => string } = {}) {
const prefix = splitPrefix(rawPrefix);
const randomBytes = _.times(6 - prefix.length, getRandomByte);
const bytes = [...prefix, ...randomBytes];
return bytes.join(separator);
}

View file

@ -1,12 +1,12 @@
<script setup lang="ts">
import db from 'oui/oui.json';
import db from 'oui-data';
import { macAddressValidationRules } from '@/utils/macAddress';
import { useCopy } from '@/composable/copy';
const getVendorValue = (address: string) => address.trim().replace(/[.:-]/g, '').toUpperCase().substring(0, 6);
const macAddress = ref('20:37:06:12:34:56');
const details = computed<string | undefined>(() => db[getVendorValue(macAddress.value)]);
const details = computed<string | undefined>(() => (db as Record<string, string>)[getVendorValue(macAddress.value)]);
const { copy } = useCopy({ source: () => details.value ?? '', text: 'Vendor info copied to the clipboard' });
</script>

View file

@ -1,4 +0,0 @@
declare module 'oui/oui.json' {
const db: Record<string, string>;
export default db;
}

View file

@ -4,10 +4,13 @@ import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Math evaluator',
path: '/math-evaluator',
description: 'Evaluate math expression, like a calculator on steroid (you can use function like sqrt, cos, sin, abs, ...)',
description: 'A calculator for evaluating mathematical expressions. You can use functions like sqrt, cos, sin, abs, etc.',
keywords: [
'math',
'evaluator',
'calculator',
'expression',
'abs',
'acos',
'acosh',
'acot',
@ -31,6 +34,7 @@ export const tool = defineTool({
'sech',
'sin',
'sinh',
'sqrt',
'tan',
'tanh',
],

View file

@ -16,6 +16,9 @@ const result = computed(() => withDefaultOnError(() => evaluate(expression.value
multiline
placeholder="Your math expression (ex: 2*sqrt(6) )..."
raw-text
monospace
autofocus
autosize
/>
<c-card v-if="result !== ''" title="Result " mt-5>

View file

@ -0,0 +1,12 @@
import { defineTool } from '../tool';
import n7mIcon from './n7m-icon.svg?component';
export const tool = defineTool({
name: 'Numeronym generator',
path: '/numeronym-generator',
description: 'A numeronym is a word where a number is used to form an abbreviation. For example, "i18n" is a numeronym of "internationalization" where 18 stands for the number of letters between the first i and the last n in the word.',
keywords: ['numeronym', 'generator', 'abbreviation', 'i18n', 'a11y', 'l10n'],
component: () => import('./numeronym-generator.vue'),
icon: n7mIcon,
createdAt: new Date('2023-11-05'),
});

View file

@ -0,0 +1,3 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" >
<path id="n7m" fill="currentColor" aria-label="n7m" d="m0.7 35v-16.7q1.1-0.2 2.8-0.5 1.7-0.3 4-0.3 2.1 0 3.4 0.6 1.4 0.5 2.2 1.6 0.8 1 1.1 2.5 0.4 1.4 0.4 3.2v9.6h-3.1v-9q0-1.6-0.2-2.7-0.2-1.1-0.7-1.8-0.5-0.7-1.4-1-0.8-0.3-2-0.3-0.5 0-1 0-0.6 0-1 0.1-0.5 0-0.9 0.1-0.4 0.1-0.5 0.1v14.5zm18.8 0h-3.2q0.2-2.6 0.9-5.5 0.8-3 1.9-5.7 1.1-2.8 2.4-5.1 1.3-2.4 2.5-3.9h-11.1v-2.7h14.6v2.6q-1.1 1.2-2.4 3.4-1.4 2.2-2.6 5-1.1 2.7-2 5.8-0.8 3-1 6.1zm6.6 0v-16.7q1.1-0.2 2.8-0.5 1.8-0.3 4-0.3 1.7 0 2.8 0.4 1.1 0.5 1.9 1.3 0.2-0.1 0.7-0.4 0.5-0.3 1.2-0.6 0.8-0.3 1.7-0.5 0.8-0.2 1.9-0.2 1.9 0 3.2 0.6 1.3 0.5 2 1.6 0.7 1 0.9 2.5 0.3 1.4 0.3 3.2v9.6h-3.1v-9q0-1.5-0.2-2.6-0.1-1.1-0.5-1.8-0.4-0.7-1.1-1.1-0.7-0.3-1.9-0.3-1.5 0-2.5 0.4-1 0.4-1.4 0.7 0.3 0.9 0.4 1.9 0.1 1 0.1 2.2v9.6h-3v-9q0-1.5-0.2-2.6-0.2-1.1-0.6-1.8-0.4-0.7-1.1-1.1-0.7-0.3-1.8-0.3-0.5 0-1 0-0.5 0-1 0.1-0.5 0-0.9 0.1-0.4 0.1-0.5 0.1v14.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 982 B

View file

@ -0,0 +1,25 @@
import { expect, test } from '@playwright/test';
test.describe('Tool - Numeronym generator', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/numeronym-generator');
});
test('Has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Numeronym generator - IT Tools');
});
test('a numeronym is generated when a word is entered', async ({ page }) => {
await page.getByTestId('word-input').fill('internationalization');
const numeronym = await page.getByTestId('numeronym').inputValue();
expect(numeronym).toEqual('i18n');
});
test('when a word has 3 letters or less, the numeronym is the word itself', async ({ page }) => {
await page.getByTestId('word-input').fill('abc');
const numeronym = await page.getByTestId('numeronym').inputValue();
expect(numeronym).toEqual('abc');
});
});

View file

@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { generateNumeronym } from './numeronym-generator.service';
describe('numeronym-generator service', () => {
describe('generateNumeronym', () => {
it('a numeronym of a word is the first letter, the number of letters between the first and the last letter, and the last letter', () => {
expect(generateNumeronym('internationalization')).toBe('i18n');
expect(generateNumeronym('accessibility')).toBe('a11y');
expect(generateNumeronym('localization')).toBe('l10n');
});
it('a numeronym of a word with 3 letters is the word itself', () => {
expect(generateNumeronym('abc')).toBe('abc');
});
});
});

View file

@ -0,0 +1,11 @@
export { generateNumeronym };
function generateNumeronym(word: string): string {
const wordLength = word.length;
if (wordLength <= 3) {
return word;
}
return `${word.at(0)}${wordLength - 2}${word.at(-1)}`;
}

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import { generateNumeronym } from './numeronym-generator.service';
const word = ref('');
const numeronym = computed(() => generateNumeronym(word.value));
</script>
<template>
<div flex flex-col items-center gap-4>
<c-input-text v-model:value="word" placeholder="Enter a word, e.g. 'internationalization'" size="large" clearable test-id="word-input" />
<icon-mdi-arrow-down text-30px />
<input-copyable :value="numeronym" size="large" readonly placeholder="Your numeronym will be here, e.g. 'i18n'" test-id="numeronym" />
</div>
</template>

View file

@ -61,19 +61,16 @@ const secretValidationRules = [
:validation-rules="secretValidationRules"
>
<template #suffix>
<n-tooltip trigger="hover">
<template #trigger>
<c-tooltip tooltip="Generate a new random secret">
<c-button circle variant="text" size="small" @click="refreshSecret">
<icon-mdi-refresh />
</c-button>
</template>
Generate secret token
</n-tooltip>
</c-tooltip>
</template>
</c-input-text>
<div>
<TokenDisplay :tokens="tokens" style="margin-top: 2px" />
<TokenDisplay :tokens="tokens" />
<n-progress :percentage="(100 * interval) / 30" :color="theme.primaryColor" :show-indicator="false" />
<div style="text-align: center">

View file

@ -11,7 +11,7 @@ const { tokens } = toRefs(props);
<template>
<div>
<div class="labels" w-full flex items-center>
<div mb-5px w-full flex items-center>
<div flex-1 text-left>
Previous
</div>
@ -22,60 +22,24 @@ const { tokens } = toRefs(props);
Next
</div>
</div>
<n-input-group>
<n-tooltip trigger="hover" placement="bottom">
<template #trigger>
<c-button important:h-12 data-test-id="previous-otp" @click.prevent="copyPrevious(tokens.previous)">
<div flex items-center>
<c-tooltip :tooltip="previousCopied ? 'Copied !' : 'Copy previous OTP'" position="bottom" flex-1>
<c-button data-test-id="previous-otp" w-full important:h-12 important:rounded-r-none important:font-mono @click.prevent="copyPrevious(tokens.previous)">
{{ tokens.previous }}
</c-button>
</template>
<div>{{ previousCopied ? 'Copied !' : 'Copy previous OTP' }}</div>
</n-tooltip>
<n-tooltip trigger="hover" placement="bottom">
<template #trigger>
</c-tooltip>
<c-tooltip :tooltip="currentCopied ? 'Copied !' : 'Copy current OTP'" position="bottom" flex-1 flex-basis-5xl>
<c-button
data-test-id="current-otp"
class="current-otp"
important:h-12
@click.prevent="copyCurrent(tokens.current)"
data-test-id="current-otp" w-full important:border-x="1px solid gray op-40" important:h-12 important:rounded-0 important:text-22px @click.prevent="copyCurrent(tokens.current)"
>
{{ tokens.current }}
</c-button>
</template>
<div>{{ currentCopied ? 'Copied !' : 'Copy current OTP' }}</div>
</n-tooltip>
<n-tooltip trigger="hover" placement="bottom">
<template #trigger>
<c-button important:h-12 data-test-id="next-otp" @click.prevent="copyNext(tokens.next)">
{{
tokens.next
}}
</c-tooltip>
<c-tooltip :tooltip="nextCopied ? 'Copied !' : 'Copy next OTP'" position="bottom" flex-1>
<c-button data-test-id="next-otp" w-full important:h-12 important:rounded-l-none @click.prevent="copyNext(tokens.next)">
{{ tokens.next }}
</c-button>
</template>
<div>{{ nextCopied ? 'Copied !' : 'Copy next OTP' }}</div>
</n-tooltip>
</n-input-group>
</c-tooltip>
</div>
</div>
</template>
<style scoped lang="less">
.current-otp {
font-size: 22px;
flex: 1 0 35% !important;
}
.n-button {
height: 45px;
}
.labels {
div {
padding: 0 2px 6px 2px;
line-height: 1.25;
}
}
.n-input-group > * {
flex: 1 0 0;
}
</style>

View file

@ -0,0 +1,99 @@
<script setup lang="ts">
import type { SignatureInfo } from '../pdf-signature-checker.types';
const props = defineProps<{ signature: SignatureInfo }>();
const { signature } = toRefs(props);
const tableHeaders = {
validityPeriod: 'Validity period',
issuedBy: 'Issued by',
issuedTo: 'Issued to',
pemCertificate: 'PEM certificate',
};
const certs = computed(() => signature.value.meta.certs.map((certificate, index) => ({
...certificate,
validityPeriod: {
notBefore: new Date(certificate.validityPeriod.notBefore).toLocaleString(),
notAfter: new Date(certificate.validityPeriod.notAfter).toLocaleString(),
},
certificateName: `Certificate ${index + 1}`,
})),
);
</script>
<template>
<div flex flex-col gap-2>
<c-table :data="certs" :headers="tableHeaders">
<template #validityPeriod="{ value }">
<c-key-value-list
:items="[{
label: 'Not before',
value: value.notBefore,
}, {
label: 'Not after',
value: value.notAfter,
}]"
/>
</template>
<template #issuedBy="{ value }">
<c-key-value-list
:items="[{
label: 'Common name',
value: value.commonName,
}, {
label: 'Organization name',
value: value.organizationName,
}, {
label: 'Country name',
value: value.countryName,
}, {
label: 'Locality name',
value: value.localityName,
}, {
label: 'Organizational unit name',
value: value.organizationalUnitName,
}, {
label: 'State or province name',
value: value.stateOrProvinceName,
}]"
/>
</template>
<template #issuedTo="{ value }">
<c-key-value-list
:items="[{
label: 'Common name',
value: value.commonName,
}, {
label: 'Organization name',
value: value.organizationName,
}, {
label: 'Country name',
value: value.countryName,
}, {
label: 'Locality name',
value: value.localityName,
}, {
label: 'Organizational unit name',
value: value.organizationalUnitName,
}, {
label: 'State or province name',
value: value.stateOrProvinceName,
}]"
/>
</template>
<template #pemCertificate="{ value }">
<c-modal-value :value="value" label="View PEM cert">
<template #value>
<div break-all text-xs>
{{ value }}
</div>
</template>
</c-modal-value>
</template>
</c-table>
</div>
</template>

View file

@ -0,0 +1,12 @@
import { defineTool } from '../tool';
import FileCertIcon from '~icons/mdi/file-certificate-outline';
export const tool = defineTool({
name: 'PDF signature checker',
path: '/pdf-signature-checker',
description: 'Verify the signatures of a PDF file. A signed PDF file contains one or more signatures that may be used to determine whether the contents of the file have been altered since the file was signed.',
keywords: ['pdf', 'signature', 'checker', 'verify', 'validate', 'sign'],
component: () => import('./pdf-signature-checker.vue'),
icon: FileCertIcon,
createdAt: new Date('2023-12-09'),
});

View file

@ -0,0 +1,11 @@
import { expect, test } from '@playwright/test';
test.describe('Tool - Pdf signature checker', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/pdf-signature-checker');
});
test('Has correct title', async ({ page }) => {
await expect(page).toHaveTitle('PDF signature checker - IT Tools');
});
});

View file

@ -0,0 +1,39 @@
export interface SignatureInfo {
verified: boolean
authenticity: boolean
integrity: boolean
expired: boolean
meta: {
certs: {
clientCertificate?: boolean
issuedBy: {
commonName: string
organizationalUnitName?: string
organizationName: string
countryName?: string
localityName?: string
stateOrProvinceName?: string
}
issuedTo: {
commonName: string
serialNumber?: string
organizationalUnitName?: string
organizationName: string
countryName?: string
localityName?: string
stateOrProvinceName?: string
}
validityPeriod: {
notBefore: string
notAfter: string
}
pemCertificate: string
}[]
signatureMeta: {
reason: string
contactInfo: string | null
location: string
name: string | null
}
}
}

View file

@ -0,0 +1,59 @@
<script setup lang="ts">
import verifyPDF from 'pdf-signature-reader';
import type { SignatureInfo } from './pdf-signature-checker.types';
import { formatBytes } from '@/utils/convert';
const signatures = ref<SignatureInfo[]>([]);
const status = ref<'idle' | 'parsed' | 'error' | 'loading'>('idle');
const file = ref<File | null>(null);
async function onVerifyClicked(uploadedFile: File) {
file.value = uploadedFile;
const fileBuffer = await uploadedFile.arrayBuffer();
status.value = 'loading';
try {
const { signatures: parsedSignatures } = verifyPDF(fileBuffer);
signatures.value = parsedSignatures;
status.value = 'parsed';
}
catch (e) {
signatures.value = [];
status.value = 'error';
}
}
</script>
<template>
<div style="flex: 0 0 100%">
<div mx-auto max-w-600px>
<c-file-upload title="Drag and drop a PDF file here, or click to select a file" accept=".pdf" @file-upload="onVerifyClicked" />
<c-card v-if="file" mt-4 flex gap-2>
<div font-bold>
{{ file.name }}
</div>
<div>
{{ formatBytes(file.size) }}
</div>
</c-card>
<div v-if="status === 'error'">
<c-alert mt-4>
No signatures found in the provided file.
</c-alert>
</div>
</div>
</div>
<div v-if="status === 'parsed' && signatures.length" style="flex: 0 0 100%" mt-5 flex flex-col gap-4>
<div v-for="(signature, index) of signatures" :key="index">
<div mb-2 font-bold>
Signature {{ index + 1 }} certificates :
</div>
<pdf-signature-details :signature="signature" />
</div>
</div>
</template>

View file

@ -0,0 +1,12 @@
import { Binary } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Text to ASCII binary',
path: '/text-to-binary',
description: 'Convert text to its ASCII binary representation and vice versa.',
keywords: ['text', 'to', 'binary', 'converter', 'encode', 'decode', 'ascii'],
component: () => import('./text-to-binary.vue'),
icon: Binary,
createdAt: new Date('2023-10-15'),
});

View file

@ -0,0 +1,25 @@
import { expect, test } from '@playwright/test';
test.describe('Tool - Text to ASCII binary', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/text-to-binary');
});
test('Has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Text to ASCII binary - IT Tools');
});
test('Text to binary conversion', async ({ page }) => {
await page.getByTestId('text-to-binary-input').fill('it-tools');
const binary = await page.getByTestId('text-to-binary-output').inputValue();
expect(binary).toEqual('01101001 01110100 00101101 01110100 01101111 01101111 01101100 01110011');
});
test('Binary to text conversion', async ({ page }) => {
await page.getByTestId('binary-to-text-input').fill('01101001 01110100 00101101 01110100 01101111 01101111 01101100 01110011');
const text = await page.getByTestId('binary-to-text-output').inputValue();
expect(text).toEqual('it-tools');
});
});

View file

@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { convertAsciiBinaryToText, convertTextToAsciiBinary } from './text-to-binary.models';
describe('text-to-binary', () => {
describe('convertTextToAsciiBinary', () => {
it('a text string is converted to its ascii binary representation', () => {
expect(convertTextToAsciiBinary('A')).toBe('01000001');
expect(convertTextToAsciiBinary('hello')).toBe('01101000 01100101 01101100 01101100 01101111');
expect(convertTextToAsciiBinary('')).toBe('');
});
it('the separator between octets can be changed', () => {
expect(convertTextToAsciiBinary('hello', { separator: '' })).toBe('0110100001100101011011000110110001101111');
});
});
describe('convertAsciiBinaryToText', () => {
it('an ascii binary string is converted to its text representation', () => {
expect(convertAsciiBinaryToText('01101000 01100101 01101100 01101100 01101111')).toBe('hello');
expect(convertAsciiBinaryToText('01000001')).toBe('A');
expect(convertTextToAsciiBinary('')).toBe('');
});
it('the given binary string is cleaned before conversion', () => {
expect(convertAsciiBinaryToText(' 01000 001garbage')).toBe('A');
});
it('throws an error if the given binary string as no complete octet', () => {
expect(() => convertAsciiBinaryToText('010000011')).toThrow('Invalid binary string');
expect(() => convertAsciiBinaryToText('1')).toThrow('Invalid binary string');
});
});
});

View file

@ -0,0 +1,22 @@
export { convertTextToAsciiBinary, convertAsciiBinaryToText };
function convertTextToAsciiBinary(text: string, { separator = ' ' }: { separator?: string } = {}): string {
return text
.split('')
.map(char => char.charCodeAt(0).toString(2).padStart(8, '0'))
.join(separator);
}
function convertAsciiBinaryToText(binary: string): string {
const cleanBinary = binary.replace(/[^01]/g, '');
if (cleanBinary.length % 8) {
throw new Error('Invalid binary string');
}
return cleanBinary
.split(/(\d{8})/)
.filter(Boolean)
.map(binary => String.fromCharCode(Number.parseInt(binary, 2)))
.join('');
}

View file

@ -0,0 +1,42 @@
<script setup lang="ts">
import { convertAsciiBinaryToText, convertTextToAsciiBinary } from './text-to-binary.models';
import { withDefaultOnError } from '@/utils/defaults';
import { useCopy } from '@/composable/copy';
import { isNotThrowing } from '@/utils/boolean';
const inputText = ref('');
const binaryFromText = computed(() => convertTextToAsciiBinary(inputText.value));
const { copy: copyBinary } = useCopy({ source: binaryFromText });
const inputBinary = ref('');
const textFromBinary = computed(() => withDefaultOnError(() => convertAsciiBinaryToText(inputBinary.value), ''));
const inputBinaryValidationRules = [
{
validator: (value: string) => isNotThrowing(() => convertAsciiBinaryToText(value)),
message: 'Binary should be a valid ASCII binary string with multiples of 8 bits',
},
];
const { copy: copyText } = useCopy({ source: textFromBinary });
</script>
<template>
<c-card title="Text to ASCII binary">
<c-input-text v-model:value="inputText" multiline placeholder="e.g. 'Hello world'" label="Enter text to convert to binary" autosize autofocus raw-text test-id="text-to-binary-input" />
<c-input-text v-model:value="binaryFromText" label="Binary from your text" multiline raw-text readonly mt-2 placeholder="The binary representation of your text will be here" test-id="text-to-binary-output" />
<div mt-2 flex justify-center>
<c-button :disabled="!binaryFromText" @click="copyBinary()">
Copy binary to clipboard
</c-button>
</div>
</c-card>
<c-card title="ASCII binary to text">
<c-input-text v-model:value="inputBinary" multiline placeholder="e.g. '01001000 01100101 01101100 01101100 01101111'" label="Enter binary to convert to text" autosize raw-text :validation-rules="inputBinaryValidationRules" test-id="binary-to-text-input" />
<c-input-text v-model:value="textFromBinary" label="Text from your binary" multiline raw-text readonly mt-2 placeholder="The text representation of your binary will be here" test-id="binary-to-text-output" />
<div mt-2 flex justify-center>
<c-button :disabled="!textFromBinary" @click="copyText()">
Copy text to clipboard
</c-button>
</div>
</c-card>
</template>

View file

@ -1,12 +1,12 @@
import { ArrowsShuffle } from '@vicons/tabler';
import { defineTool } from '../tool';
import { translate } from '@/plugins/i18n.plugin';
export const tool = defineTool({
name: 'Token generator',
name: translate('tools.token-generator.title'),
path: '/token-generator',
description:
'Generate random string with the chars you want: uppercase or lowercase letters, numbers and/or symbols.',
keywords: ['token', 'random', 'string', 'alphanumeric', 'symbols', 'number', 'letters', 'lowercase', 'uppercase'],
description: translate('tools.token-generator.description'),
keywords: ['token', 'random', 'string', 'alphanumeric', 'symbols', 'number', 'letters', 'lowercase', 'uppercase', 'password'],
component: () => import('./token-generator.tool.vue'),
icon: ArrowsShuffle,
});

View file

@ -7,3 +7,9 @@ tools:
lowercase: Lowercase (abc...)
numbers: Numbers (123...)
symbols: Symbols (!-;...)
length: Length
tokenPlaceholder: 'The token...'
copied: Token copied to the clipboard
button:
copy: Copy
refresh: Refresh

View file

@ -1,9 +1,16 @@
tools:
token-generator:
title: Générateur de token
description: Génère une chaîne aléatoire avec les caractères que vous voulez, lettres majuscules ou minuscules, chiffres et/ou symboles.
description: >-
Génère une chaîne aléatoire avec les caractères que vous voulez, lettres
majuscules ou minuscules, chiffres et/ou symboles.
uppercase: Majuscules (ABC...)
lowercase: Minuscules (abc...)
numbers: Chiffres (123...)
symbols: Symboles (!-;...)
button:
copy: Copier
refresh: Rafraichir
copied: Le token a été copié
length: Longueur
tokenPlaceholder: Le token...

View file

@ -21,7 +21,7 @@ const [token, refreshToken] = computedRefreshable(() =>
}),
);
const { copy } = useCopy({ source: token, text: 'Token copied to the clipboard' });
const { copy } = useCopy({ source: token, text: t('tools.token-generator.copied') });
</script>
<template>
@ -51,14 +51,14 @@ const { copy } = useCopy({ source: token, text: 'Token copied to the clipboard'
</div>
</n-form>
<n-form-item :label="`Length (${length})`" label-placement="left">
<n-form-item :label="`${t('tools.token-generator.length')} (${length})`" label-placement="left">
<n-slider v-model:value="length" :step="1" :min="1" :max="512" />
</n-form-item>
<c-input-text
v-model:value="token"
multiline
placeholder="The token..."
:placeholder="t('tools.token-generator.tokenPlaceholder')"
readonly
rows="3"
autosize
@ -67,10 +67,10 @@ const { copy } = useCopy({ source: token, text: 'Token copied to the clipboard'
<div mt-5 flex justify-center gap-3>
<c-button @click="copy()">
Copy
{{ t('tools.token-generator.button.copy') }}
</c-button>
<c-button @click="refreshToken">
Refresh
{{ t('tools.token-generator.button.refresh') }}
</c-button>
</div>
</c-card>

View file

@ -1,44 +1,57 @@
import { type MaybeRef, get, useStorage } from '@vueuse/core';
import { defineStore } from 'pinia';
import type { Ref } from 'vue';
import type { Tool, ToolWithCategory } from './tools.types';
import _ from 'lodash';
import type { Tool, ToolCategory, ToolWithCategory } from './tools.types';
import { toolsWithCategory } from './index';
export const useToolStore = defineStore('tools', {
state: () => ({
favoriteToolsName: useStorage('favoriteToolsName', []) as Ref<string[]>,
}),
getters: {
favoriteTools(state) {
return state.favoriteToolsName
.map(favoriteName => toolsWithCategory.find(({ name }) => name === favoriteName))
export const useToolStore = defineStore('tools', () => {
const favoriteToolsName = useStorage('favoriteToolsName', []) as Ref<string[]>;
const { t } = useI18n();
const tools = computed<ToolWithCategory[]>(() => toolsWithCategory.map((tool) => {
const toolI18nKey = tool.path.replace(/\//g, '');
return ({
...tool,
name: t(`tools.${toolI18nKey}.title`, tool.name),
description: t(`tools.${toolI18nKey}.description`, tool.description),
category: t(`tools.categories.${tool.category.toLowerCase()}`, tool.category),
});
}));
const toolsByCategory = computed<ToolCategory[]>(() => {
return _.chain(tools.value)
.groupBy('category')
.map((components, name) => ({
name,
components,
}))
.value();
});
const favoriteTools = computed(() => {
return favoriteToolsName.value
.map(favoriteName => tools.value.find(({ name }) => name === favoriteName))
.filter(Boolean) as ToolWithCategory[]; // cast because .filter(Boolean) does not remove undefined from type
},
});
notFavoriteTools(state): ToolWithCategory[] {
return toolsWithCategory.filter(tool => !state.favoriteToolsName.includes(tool.name));
},
return {
tools,
favoriteTools,
toolsByCategory,
newTools: computed(() => tools.value.filter(({ isNew }) => isNew)),
tools(): ToolWithCategory[] {
return toolsWithCategory;
},
newTools(): ToolWithCategory[] {
return this.tools.filter(({ isNew }) => isNew);
},
},
actions: {
addToolToFavorites({ tool }: { tool: MaybeRef<Tool> }) {
this.favoriteToolsName.push(get(tool).name);
favoriteToolsName.value.push(get(tool).name);
},
removeToolFromFavorites({ tool }: { tool: MaybeRef<Tool> }) {
this.favoriteToolsName = this.favoriteToolsName.filter(name => get(tool).name !== name);
favoriteToolsName.value = favoriteToolsName.value.filter(name => get(tool).name !== name);
},
isToolFavorite({ tool }: { tool: MaybeRef<Tool> }) {
return this.favoriteToolsName.includes(get(tool).name);
},
return favoriteToolsName.value.includes(get(tool).name);
},
};
});

View file

@ -14,25 +14,18 @@ const { userAgentInfo, sections } = toRefs(props);
<n-grid :x-gap="12" :y-gap="8" cols="1 s:2" responsive="screen">
<n-gi v-for="{ heading, icon, content } in sections" :key="heading">
<c-card h-full>
<n-page-header>
<template #title>
{{ heading }}
</template>
<template v-if="icon" #avatar>
<div flex items-center gap-3>
<n-icon size="30" :component="icon" :depth="3" />
</template>
</n-page-header>
<span text-lg>{{ heading }}</span>
</div>
<div mt-5 flex gap-2>
<span v-for="{ label, getValue } in content" :key="label">
<n-tooltip v-if="getValue(userAgentInfo)" trigger="hover">
<template #trigger>
<c-tooltip v-if="getValue(userAgentInfo)" :tooltip="label">
<n-tag type="success" size="large" round :bordered="false">
{{ getValue(userAgentInfo) }}
</n-tag>
</template>
{{ label }}
</n-tooltip>
</c-tooltip>
</span>
</div>
<div flex flex-col>

View file

@ -2,11 +2,11 @@ import { Fingerprint } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'UUIDs v4 generator',
name: 'UUIDs generator',
path: '/uuid-generator',
description:
'A Universally Unique Identifier (UUID) is a 128-bit number used to identify information in computer systems. The number of possible UUIDs is 16^32, which is 2^128 or about 3.4x10^38 (which is a lot!).',
keywords: ['uuid', 'v4', 'random', 'id', 'alphanumeric', 'identity', 'token', 'string', 'identifier', 'unique'],
keywords: ['uuid', 'v4', 'random', 'id', 'alphanumeric', 'identity', 'token', 'string', 'identifier', 'unique', 'v1', 'v3', 'v5', 'nil'],
component: () => import('./uuid-generator.vue'),
icon: Fingerprint,
});

View file

@ -1,22 +1,94 @@
<script setup lang="ts">
import { v4 as generateUUID } from 'uuid';
import { v1 as generateUuidV1, v3 as generateUuidV3, v4 as generateUuidV4, v5 as generateUuidV5, NIL as nilUuid } from 'uuid';
import { useCopy } from '@/composable/copy';
import { computedRefreshable } from '@/composable/computedRefreshable';
import { withDefaultOnError } from '@/utils/defaults';
const versions = ['NIL', 'v1', 'v3', 'v4', 'v5'] as const;
const version = useStorage<typeof versions[number]>('uuid-generator:version', 'v4');
const count = useStorage('uuid-generator:quantity', 1);
const v35Args = ref({ namespace: '6ba7b811-9dad-11d1-80b4-00c04fd430c8', name: '' });
const [uuids, refreshUUIDs] = computedRefreshable(() =>
Array.from({ length: count.value }, () => generateUUID()).join('\n'),
);
const validUuidRules = [
{
message: 'Invalid UUID',
validator: (value: string) => {
if (value === nilUuid) {
return true;
}
return Boolean(value.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/));
},
},
];
const generators = {
NIL: () => nilUuid,
v1: (index: number) => generateUuidV1({
clockseq: index,
msecs: Date.now(),
nsecs: Math.floor(Math.random() * 10000),
node: Array.from({ length: 6 }, () => Math.floor(Math.random() * 256)),
}),
v3: () => generateUuidV3(v35Args.value.name, v35Args.value.namespace),
v4: () => generateUuidV4(),
v5: () => generateUuidV5(v35Args.value.name, v35Args.value.namespace),
};
const [uuids, refreshUUIDs] = computedRefreshable(() => withDefaultOnError(() =>
Array.from({ length: count.value }, (_ignored, index) => {
const generator = generators[version.value] ?? generators.NIL;
return generator(index);
}).join('\n'), ''));
const { copy } = useCopy({ source: uuids, text: 'UUIDs copied to the clipboard' });
</script>
<template>
<div>
<div flex items-center justify-center gap-3>
Quantity :
<n-input-number v-model:value="count" :min="1" :max="50" placeholder="UUID quantity" />
<c-buttons-select v-model:value="version" :options="versions" label="UUID version" label-width="100px" mb-2 />
<div mb-2 flex items-center>
<span w-100px>Quantity </span>
<n-input-number v-model:value="count" flex-1 :min="1" :max="50" placeholder="UUID quantity" />
</div>
<div v-if="version === 'v3' || version === 'v5'">
<div>
<c-buttons-select
v-model:value="v35Args.namespace"
:options="{
DNS: '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
URL: '6ba7b811-9dad-11d1-80b4-00c04fd430c8',
OID: '6ba7b812-9dad-11d1-80b4-00c04fd430c8',
X500: '6ba7b814-9dad-11d1-80b4-00c04fd430c8',
}"
label="Namespace"
label-width="100px"
mb-2
/>
</div>
<div flex-1>
<c-input-text
v-model:value="v35Args.namespace"
placeholder="Namespace"
label-width="100px"
label-position="left"
label=" "
:validation-rules="validUuidRules"
mb-2
/>
</div>
<c-input-text
v-model:value="v35Args.name"
placeholder="Name"
label="Name"
label-width="100px"
label-position="left"
mb-2
/>
</div>
<c-input-text

View file

@ -1,11 +1,19 @@
<script lang="ts" setup>
const variants = ['warning'] as const;
const variants = ['warning', 'error'] as const;
</script>
<template>
<h2>Basic</h2>
<c-alert v-for="variant in variants" :key="variant" :type="variant" mb-4>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Magni reprehenderit itaque enim? Suscipit magni optio velit
quia, eveniet repellat pariatur quaerat laudantium dignissimos natus, beatae deleniti adipisci, atque necessitatibus
odio!
</c-alert>
<h2>With title</h2>
<c-alert v-for="variant in variants" :key="variant" :type="variant" title="This is the title" mb-4>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Magni reprehenderit itaque enim? Suscipit magni optio velit
quia, eveniet repellat pariatur quaerat laudantium dignissimos natus, beatae deleniti adipisci, atque necessitatibus
odio!
</c-alert>
</template>

View file

@ -3,6 +3,7 @@ import { defineThemes } from '../theme/theme.models';
import { appThemes } from '../theme/themes';
import WarningIcon from '~icons/mdi/alert-circle-outline';
import ErrorIcon from '~icons/mdi/close-circle-outline';
export const { useTheme } = defineThemes({
dark: {
@ -12,6 +13,12 @@ export const { useTheme } = defineThemes({
textColor: appThemes.dark.warning.color,
icon: WarningIcon,
},
error: {
backgroundColor: appThemes.dark.error.colorFaded,
borderColor: appThemes.dark.error.color,
textColor: appThemes.dark.error.color,
icon: ErrorIcon,
},
},
light: {
warning: {
@ -20,5 +27,11 @@ export const { useTheme } = defineThemes({
textColor: darken(appThemes.light.warning.color, 40),
icon: WarningIcon,
},
error: {
backgroundColor: appThemes.light.error.colorFaded,
borderColor: appThemes.light.error.color,
textColor: darken(appThemes.light.error.color, 40),
icon: ErrorIcon,
},
},
});

View file

@ -1,8 +1,8 @@
<script lang="ts" setup>
import { useTheme } from './c-alert.theme';
const props = withDefaults(defineProps<{ type?: 'warning' }>(), { type: 'warning' });
const { type } = toRefs(props);
const props = withDefaults(defineProps<{ type?: 'warning'; title?: string }>(), { type: 'warning', title: undefined });
const { type, title } = toRefs(props);
const theme = useTheme();
const variantTheme = computed(() => theme.value[type.value]);
@ -17,6 +17,9 @@ const variantTheme = computed(() => theme.value[type.value]);
</div>
<div class="c-alert--content">
<div v-if="title" class="c-alert--title" text-15px fw-600>
{{ title }}
</div>
<slot />
</div>
</div>

View file

@ -4,11 +4,18 @@ const optionsA = [
{ label: 'Option B', value: 'b', tooltip: 'This is a tooltip' },
{ label: 'Option C', value: 'c' },
];
const optionB = {
'Option A': 'a',
'Option B': 'b',
'Option C': 'c',
};
const valueA = ref('a');
</script>
<template>
<c-buttons-select v-model:value="valueA" :options="optionsA" label="Label: " />
<c-buttons-select v-model:value="valueA" :options="optionsA" label="Label: " label-position="left" mt-2 />
<c-buttons-select v-model:value="valueA" :options="optionsA" label="Label: " label-position="left" mt-2 />
<c-buttons-select v-model:value="valueA" :options="optionB" label="Options object: " />
</template>

View file

@ -1,10 +1,11 @@
<script setup lang="ts" generic="T extends unknown">
import _ from 'lodash';
import type { CLabelProps } from '../c-label/c-label.types';
import type { CButtonSelectOption } from './c-buttons-select.types';
const props = withDefaults(
defineProps<{
options?: CButtonSelectOption<T>[] | string[]
options?: CButtonSelectOption<T>[] | string[] | Record<string, T>
value?: T
size?: 'small' | 'medium' | 'large'
} & CLabelProps >(),
@ -20,14 +21,18 @@ const emits = defineEmits(['update:value']);
const { options: rawOptions, size } = toRefs(props);
const options = computed(() => {
const options = computed<CButtonSelectOption<T>[]>(() => {
if (_.isArray(rawOptions.value)) {
return rawOptions.value.map((option: string | CButtonSelectOption<T>) => {
if (typeof option === 'string') {
return { label: option, value: option };
}
return option;
});
}) as CButtonSelectOption<T>[];
}
return _.map(rawOptions.value, (value, label) => ({ label, value })) as CButtonSelectOption<T>[];
});
const value = useVModel(props, 'value', emits);

View file

@ -0,0 +1,5 @@
<template>
<c-collapse title="Collapse title">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquet iaculis class cubilia metus per nullam gravida ad venenatis. Id elementum elementum enim orci elementum justo facilisi habitant consequat. Justo eget ligula purus laoreet penatibus eros quisque fusce sociis. In eget amet sagittis dignissim eleifend proin lacinia potenti tellus. Interdum vulputate condimentum molestie pulvinar praesent accumsan quisque venenatis imperdiet.
</c-collapse>
</template>

View file

@ -0,0 +1,25 @@
<script lang="ts" setup>
const props = withDefaults(defineProps<{ title?: string }>(), { title: '' });
const { title } = toRefs(props);
const isCollapsed = ref(true);
</script>
<template>
<div>
<div flex cursor-pointer items-center @click="isCollapsed = !isCollapsed">
<icon-mdi-triangle-down :class="{ 'transform-rotate--90': isCollapsed }" op-50 transition />
<slot name="title">
<span class="ml-2" font-bold>{{ title }}</span>
</slot>
</div>
<div
v-show="!isCollapsed"
mt-2
>
<slot />
</div>
</div>
</template>

View file

@ -0,0 +1,3 @@
<template>
<c-file-upload />
</template>

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