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:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: |
corentinth/it-tools:nightly

View file

@ -39,7 +39,7 @@ jobs:
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: |
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.
## 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

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:contentCopy': typeof import('~icons/mdi/content-copy')['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']
IconMdiCamera: typeof import('~icons/mdi/camera')['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']
IconMdiEye: typeof import('~icons/mdi/eye')['default']
IconMdiEyeOff: typeof import('~icons/mdi/eye-off')['default']
IconMdiFavoriteFilled: typeof import('~icons/mdi/favorite-filled')['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']
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']
@ -115,7 +116,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']
@ -150,8 +153,6 @@ declare module '@vue/runtime-core' {
NLayout: typeof import('naive-ui')['NLayout']
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NMenu: typeof import('naive-ui')['NMenu']
NP: typeof import('naive-ui')['NP']
NPageHeader: typeof import('naive-ui')['NPageHeader']
NProgress: typeof import('naive-ui')['NProgress']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSlider: typeof import('naive-ui')['NSlider']
@ -159,7 +160,7 @@ declare module '@vue/runtime-core' {
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']
@ -191,7 +192,6 @@ declare module '@vue/runtime-core' {
'Tool.layout': typeof import('./src/layouts/tool.layout.vue')['default']
ToolCard: typeof import('./src/components/ToolCard.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']
UrlParser: typeof import('./src/tools/url-parser/url-parser.vue')['default']
UserAgentParser: typeof import('./src/tools/user-agent-parser/user-agent-parser.vue')['default']

View file

@ -28,7 +28,7 @@ home:
about:
h1: 'About IT-Tools'
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'
h1p4: 'sponsoring me'
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'
h3p2: 'issues section'
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'
h3p6: 'in the GitHub repository.'
404:
@ -48,4 +48,18 @@ about:
backHome: 'Back home'
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,49 @@
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'
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",
"version": "2023.8.21-6f93cba",
"version": "2023.11.2-7d94e11",
"description": "Collection of handy online tools for developers, with great UX. ",
"keywords": [
"productivity",
@ -38,9 +38,9 @@
"@it-tools/bip39": "^0.0.4",
"@it-tools/oggen": "^1.3.0",
"@sindresorhus/slugify": "^2.2.1",
"@tiptap/pm": "^2.1.6",
"@tiptap/starter-kit": "^2.1.6",
"@tiptap/vue-3": "^2.0.3",
"@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",
@ -68,14 +68,13 @@
"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",
"pinia": "^2.0.34",
"plausible-tracker": "^0.3.8",
"qrcode": "^1.5.1",
"randombytes": "^2.1.0",
"sql-formatter": "^13.0.0",
"ua-parser-js": "^1.0.35",
"ulid": "^2.3.0",
@ -105,7 +104,6 @@
"@types/node": "^18.15.11",
"@types/node-forge": "^1.3.2",
"@types/qrcode": "^1.5.0",
"@types/randombytes": "^2.0.0",
"@types/ua-parser-js": "^0.7.36",
"@types/uuid": "^9.0.0",
"@unocss/eslint-config": "^0.55.0",
@ -115,7 +113,6 @@
"@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",

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

@ -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 { 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>
@ -47,8 +49,12 @@ const tools = computed<ToolCategory[]>(() => [
</RouterLink>
<div class="sider-content">
<div v-if="styleStore.isSmallScreen" flex justify-center>
<NavbarButtons />
<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" />
@ -108,6 +114,8 @@ const tools = computed<ToolCategory[]>(() => [
<command-palette />
<locale-selector v-if="!styleStore.isSmallScreen" />
<div>
<NavbarButtons v-if="!styleStore.isSmallScreen" />
</div>

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

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

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

@ -73,6 +73,13 @@ const formats = computed(() => [
label: 'Snakecase:',
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 = {

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();
}
function updateColorValue(value: Colord | undefined, omitLabel?: string) {
if (value === undefined) {
return;
}
catch {
//
if (!value.isValid()) {
return;
}
_.forEach(formats, ({ value: valueRef, format }, key) => {
if (key !== omitLabel) {
valueRef.value = format(value);
}
});
}
onInputUpdated(hex.value, 'hex');
</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

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

@ -1,6 +1,8 @@
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 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';
@ -140,7 +142,7 @@ export const toolsByCategory: ToolCategory[] = [
},
{
name: 'Network',
components: [ipv4SubnetCalculator, ipv4AddressConverter, ipv4RangeExpander, macAddressLookup, ipv6UlaGenerator],
components: [ipv4SubnetCalculator, ipv4AddressConverter, ipv4RangeExpander, macAddressLookup, macAddressGenerator, ipv6UlaGenerator],
},
{
name: 'Math',
@ -152,7 +154,7 @@ export const toolsByCategory: ToolCategory[] = [
},
{
name: 'Text',
components: [loremIpsumGenerator, textStatistics, emojiPicker, stringObfuscator, textDiff],
components: [loremIpsumGenerator, textStatistics, emojiPicker, stringObfuscator, textDiff, numeronymGenerator],
},
{
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({
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

@ -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))
.filter(Boolean) as ToolWithCategory[]; // cast because .filter(Boolean) does not remove undefined from type
},
export const useToolStore = defineStore('tools', () => {
const favoriteToolsName = useStorage('favoriteToolsName', []) as Ref<string[]>;
const { t } = useI18n();
notFavoriteTools(state): ToolWithCategory[] {
return toolsWithCategory.filter(tool => !state.favoriteToolsName.includes(tool.name));
},
const tools = computed<ToolWithCategory[]>(() => toolsWithCategory.map((tool) => {
const toolI18nKey = tool.path.replace(/\//g, '');
tools(): ToolWithCategory[] {
return toolsWithCategory;
},
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),
});
}));
newTools(): ToolWithCategory[] {
return this.tools.filter(({ isNew }) => isNew);
},
},
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
});
return {
tools,
favoriteTools,
toolsByCategory,
newTools: computed(() => tools.value.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

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

@ -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="center" 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>

View file

@ -150,13 +150,15 @@ function onSearchInput() {
@keydown="handleKeydown"
>
<div flex-1 truncate>
<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>
{{ selectedOption.label }}
</span>
<span v-else class="placeholder" lh-normal>
{{ placeholder ?? 'Select an option' }}
</span>
<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">
<span v-else-if="selectedOption" lh-normal>
{{ selectedOption.label }}
</span>
<span v-else class="placeholder" lh-normal>
{{ placeholder ?? 'Select an option' }}
</span>
</slot>
</div>
<icon-mdi-chevron-down class="chevron" />

View file

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

View file

@ -12,7 +12,7 @@ const componentName = computed(() => _.startCase(String(route.name).replace(/^c-
<h1>c-lib components</h1>
<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
v-for="{ name } of demoRoutes"
:key="name"
@ -20,6 +20,7 @@ const componentName = computed(() => _.startCase(String(route.name).replace(/^c-
:to="{ name }"
w-full
important:justify-start
important:text-left
:type="route.name === name ? 'primary' : 'default'"
>
{{ name }}

View file

@ -1,16 +1,16 @@
import type { RouteRecordRaw } from 'vue-router';
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) => {
const [, , fileName] = path.split('/');
const name = fileName.split('.').shift();
export const demoRoutes = Object.keys(demoPages).map((demoComponentPath) => {
const [, , fileName] = demoComponentPath.split('/');
const demoComponentName = fileName.split('.').shift();
return {
path: name,
name,
component: () => import(/* @vite-ignore */ path),
path: demoComponentName,
name: demoComponentName,
component: () => import(/* @vite-ignore */ demoComponentPath),
} 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 };