Merge pull request #2 from CorentinTh/main

Đồng bộ
This commit is contained in:
David Nguyen 2023-11-07 22:54:06 +07:00 committed by GitHub
commit 64ba04acc9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 1837 additions and 996 deletions

5
.dockerignore Normal file
View file

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

View file

@ -80,7 +80,7 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true push: true
tags: | tags: |
corentinth/it-tools:nightly corentinth/it-tools:nightly

View file

@ -39,7 +39,7 @@ jobs:
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true push: true
tags: | tags: |
corentinth/it-tools:latest corentinth/it-tools:latest

View file

@ -2,6 +2,78 @@
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. 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.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 ## Version 2023.08.21-6f93cba
### Features ### Features

View file

@ -1,9 +1,12 @@
# build stage # build stage
FROM node:lts-alpine AS 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 WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm i --frozen-lockfile
COPY . . COPY . .
RUN npm install -g pnpm
RUN pnpm i --frozen-lockfile
RUN pnpm build RUN pnpm build
# production stage # production stage

10
components.d.ts vendored
View file

@ -82,6 +82,7 @@ declare module '@vue/runtime-core' {
'IconMdi:brushVariant': typeof import('~icons/mdi/brush-variant')['default'] 'IconMdi:brushVariant': typeof import('~icons/mdi/brush-variant')['default']
'IconMdi:contentCopy': typeof import('~icons/mdi/content-copy')['default'] 'IconMdi:contentCopy': typeof import('~icons/mdi/content-copy')['default']
'IconMdi:kettleSteamOutline': typeof import('~icons/mdi/kettle-steam-outline')['default'] 'IconMdi:kettleSteamOutline': typeof import('~icons/mdi/kettle-steam-outline')['default']
IconMdiArrowDown: typeof import('~icons/mdi/arrow-down')['default']
IconMdiArrowRightBottom: typeof import('~icons/mdi/arrow-right-bottom')['default'] IconMdiArrowRightBottom: typeof import('~icons/mdi/arrow-right-bottom')['default']
IconMdiCamera: typeof import('~icons/mdi/camera')['default'] IconMdiCamera: typeof import('~icons/mdi/camera')['default']
IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default'] IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
@ -92,13 +93,13 @@ declare module '@vue/runtime-core' {
IconMdiDownload: typeof import('~icons/mdi/download')['default'] IconMdiDownload: typeof import('~icons/mdi/download')['default']
IconMdiEye: typeof import('~icons/mdi/eye')['default'] IconMdiEye: typeof import('~icons/mdi/eye')['default']
IconMdiEyeOff: typeof import('~icons/mdi/eye-off')['default'] IconMdiEyeOff: typeof import('~icons/mdi/eye-off')['default']
IconMdiFavoriteFilled: typeof import('~icons/mdi/favorite-filled')['default']
IconMdiHeart: typeof import('~icons/mdi/heart')['default'] IconMdiHeart: typeof import('~icons/mdi/heart')['default']
IconMdiPause: typeof import('~icons/mdi/pause')['default'] IconMdiPause: typeof import('~icons/mdi/pause')['default']
IconMdiPlay: typeof import('~icons/mdi/play')['default'] IconMdiPlay: typeof import('~icons/mdi/play')['default']
IconMdiRecord: typeof import('~icons/mdi/record')['default'] IconMdiRecord: typeof import('~icons/mdi/record')['default']
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default'] IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
IconMdiSearch: typeof import('~icons/mdi/search')['default'] IconMdiSearch: typeof import('~icons/mdi/search')['default']
IconMdiTranslate: typeof import('~icons/mdi/translate')['default']
IconMdiVideo: typeof import('~icons/mdi/video')['default'] IconMdiVideo: typeof import('~icons/mdi/video')['default']
InputCopyable: typeof import('./src/components/InputCopyable.vue')['default'] InputCopyable: typeof import('./src/components/InputCopyable.vue')['default']
IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default'] IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default']
@ -115,7 +116,9 @@ declare module '@vue/runtime-core' {
JwtParser: typeof import('./src/tools/jwt-parser/jwt-parser.vue')['default'] JwtParser: typeof import('./src/tools/jwt-parser/jwt-parser.vue')['default']
KeycodeInfo: typeof import('./src/tools/keycode-info/keycode-info.vue')['default'] KeycodeInfo: typeof import('./src/tools/keycode-info/keycode-info.vue')['default']
ListConverter: typeof import('./src/tools/list-converter/list-converter.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'] 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'] MacAddressLookup: typeof import('./src/tools/mac-address-lookup/mac-address-lookup.vue')['default']
MathEvaluator: typeof import('./src/tools/math-evaluator/math-evaluator.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'] MenuBar: typeof import('./src/tools/html-wysiwyg-editor/editor/menu-bar.vue')['default']
@ -150,8 +153,6 @@ declare module '@vue/runtime-core' {
NLayout: typeof import('naive-ui')['NLayout'] NLayout: typeof import('naive-ui')['NLayout']
NLayoutSider: typeof import('naive-ui')['NLayoutSider'] NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NMenu: typeof import('naive-ui')['NMenu'] NMenu: typeof import('naive-ui')['NMenu']
NP: typeof import('naive-ui')['NP']
NPageHeader: typeof import('naive-ui')['NPageHeader']
NProgress: typeof import('naive-ui')['NProgress'] NProgress: typeof import('naive-ui')['NProgress']
NScrollbar: typeof import('naive-ui')['NScrollbar'] NScrollbar: typeof import('naive-ui')['NScrollbar']
NSlider: typeof import('naive-ui')['NSlider'] NSlider: typeof import('naive-ui')['NSlider']
@ -159,7 +160,7 @@ declare module '@vue/runtime-core' {
NSwitch: typeof import('naive-ui')['NSwitch'] NSwitch: typeof import('naive-ui')['NSwitch']
NTable: typeof import('naive-ui')['NTable'] NTable: typeof import('naive-ui')['NTable']
NTag: typeof import('naive-ui')['NTag'] 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'] NUpload: typeof import('naive-ui')['NUpload']
NUploadDragger: typeof import('naive-ui')['NUploadDragger'] NUploadDragger: typeof import('naive-ui')['NUploadDragger']
OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default'] OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default']
@ -191,7 +192,6 @@ declare module '@vue/runtime-core' {
'Tool.layout': typeof import('./src/layouts/tool.layout.vue')['default'] 'Tool.layout': typeof import('./src/layouts/tool.layout.vue')['default']
ToolCard: typeof import('./src/components/ToolCard.vue')['default'] ToolCard: typeof import('./src/components/ToolCard.vue')['default']
UlidGenerator: typeof import('./src/tools/ulid-generator/ulid-generator.vue')['default'] UlidGenerator: typeof import('./src/tools/ulid-generator/ulid-generator.vue')['default']
Unnamed: typeof import('./src/ui/unnamed/unnamed.vue')['default']
UrlEncoder: typeof import('./src/tools/url-encoder/url-encoder.vue')['default'] UrlEncoder: typeof import('./src/tools/url-encoder/url-encoder.vue')['default']
UrlParser: typeof import('./src/tools/url-parser/url-parser.vue')['default'] UrlParser: typeof import('./src/tools/url-parser/url-parser.vue')['default']
UserAgentParser: typeof import('./src/tools/user-agent-parser/user-agent-parser.vue')['default'] UserAgentParser: typeof import('./src/tools/user-agent-parser/user-agent-parser.vue')['default']

View file

@ -28,7 +28,7 @@ home:
about: about:
h1: 'About IT-Tools' h1: 'About IT-Tools'
h1p1: 'This wonderful website, made with ❤ by' h1p1: 'This wonderful website, made with ❤ by'
h1p2: ', 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!' h1p2: ", 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!"
h1p3: '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' h1p3: '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'
h1p4: 'sponsoring me' h1p4: 'sponsoring me'
h2: Technologies h2: Technologies
@ -38,7 +38,7 @@ about:
h3p1: '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' h3p1: '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'
h3p2: 'issues section' h3p2: 'issues section'
h3p3: 'in the GitHub repository.' h3p3: 'in the GitHub repository.'
h3p4: 'And if you found a bug, or something doesn''''t work as expected, please file a bug report in the' h3p4: "And if you found a bug, or something doesn''t work as expected, please file a bug report in the"
h3p5: 'issues section' h3p5: 'issues section'
h3p6: 'in the GitHub repository.' h3p6: 'in the GitHub repository.'
404: 404:
@ -48,4 +48,18 @@ about:
backHome: 'Back home' backHome: 'Back home'
toolCard: toolCard:
new: New 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,49 @@
home: home:
categories: 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'
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

View file

@ -1,6 +1,6 @@
{ {
"name": "it-tools", "name": "it-tools",
"version": "2023.8.21-6f93cba", "version": "2023.11.2-7d94e11",
"description": "Collection of handy online tools for developers, with great UX. ", "description": "Collection of handy online tools for developers, with great UX. ",
"keywords": [ "keywords": [
"productivity", "productivity",
@ -38,9 +38,9 @@
"@it-tools/bip39": "^0.0.4", "@it-tools/bip39": "^0.0.4",
"@it-tools/oggen": "^1.3.0", "@it-tools/oggen": "^1.3.0",
"@sindresorhus/slugify": "^2.2.1", "@sindresorhus/slugify": "^2.2.1",
"@tiptap/pm": "^2.1.6", "@tiptap/pm": "2.1.6",
"@tiptap/starter-kit": "^2.1.6", "@tiptap/starter-kit": "2.1.6",
"@tiptap/vue-3": "^2.0.3", "@tiptap/vue-3": "2.0.3",
"@vicons/material": "^0.12.0", "@vicons/material": "^0.12.0",
"@vicons/tabler": "^0.12.0", "@vicons/tabler": "^0.12.0",
"@vueuse/core": "^10.3.0", "@vueuse/core": "^10.3.0",
@ -68,14 +68,13 @@
"mathjs": "^11.9.1", "mathjs": "^11.9.1",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"monaco-editor": "^0.43.0", "monaco-editor": "^0.43.0",
"naive-ui": "^2.34.3", "naive-ui": "^2.35.0",
"netmask": "^2.0.2", "netmask": "^2.0.2",
"node-forge": "^1.3.1", "node-forge": "^1.3.1",
"oui": "^12.0.52", "oui": "^12.0.52",
"pinia": "^2.0.34", "pinia": "^2.0.34",
"plausible-tracker": "^0.3.8", "plausible-tracker": "^0.3.8",
"qrcode": "^1.5.1", "qrcode": "^1.5.1",
"randombytes": "^2.1.0",
"sql-formatter": "^13.0.0", "sql-formatter": "^13.0.0",
"ua-parser-js": "^1.0.35", "ua-parser-js": "^1.0.35",
"ulid": "^2.3.0", "ulid": "^2.3.0",
@ -105,7 +104,6 @@
"@types/node": "^18.15.11", "@types/node": "^18.15.11",
"@types/node-forge": "^1.3.2", "@types/node-forge": "^1.3.2",
"@types/qrcode": "^1.5.0", "@types/qrcode": "^1.5.0",
"@types/randombytes": "^2.0.0",
"@types/ua-parser-js": "^0.7.36", "@types/ua-parser-js": "^0.7.36",
"@types/uuid": "^9.0.0", "@types/uuid": "^9.0.0",
"@unocss/eslint-config": "^0.55.0", "@unocss/eslint-config": "^0.55.0",
@ -115,7 +113,6 @@
"@vue/runtime-dom": "^3.3.4", "@vue/runtime-dom": "^3.3.4",
"@vue/test-utils": "^2.3.2", "@vue/test-utils": "^2.3.2",
"@vue/tsconfig": "^0.4.0", "@vue/tsconfig": "^0.4.0",
"c8": "^8.0.0",
"consola": "^3.0.2", "consola": "^3.0.2",
"eslint": "^8.47.0", "eslint": "^8.47.0",
"hygen": "^6.2.11", "hygen": "^6.2.11",

1793
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 theme = computed(() => (styleStore.isDarkTheme ? darkTheme : null));
const themeOverrides = computed(() => (styleStore.isDarkTheme ? darkThemeOverrides : lightThemeOverrides)); const themeOverrides = computed(() => (styleStore.isDarkTheme ? darkThemeOverrides : lightThemeOverrides));
const { locale } = useI18n();
syncRef(
locale,
useStorage('locale', locale),
);
</script> </script>
<template> <template>

View file

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

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

@ -4,10 +4,10 @@ import { NIcon, useThemeVars } from 'naive-ui';
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import { Heart, Home2, Menu2 } from '@vicons/tabler'; import { Heart, Home2, Menu2 } from '@vicons/tabler';
import { storeToRefs } from 'pinia';
import HeroGradient from '../assets/hero-gradient.svg?component'; import HeroGradient from '../assets/hero-gradient.svg?component';
import MenuLayout from '../components/MenuLayout.vue'; import MenuLayout from '../components/MenuLayout.vue';
import NavbarButtons from '../components/NavbarButtons.vue'; import NavbarButtons from '../components/NavbarButtons.vue';
import { toolsByCategory } from '@/tools';
import { useStyleStore } from '@/stores/style.store'; import { useStyleStore } from '@/stores/style.store';
import { config } from '@/config'; import { config } from '@/config';
import type { ToolCategory } from '@/tools/tools.types'; 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 commitSha = config.app.lastCommitSha.slice(0, 7);
const { tracker } = useTracker(); const { tracker } = useTracker();
const { t } = useI18n();
const toolStore = useToolStore(); const toolStore = useToolStore();
const { favoriteTools, toolsByCategory } = storeToRefs(toolStore);
const tools = computed<ToolCategory[]>(() => [ const tools = computed<ToolCategory[]>(() => [
...(toolStore.favoriteTools.length > 0 ? [{ name: 'Your favorite tools', components: toolStore.favoriteTools }] : []), ...(favoriteTools.value.length > 0 ? [{ name: t('tools.categories.favorite-tools'), components: favoriteTools.value }] : []),
...toolsByCategory, ...toolsByCategory.value,
]); ]);
</script> </script>
@ -47,9 +49,13 @@ const tools = computed<ToolCategory[]>(() => [
</RouterLink> </RouterLink>
<div class="sider-content"> <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 /> <NavbarButtons />
</div> </div>
</div>
<CollapsibleToolMenu :tools-by-category="tools" /> <CollapsibleToolMenu :tools-by-category="tools" />
@ -108,6 +114,8 @@ const tools = computed<ToolCategory[]>(() => [
<command-palette /> <command-palette />
<locale-selector v-if="!styleStore.isSmallScreen" />
<div> <div>
<NavbarButtons v-if="!styleStore.isSmallScreen" /> <NavbarButtons v-if="!styleStore.isSmallScreen" />
</div> </div>

View file

@ -9,6 +9,7 @@ import SunIcon from '~icons/mdi/white-balance-sunny';
import GithubIcon from '~icons/mdi/github'; import GithubIcon from '~icons/mdi/github';
import BugIcon from '~icons/mdi/bug-outline'; import BugIcon from '~icons/mdi/bug-outline';
import DiceIcon from '~icons/mdi/dice-5'; import DiceIcon from '~icons/mdi/dice-5';
import InfoIcon from '~icons/mdi/information-outline';
export const useCommandPaletteStore = defineStore('command-palette', () => { export const useCommandPaletteStore = defineStore('command-palette', () => {
const toolStore = useToolStore(); const toolStore = useToolStore();
@ -61,6 +62,14 @@ export const useCommandPaletteStore = defineStore('command-palette', () => {
keywords: ['report', 'issue', 'bug', 'problem', 'error'], keywords: ['report', 'issue', 'bug', 'problem', 'error'],
icon: BugIcon, 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({ const { searchResult } = useFuzzySearch({

View file

@ -37,6 +37,7 @@ function open() {
function close() { function close() {
isModalOpen.value = false; isModalOpen.value = false;
searchPrompt.value = '';
} }
const selectedOptionIndex = ref(0); const selectedOptionIndex = ref(0);
@ -115,7 +116,7 @@ function activateOption(option: PaletteOption) {
<span flex items-center gap-3 op-40> <span flex items-center gap-3 op-40>
<icon-mdi-search /> <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> <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 {{ 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

@ -31,7 +31,8 @@ const { t } = useI18n();
rel="noopener" rel="noopener"
target="_blank" target="_blank"
:aria-label="$t('home.follow.twitterAccount')" :aria-label="$t('home.follow.twitterAccount')"
>Twitter</a>{{ $t('home.follow.thankYou') }} >Twitter</a>.
{{ $t('home.follow.thankYou') }}
<n-icon :component="Heart" /> <n-icon :component="Heart" />
</ColoredCard> </ColoredCard>
</n-gi> </n-gi>

View file

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

View file

@ -73,6 +73,13 @@ const formats = computed(() => [
label: 'Snakecase:', label: 'Snakecase:',
value: snakeCase(input.value, baseConfig), value: snakeCase(input.value, baseConfig),
}, },
{
label: 'Mockingcase:',
value: noCase(input.value, baseConfig)
.split('')
.map((char, index) => (index % 2 === 0 ? char.toUpperCase() : char.toLowerCase()))
.join(''),
},
]); ]);
const inputLabelAlignmentConfig = { 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"> <script setup lang="ts">
import type { Colord } from 'colord';
import { colord, extend } from 'colord'; import { colord, extend } from 'colord';
import _ from 'lodash';
import cmykPlugin from 'colord/plugins/cmyk'; import cmykPlugin from 'colord/plugins/cmyk';
import hwbPlugin from 'colord/plugins/hwb'; import hwbPlugin from 'colord/plugins/hwb';
import namesPlugin from 'colord/plugins/names'; import namesPlugin from 'colord/plugins/names';
import lchPlugin from 'colord/plugins/lch'; import lchPlugin from 'colord/plugins/lch';
import InputCopyable from '../../components/InputCopyable.vue'; import { buildColorFormat } from './color-converter.models';
extend([cmykPlugin, hwbPlugin, namesPlugin, lchPlugin]); extend([cmykPlugin, hwbPlugin, namesPlugin, lchPlugin]);
const name = ref(''); const formats = {
const hex = ref('#1ea54cff'); picker: buildColorFormat({
const rgb = ref(''); label: 'color picker',
const hsl = ref(''); format: (v: Colord) => v.toHex(),
const hwb = ref(''); type: 'color-picker',
const cmyk = ref(''); }),
const lch = ref(''); 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) { updateColorValue(colord('#1ea54c'));
try {
const color = colord(value);
if (omit !== 'name') { function updateColorValue(value: Colord | undefined, omitLabel?: string) {
name.value = color.toName({ closest: true }) ?? ''; if (value === undefined) {
return;
} }
if (omit !== 'hex') {
hex.value = color.toHex(); if (!value.isValid()) {
return;
} }
if (omit !== 'rgb') {
rgb.value = color.toRgbString(); _.forEach(formats, ({ value: valueRef, format }, key) => {
} if (key !== omitLabel) {
if (omit !== 'hsl') { valueRef.value = format(value);
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 {
//
} }
});
} }
onInputUpdated(hex.value, 'hex');
</script> </script>
<template> <template>
<c-card> <c-card>
<n-form label-width="100" label-placement="left"> <template v-for="({ label, parse, placeholder, validation, type }, key) in formats" :key="key">
<n-form-item label="color picker:"> <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 <n-color-picker
v-model:value="hex" v-model:value="formats[key].value.value"
placement="bottom-end" placement="bottom-end"
@update:value="(v: string) => onInputUpdated(v, 'hex')" @update:value="(v:string) => updateColorValue(parse(v), key)"
/> />
</n-form-item> </n-form-item>
<n-form-item label="color name:"> </template>
<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>
</c-card> </c-card>
</template> </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('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('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('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 { describe, expect, test } from 'vitest';
import { import {
dateToExcelFormat,
excelFormatToDate,
isExcelFormat,
isISO8601DateTimeString, isISO8601DateTimeString,
isISO9075DateString, isISO9075DateString,
isMongoObjectId, isMongoObjectId,
@ -139,4 +142,39 @@ describe('date-time-converter models', () => {
expect(isMongoObjectId('')).toBe(false); 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, isTimestamp,
isUTCDateString, isUTCDateString,
isMongoObjectId, isMongoObjectId,
dateToExcelFormat,
excelFormatToDate,
isExcelFormat,
}; };
const ISO8601_REGEX 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 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) { function createRegexMatcher(regex: RegExp) {
return (date?: string) => !_.isNil(date) && regex.test(date); 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 isTimestamp = createRegexMatcher(/^[0-9]{1,13}$/);
const isMongoObjectId = createRegexMatcher(/^[0-9a-fA-F]{24}$/); const isMongoObjectId = createRegexMatcher(/^[0-9a-fA-F]{24}$/);
const isExcelFormat = createRegexMatcher(EXCEL_FORMAT_REGEX);
function isUTCDateString(date?: string) { function isUTCDateString(date?: string) {
if (_.isNil(date)) { if (_.isNil(date)) {
return false; return false;
@ -45,3 +52,11 @@ function isUTCDateString(date?: string) {
return false; 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'; } from 'date-fns';
import type { DateFormat, ToDateMapper } from './date-time-converter.types'; import type { DateFormat, ToDateMapper } from './date-time-converter.types';
import { import {
dateToExcelFormat,
excelFormatToDate,
isExcelFormat,
isISO8601DateTimeString, isISO8601DateTimeString,
isISO9075DateString, isISO9075DateString,
isMongoObjectId, isMongoObjectId,
@ -85,6 +88,12 @@ const formats: DateFormat[] = [
toDate: objectId => new Date(Number.parseInt(objectId.substring(0, 8), 16) * 1000), toDate: objectId => new Date(Number.parseInt(objectId.substring(0, 8), 16) * 1000),
formatMatcher: date => isMongoObjectId(date), formatMatcher: date => isMongoObjectId(date),
}, },
{
name: 'Excel date/time',
fromDate: date => dateToExcelFormat(date),
toDate: excelFormatToDate,
formatMatcher: isExcelFormat,
},
]; ];
const formatIndex = ref(6); const formatIndex = ref(6);

View file

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

View file

@ -1,6 +1,8 @@
import { tool as base64FileConverter } from './base64-file-converter'; import { tool as base64FileConverter } from './base64-file-converter';
import { tool as base64StringConverter } from './base64-string-converter'; import { tool as base64StringConverter } from './base64-string-converter';
import { tool as basicAuthGenerator } from './basic-auth-generator'; import { tool as basicAuthGenerator } from './basic-auth-generator';
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 textToBinary } from './text-to-binary';
import { tool as ulidGenerator } from './ulid-generator'; import { tool as ulidGenerator } from './ulid-generator';
import { tool as ibanValidatorAndParser } from './iban-validator-and-parser'; import { tool as ibanValidatorAndParser } from './iban-validator-and-parser';
@ -140,7 +142,7 @@ export const toolsByCategory: ToolCategory[] = [
}, },
{ {
name: 'Network', name: 'Network',
components: [ipv4SubnetCalculator, ipv4AddressConverter, ipv4RangeExpander, macAddressLookup, ipv6UlaGenerator], components: [ipv4SubnetCalculator, ipv4AddressConverter, ipv4RangeExpander, macAddressLookup, macAddressGenerator, ipv6UlaGenerator],
}, },
{ {
name: 'Math', name: 'Math',
@ -152,7 +154,7 @@ export const toolsByCategory: ToolCategory[] = [
}, },
{ {
name: 'Text', name: 'Text',
components: [loremIpsumGenerator, textStatistics, emojiPicker, stringObfuscator, textDiff], components: [loremIpsumGenerator, textStatistics, emojiPicker, stringObfuscator, textDiff, numeronymGenerator],
}, },
{ {
name: 'Data', name: 'Data',

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

@ -4,10 +4,13 @@ import { defineTool } from '../tool';
export const tool = defineTool({ export const tool = defineTool({
name: 'Math evaluator', name: 'Math evaluator',
path: '/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: [ keywords: [
'math', 'math',
'evaluator', 'evaluator',
'calculator',
'expression',
'abs',
'acos', 'acos',
'acosh', 'acosh',
'acot', 'acot',
@ -31,6 +34,7 @@ export const tool = defineTool({
'sech', 'sech',
'sin', 'sin',
'sinh', 'sinh',
'sqrt',
'tan', 'tan',
'tanh', 'tanh',
], ],

View file

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

@ -1,44 +1,57 @@
import { type MaybeRef, get, useStorage } from '@vueuse/core'; import { type MaybeRef, get, useStorage } from '@vueuse/core';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import type { Ref } from 'vue'; 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'; import { toolsWithCategory } from './index';
export const useToolStore = defineStore('tools', { export const useToolStore = defineStore('tools', () => {
state: () => ({ const favoriteToolsName = useStorage('favoriteToolsName', []) as Ref<string[]>;
favoriteToolsName: useStorage('favoriteToolsName', []) as Ref<string[]>, const { t } = useI18n();
}),
getters: { const tools = computed<ToolWithCategory[]>(() => toolsWithCategory.map((tool) => {
favoriteTools(state) { const toolI18nKey = tool.path.replace(/\//g, '');
return state.favoriteToolsName
.map(favoriteName => toolsWithCategory.find(({ name }) => name === favoriteName)) 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 .filter(Boolean) as ToolWithCategory[]; // cast because .filter(Boolean) does not remove undefined from type
}, });
notFavoriteTools(state): ToolWithCategory[] { return {
return toolsWithCategory.filter(tool => !state.favoriteToolsName.includes(tool.name)); 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> }) { addToolToFavorites({ tool }: { tool: MaybeRef<Tool> }) {
this.favoriteToolsName.push(get(tool).name); favoriteToolsName.value.push(get(tool).name);
}, },
removeToolFromFavorites({ tool }: { tool: MaybeRef<Tool> }) { 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> }) { isToolFavorite({ tool }: { tool: MaybeRef<Tool> }) {
return this.favoriteToolsName.includes(get(tool).name); return favoriteToolsName.value.includes(get(tool).name);
},
}, },
};
}); });

View file

@ -1,11 +1,19 @@
<script lang="ts" setup> <script lang="ts" setup>
const variants = ['warning'] as const; const variants = ['warning', 'error'] as const;
</script> </script>
<template> <template>
<h2>Basic</h2>
<c-alert v-for="variant in variants" :key="variant" :type="variant" mb-4> <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 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 quia, eveniet repellat pariatur quaerat laudantium dignissimos natus, beatae deleniti adipisci, atque necessitatibus
odio! odio!
</c-alert> </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> </template>

View file

@ -3,6 +3,7 @@ import { defineThemes } from '../theme/theme.models';
import { appThemes } from '../theme/themes'; import { appThemes } from '../theme/themes';
import WarningIcon from '~icons/mdi/alert-circle-outline'; import WarningIcon from '~icons/mdi/alert-circle-outline';
import ErrorIcon from '~icons/mdi/close-circle-outline';
export const { useTheme } = defineThemes({ export const { useTheme } = defineThemes({
dark: { dark: {
@ -12,6 +13,12 @@ export const { useTheme } = defineThemes({
textColor: appThemes.dark.warning.color, textColor: appThemes.dark.warning.color,
icon: WarningIcon, icon: WarningIcon,
}, },
error: {
backgroundColor: appThemes.dark.error.colorFaded,
borderColor: appThemes.dark.error.color,
textColor: appThemes.dark.error.color,
icon: ErrorIcon,
},
}, },
light: { light: {
warning: { warning: {
@ -20,5 +27,11 @@ export const { useTheme } = defineThemes({
textColor: darken(appThemes.light.warning.color, 40), textColor: darken(appThemes.light.warning.color, 40),
icon: WarningIcon, 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> <script lang="ts" setup>
import { useTheme } from './c-alert.theme'; import { useTheme } from './c-alert.theme';
const props = withDefaults(defineProps<{ type?: 'warning' }>(), { type: 'warning' }); const props = withDefaults(defineProps<{ type?: 'warning'; title?: string }>(), { type: 'warning', title: undefined });
const { type } = toRefs(props); const { type, title } = toRefs(props);
const theme = useTheme(); const theme = useTheme();
const variantTheme = computed(() => theme.value[type.value]); const variantTheme = computed(() => theme.value[type.value]);
@ -17,6 +17,9 @@ const variantTheme = computed(() => theme.value[type.value]);
</div> </div>
<div class="c-alert--content"> <div class="c-alert--content">
<div v-if="title" class="c-alert--title" text-15px fw-600>
{{ title }}
</div>
<slot /> <slot />
</div> </div>
</div> </div>

View file

@ -33,4 +33,19 @@ const value = ref('');
<c-select label="Label" label-position="left" label-align="left" mb-2 label-width="200px" /> <c-select label="Label" label-position="left" label-align="left" mb-2 label-width="200px" />
<c-select label="Label" label-position="left" label-align="center" mb-2 label-width="200px" /> <c-select label="Label" label-position="left" label-align="center" mb-2 label-width="200px" />
<c-select label="Label" label-position="left" label-align="right" mb-2 label-width="200px" /> <c-select label="Label" label-position="left" label-align="right" mb-2 label-width="200px" />
<h2>Custom displayed value</h2>
<c-select v-model:value="value" :options="optionsA" mb-2>
<template #displayed-value>
<span class="font-bold lh-normal">Hello</span>
</template>
</c-select>
<c-select v-model:value="value" :options="optionsA">
<template #displayed-value>
<span lh-normal>
<icon-mdi-translate />
</span>
</template>
</c-select>
</template> </template>

View file

@ -150,6 +150,7 @@ function onSearchInput() {
@keydown="handleKeydown" @keydown="handleKeydown"
> >
<div flex-1 truncate> <div flex-1 truncate>
<slot name="displayed-value">
<input v-if="searchable && isOpen" ref="searchInputRef" v-model="searchQuery" type="text" placeholder="Search..." class="search-input" w-full lh-normal color-current @input="onSearchInput"> <input v-if="searchable && isOpen" ref="searchInputRef" v-model="searchQuery" type="text" placeholder="Search..." class="search-input" w-full lh-normal color-current @input="onSearchInput">
<span v-else-if="selectedOption" lh-normal> <span v-else-if="selectedOption" lh-normal>
{{ selectedOption.label }} {{ selectedOption.label }}
@ -157,6 +158,7 @@ function onSearchInput() {
<span v-else class="placeholder" lh-normal> <span v-else class="placeholder" lh-normal>
{{ placeholder ?? 'Select an option' }} {{ placeholder ?? 'Select an option' }}
</span> </span>
</slot>
</div> </div>
<icon-mdi-chevron-down class="chevron" /> <icon-mdi-chevron-down class="chevron" />

View file

@ -4,10 +4,8 @@ import { demoRoutes } from './demo.routes';
<template> <template>
<div grid grid-cols-5 gap-2> <div grid grid-cols-5 gap-2>
<c-card v-for="{ name } of demoRoutes" :key="name" :title="String(name)"> <c-button v-for="{ name } of demoRoutes" :key="name" :to="{ name }">
<c-button :to="{ name }">
{{ name }} {{ name }}
</c-button> </c-button>
</c-card>
</div> </div>
</template> </template>

View file

@ -12,7 +12,7 @@ const componentName = computed(() => _.startCase(String(route.name).replace(/^c-
<h1>c-lib components</h1> <h1>c-lib components</h1>
<div flex> <div flex>
<div w-30 b-r b-gray b-op-10 b-r-solid pr-4> <div w-200px b-r b-gray b-op-10 b-r-solid pr-4>
<c-button <c-button
v-for="{ name } of demoRoutes" v-for="{ name } of demoRoutes"
:key="name" :key="name"
@ -20,6 +20,7 @@ const componentName = computed(() => _.startCase(String(route.name).replace(/^c-
:to="{ name }" :to="{ name }"
w-full w-full
important:justify-start important:justify-start
important:text-left
:type="route.name === name ? 'primary' : 'default'" :type="route.name === name ? 'primary' : 'default'"
> >
{{ name }} {{ name }}

View file

@ -1,16 +1,16 @@
import type { RouteRecordRaw } from 'vue-router'; import type { RouteRecordRaw } from 'vue-router';
import DemoHome from './demo-home.page.vue'; import DemoHome from './demo-home.page.vue';
const demoPages = import.meta.glob('../*/*.demo.vue'); const demoPages = import.meta.glob('../*/*.demo.vue', { eager: true });
export const demoRoutes = Object.keys(demoPages).map((path) => { export const demoRoutes = Object.keys(demoPages).map((demoComponentPath) => {
const [, , fileName] = path.split('/'); const [, , fileName] = demoComponentPath.split('/');
const name = fileName.split('.').shift(); const demoComponentName = fileName.split('.').shift();
return { return {
path: name, path: demoComponentName,
name, name: demoComponentName,
component: () => import(/* @vite-ignore */ path), component: () => import(/* @vite-ignore */ demoComponentPath),
} as RouteRecordRaw; } as RouteRecordRaw;
}); });

View file

@ -15,4 +15,18 @@ function macAddressValidation(value: Ref) {
}); });
} }
export { macAddressValidation, macAddressValidationRules }; const partialMacAddressValidationRules = [
{
message: 'Invalid partial MAC address',
validator: (value: string) => value.trim().match(/^([0-9a-f]{2}[:\-. ]){0,5}([0-9a-f]{0,2})$/i),
},
];
function usePartialMacAddressValidation(value: Ref) {
return useValidation({
source: value,
rules: partialMacAddressValidationRules,
});
}
export { macAddressValidation, macAddressValidationRules, usePartialMacAddressValidation, partialMacAddressValidationRules };