Compare commits

..

16 Commits

Author SHA1 Message Date
Corentin THOMASSET
e9e0884789 Merge branch 'main' into card-hover 2023-09-06 10:53:40 +02:00
Simon Bordeyne
0eedce69a6 feat(new tool): add wifi qr code generator (#599)
* (feat: new tool): add wifi qr code generator

* Update src/tools/wifi-qr-code-generator/wifi-qr-code-generator.vue

Co-authored-by: Corentin THOMASSET <corentin.thomasset74@gmail.com>

* Update src/tools/wifi-qr-code-generator/index.ts

Co-authored-by: Corentin THOMASSET <corentin.thomasset74@gmail.com>

* remove naive UI grid

* Update src/tools/wifi-qr-code-generator/index.ts

---------

Co-authored-by: Corentin THOMASSET <corentin.thomasset74@gmail.com>
2023-09-06 07:46:40 +00:00
Corentin Thomasset
e0c7771e8f refactor(home): prettier tool card list 2023-09-05 08:56:16 +02:00
Mark Townsend
8a30b6bdb3 refactor(spelling): minor corrections to phrasing/spelling (#596)
* Minor corrections to phrasing/spelling.

* Corrected 'millennia'.

* Corrected tests.

---------

Co-authored-by: Corentin THOMASSET <corentin.thomasset74@gmail.com>
2023-09-04 14:51:04 +02:00
Corentin THOMASSET
233d5565f6 refactor(i18n): merge tools scoped locales with global ones (#612) 2023-09-03 20:07:45 +00:00
Corentin THOMASSET
7ab9204e96 refactor(c-key-value-list): got rid of table for layout (#611) 2023-09-03 16:12:22 +00:00
renovate[bot]
c7d4562d3b fix(deps): update dependency sql-formatter to v13 (#606)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-03 16:51:12 +02:00
Corentin THOMASSET
18dd1400bd refactor(CI): run e2e against built app and no longer vercel (#610) 2023-09-03 14:28:19 +00:00
renovate[bot]
f035f485c0 chore(deps): update dependency unplugin-icons to ^0.17.0 (#609)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-03 16:16:01 +02:00
Anton Mosich
e18bae1fca refactor(bcrypt): fix typo (#604) 2023-09-01 16:30:51 +02:00
renovate[bot]
d1dff428d8 chore(deps): update dependency @intlify/unplugin-vue-i18n to ^0.13.0 (#597)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-01 10:14:50 +02:00
Corentin THOMASSET
3a63837d3d feat(new tool): iban validation and parser (#591) 2023-08-27 18:12:31 +00:00
Corentin THOMASSET
81bfe57cb8 feat(new tool): text diff and comparator (#588)
* feat(new tool): text diff and comparator

* chore(ci): increased memory in CI
2023-08-26 16:43:47 +02:00
renovate[bot]
a9cd91ca9c chore(deps): update dependency @antfu/eslint-config to ^0.41.0 (#585)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-26 16:28:31 +02:00
Corentin THOMASSET
06c35472d3 doc(hosting): added cloudron in the other hosting solutions section (#589) 2023-08-26 14:18:12 +00:00
renovate[bot]
f3e14fc18f chore(deps): update dependency typescript to ~5.2.0 (#587)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-26 16:17:19 +02:00
34 changed files with 1023 additions and 379 deletions

View File

@@ -1,13 +1,13 @@
name: E2E tests
on: [deployment_status]
on:
pull_request:
push:
branches:
- main
jobs:
test:
if: github.event.deployment_status.state == 'success'
timeout-minutes: 60
runs-on: ubuntu-latest
env:
BASE_URL: ${{ github.event.deployment_status.target_url }}
strategy:
matrix:
shard: [1/3, 2/3, 3/3]
@@ -28,6 +28,9 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Build app
run: pnpm build
- name: Restore Playwright browsers from cache
uses: actions/cache@v3
with:

View File

@@ -26,6 +26,7 @@ docker run -d --name it-tools --restart unless-stopped -p 8080:80 ghcr.io/corent
**Other solutions:**
- [Cloudron](https://www.cloudron.io/store/tech.ittools.cloudron.html)
- [Tipi](https://www.runtipi.io/docs/apps-available)
- [Unraid](https://unraid.net/community/apps?q=it-tools)
@@ -34,23 +35,21 @@ docker run -d --name it-tools --restart unless-stopped -p 8080:80 ghcr.io/corent
### Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) with the following extensions:
- [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur)
- [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur)
- [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
- [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)
- [i18n Ally](https://marketplace.visualstudio.com/items?itemName=lokalise.i18n-ally)
with the following settings:
```json5
```json
{
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"i18n-ally.localesPaths": [
"locales",
"src/tools/*/locales"
],
"i18n-ally.localesPaths": ["locales", "src/tools/*/locales"],
"i18n-ally.keystyle": "nested"
}
```

10
components.d.ts vendored
View File

@@ -27,10 +27,13 @@ declare module '@vue/runtime-core' {
'CButton.demo': typeof import('./src/ui/c-button/c-button.demo.vue')['default']
CCard: typeof import('./src/ui/c-card/c-card.vue')['default']
'CCard.demo': typeof import('./src/ui/c-card/c-card.demo.vue')['default']
CDiffEditor: typeof import('./src/ui/c-diff-editor/c-diff-editor.vue')['default']
ChmodCalculator: typeof import('./src/tools/chmod-calculator/chmod-calculator.vue')['default']
Chronometer: typeof import('./src/tools/chronometer/chronometer.vue')['default']
CInputText: typeof import('./src/ui/c-input-text/c-input-text.vue')['default']
'CInputText.demo': typeof import('./src/ui/c-input-text/c-input-text.demo.vue')['default']
CKeyValueList: typeof import('./src/ui/c-key-value-list/c-key-value-list.vue')['default']
CKeyValueListItem: typeof import('./src/ui/c-key-value-list/c-key-value-list-item.vue')['default']
CLabel: typeof import('./src/ui/c-label/c-label.vue')['default']
CLink: typeof import('./src/ui/c-link/c-link.vue')['default']
'CLink.demo': typeof import('./src/ui/c-link/c-link.demo.vue')['default']
@@ -44,6 +47,10 @@ declare module '@vue/runtime-core' {
CrontabGenerator: typeof import('./src/tools/crontab-generator/crontab-generator.vue')['default']
CSelect: typeof import('./src/ui/c-select/c-select.vue')['default']
'CSelect.demo': typeof import('./src/ui/c-select/c-select.demo.vue')['default']
CTextCopyable: typeof import('./src/ui/c-text-copyable/c-text-copyable.vue')['default']
'CTextCopyable.demo': typeof import('./src/ui/c-text-copyable/c-text-copyable.demo.vue')['default']
CTooltip: typeof import('./src/ui/c-tooltip/c-tooltip.vue')['default']
'CTooltip.demo': typeof import('./src/ui/c-tooltip/c-tooltip.demo.vue')['default']
DateTimeConverter: typeof import('./src/tools/date-time-converter/date-time-converter.vue')['default']
'DemoHome.page': typeof import('./src/ui/demo/demo-home.page.vue')['default']
DemoWrapper: typeof import('./src/ui/demo/demo-wrapper.vue')['default']
@@ -67,6 +74,7 @@ declare module '@vue/runtime-core' {
HtmlEntities: typeof import('./src/tools/html-entities/html-entities.vue')['default']
HtmlWysiwygEditor: typeof import('./src/tools/html-wysiwyg-editor/html-wysiwyg-editor.vue')['default']
HttpStatusCodes: typeof import('./src/tools/http-status-codes/http-status-codes.vue')['default']
IbanValidatorAndParser: typeof import('./src/tools/iban-validator-and-parser/iban-validator-and-parser.vue')['default']
'IconMdi:brushVariant': typeof import('~icons/mdi/brush-variant')['default']
'IconMdi:contentCopy': typeof import('~icons/mdi/content-copy')['default']
'IconMdi:kettleSteamOutline': typeof import('~icons/mdi/kettle-steam-outline')['default']
@@ -166,6 +174,7 @@ declare module '@vue/runtime-core' {
SvgPlaceholderGenerator: typeof import('./src/tools/svg-placeholder-generator/svg-placeholder-generator.vue')['default']
TemperatureConverter: typeof import('./src/tools/temperature-converter/temperature-converter.vue')['default']
TextareaCopyable: typeof import('./src/components/TextareaCopyable.vue')['default']
TextDiff: typeof import('./src/tools/text-diff/text-diff.vue')['default']
TextStatistics: typeof import('./src/tools/text-statistics/text-statistics.vue')['default']
TextToNatoAlphabet: typeof import('./src/tools/text-to-nato-alphabet/text-to-nato-alphabet.vue')['default']
TokenDisplay: typeof import('./src/tools/otp-code-generator-and-validator/token-display.vue')['default']
@@ -179,6 +188,7 @@ declare module '@vue/runtime-core' {
UserAgentParser: typeof import('./src/tools/user-agent-parser/user-agent-parser.vue')['default']
UserAgentResultCards: typeof import('./src/tools/user-agent-parser/user-agent-result-cards.vue')['default']
UuidGenerator: typeof import('./src/tools/uuid-generator/uuid-generator.vue')['default']
WifiQrCodeGenerator: typeof import('./src/tools/wifi-qr-code-generator/wifi-qr-code-generator.vue')['default']
XmlFormatter: typeof import('./src/tools/xml-formatter/xml-formatter.vue')['default']
YamlToJson: typeof import('./src/tools/yaml-to-json-converter/yaml-to-json.vue')['default']
YamlToToml: typeof import('./src/tools/yaml-to-toml/yaml-to-toml.vue')['default']

View File

@@ -1,4 +1,5 @@
home:
categories:
newestTools: Newest tools
allTheTools: All the tools
yourFavoriteTools: Your favorite tools

View File

@@ -21,11 +21,12 @@
},
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"build": "vue-tsc --noEmit && NODE_OPTIONS=--max_old_space_size=4096 vite build",
"preview": "vite preview --port 5050",
"test": "npm run test:unit",
"test:unit": "vitest --environment jsdom",
"test:e2e": "playwright test",
"test:e2e:dev": "BASE_URL=http://localhost:5173 NO_WEB_SERVER=true playwright test",
"coverage": "vitest run --coverage",
"typecheck": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint src --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --ignore-path .gitignore",
@@ -58,12 +59,14 @@
"fuse.js": "^6.6.2",
"highlight.js": "^11.7.0",
"iarna-toml-esm": "^3.0.5",
"ibantools": "^4.3.3",
"json5": "^2.2.3",
"jwt-decode": "^3.1.2",
"libphonenumber-js": "^1.10.28",
"lodash": "^4.17.21",
"mathjs": "^11.9.1",
"mime-types": "^2.1.35",
"monaco-editor": "^0.41.0",
"naive-ui": "^2.34.3",
"netmask": "^2.0.2",
"node-forge": "^1.3.1",
@@ -72,7 +75,7 @@
"plausible-tracker": "^0.3.8",
"qrcode": "^1.5.1",
"randombytes": "^2.1.0",
"sql-formatter": "^12.0.0",
"sql-formatter": "^13.0.0",
"ua-parser-js": "^1.0.35",
"unicode-emoji-json": "^0.4.0",
"unplugin-auto-import": "^0.16.4",
@@ -85,9 +88,9 @@
"yaml": "^2.2.1"
},
"devDependencies": {
"@antfu/eslint-config": "^0.40.2",
"@antfu/eslint-config": "^0.41.0",
"@iconify-json/mdi": "^1.1.50",
"@intlify/unplugin-vue-i18n": "^0.12.3",
"@intlify/unplugin-vue-i18n": "^0.13.0",
"@playwright/test": "^1.32.3",
"@rushstack/eslint-patch": "^1.2.0",
"@tsconfig/node18": "^18.2.0",
@@ -116,10 +119,10 @@
"jsdom": "^22.0.0",
"less": "^4.1.3",
"prettier": "^3.0.0",
"typescript": "~5.1.0",
"typescript": "~5.2.0",
"unocss": "^0.55.0",
"unocss-preset-scrollbar": "^0.2.1",
"unplugin-icons": "^0.16.5",
"unplugin-icons": "^0.17.0",
"unplugin-vue-components": "^0.25.0",
"vite": "^4.4.9",
"vite-plugin-pwa": "^0.16.0",

View File

@@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test';
const isCI = !!process.env.CI;
const baseUrl = process.env.BASE_URL || 'http://localhost:5050';
const useWebServer = process.env.NO_WEB_SERVER !== 'true';
/**
* See https://playwright.dev/docs/test-configuration.
@@ -52,13 +53,13 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
...(isCI
? {}
: {
webServer: {
command: 'npm run preview',
url: 'http://127.0.0.1:5050',
reuseExistingServer: true,
},
}),
...(useWebServer
&& {
webServer: {
command: 'npm run preview',
url: 'http://127.0.0.1:5050',
reuseExistingServer: !isCI,
},
}
),
});

416
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,16 @@
<script setup lang="ts">
import { useThemeVars } from 'naive-ui';
import FavoriteButton from './FavoriteButton.vue';
import { useAppTheme } from '@/ui/theme/themes';
import type { Tool } from '@/tools/tools.types';
const props = defineProps<{ tool: Tool & { category: string } }>();
const { tool } = toRefs(props);
const theme = useThemeVars();
const appTheme = useAppTheme();
</script>
<template>
<router-link :to="tool.path">
<c-card class="tool-card">
<c-card class="tool-card" shadow>
<div flex items-center justify-between>
<n-icon class="icon" size="40" :component="tool.icon" />
<div flex items-center gap-8px>
@@ -32,15 +29,14 @@ const appTheme = useAppTheme();
<FavoriteButton :tool="tool" />
</div>
</div>
<n-h3 class="title">
<n-ellipsis>{{ tool.name }}</n-ellipsis>
<n-h3 class="title" truncate>
{{ tool.name }}
</n-h3>
<div class="description">
<n-ellipsis :line-clamp="2" :tooltip="false" style="min-height: 44.78px">
<div line-clamp-2 style="min-height: 44.78px">
{{ tool.description }}
<br>&nbsp;
</n-ellipsis>
</div>
</div>
</c-card>
</router-link>
@@ -52,16 +48,14 @@ a {
}
.tool-card {
transition: border-color ease 0.5s;
border-width: 2px !important;
color: transparent;
&:hover {
border-color: v-bind('appTheme.primary.colorHover');
}
position: relative;
border-radius: 15px;
border: none;
.icon {
opacity: 0.6;
opacity: 0.4;
color: v-bind('theme.textColorBase');
}
@@ -74,5 +68,46 @@ a {
color: v-bind('theme.textColorBase');
margin: 5px 0;
}
&::after {
--mask-radius: 20em;
border-radius: 15px;
content: '';
position: absolute;
inset: 0;
pointer-events: none;
user-select: none;
display: block;
height: calc(100% - 4px) ;
width: calc(100% - 4px) ;
background: #18a05818;
top: 0;
left: 0;
opacity: 1;
border: 2px solid transparent;
transition: all 0.2s ease-in-out;
-webkit-mask: radial-gradient(
var(--mask-radius) var(--mask-radius) at 45px 45px,
#000 1%,
transparent 50%
);
mask: radial-gradient(
var(--mask-radius) var(--mask-radius) at 45px 45px,
#000 1%,
transparent 50%
);
will-change: mask;
}
&:hover {
&::after {
--mask-radius: 50em;
border: 2px solid #18a058;
}
}
}
</style>

View File

@@ -1,99 +1,39 @@
<script setup lang="ts">
import { Heart } from '@vicons/tabler';
import { useHead } from '@vueuse/head';
import ColoredCard from '../components/ColoredCard.vue';
import ToolCard from '../components/ToolCard.vue';
import { useToolStore } from '@/tools/tools.store';
import { config } from '@/config';
const toolStore = useToolStore();
useHead({ title: 'IT Tools - Handy online tools for developers' });
const { t } = useI18n();
</script>
<template>
<div class="home-page">
<div class="grid-wrapper">
<n-grid v-if="config.showBanner" x-gap="12" y-gap="12" cols="1 400:2 800:3 1200:4 2000:8">
<n-gi>
<ColoredCard title="You like it-tools?" :icon="Heart">
Give us a star on
<a
href="https://github.com/CorentinTh/it-tools"
rel="noopener"
target="_blank"
aria-label="IT-Tools' GitHub repository"
>GitHub</a>
or follow us on
<a
href="https://twitter.com/ittoolsdottech"
rel="noopener"
target="_blank"
aria-label="IT-Tools' Twitter account"
>Twitter</a>! Thank you
<n-icon :component="Heart" />
</ColoredCard>
</n-gi>
</n-grid>
<div class="home-page" m-auto mt-50px max-w-1800px>
<div my-8 />
<transition name="height">
<div v-if="toolStore.favoriteTools.length > 0">
<n-h3>Your favorite tools</n-h3>
<n-grid x-gap="12" y-gap="12" cols="1 400:2 800:3 1200:4 2000:8">
<n-gi v-for="tool in toolStore.favoriteTools" :key="tool.name">
<ToolCard :tool="tool" />
</n-gi>
</n-grid>
</div>
</transition>
<div v-if="toolStore.newTools.length > 0">
<n-h3>{{ t('home.categories.newestTools', 'Newest tools') }}</n-h3>
<n-grid x-gap="12" y-gap="12" cols="1 400:2 800:3 1200:4 2000:8">
<n-gi v-for="tool in toolStore.newTools" :key="tool.name">
<ToolCard :tool="tool" />
</n-gi>
</n-grid>
<div v-if="toolStore.favoriteTools.length > 0">
<div mb-2 mt-6 text-lg font-semibold>
{{ $t('home.categories.yourFavoriteTools') }}
</div>
<div grid-cols="1 sm:2 md:2 lg:3 xl:4" grid gap-12px>
<tool-card v-for="tool in toolStore.favoriteTools" :key="tool.name" :tool="tool" />
</div>
</div>
<n-h3>All the tools</n-h3>
<n-grid x-gap="12" y-gap="12" cols="1 400:2 800:3 1200:4 2000:8">
<n-gi v-for="tool in toolStore.tools" :key="tool.name">
<transition>
<ToolCard :tool="tool" />
</transition>
</n-gi>
</n-grid>
<div v-if="toolStore.newTools.length > 0">
<div mb-2 mt-6 text-lg font-semibold>
{{ $t('home.categories.newestTools') }}
</div>
<div grid-cols="1 sm:2 md:2 lg:3 xl:4" grid gap-12px>
<tool-card v-for="tool in toolStore.newTools" :key="tool.name" :tool="tool" />
</div>
</div>
<div mb-2 mt-6 text-lg font-semibold>
{{ $t('home.categories.allTheTools') }}
</div>
<div grid-cols="1 sm:2 md:2 lg:3 xl:4" grid gap-12px>
<tool-card v-for="tool in toolStore.tools" :key="tool.name" :tool="tool" />
</div>
</div>
</template>
<style scoped lang="less">
.home-page {
padding-top: 50px;
}
.n-h3 {
margin-bottom: 10px;
}
::v-deep(.n-grid) {
margin-bottom: 30px;
}
.height-enter-active,
.height-leave-active {
transition: all 0.5s ease-in-out;
overflow: hidden;
max-height: 500px;
}
.height-enter-from,
.height-leave-to {
max-height: 42px;
overflow: hidden;
opacity: 0;
margin-bottom: 0;
}
</style>

View File

@@ -1,6 +1,22 @@
import type { App } from 'vue';
import type { Plugin } from 'vue';
import { createI18n } from 'vue-i18n';
import messages from '@intlify/unplugin-vue-i18n/messages';
import baseMessages from '@intlify/unplugin-vue-i18n/messages';
import _ from 'lodash';
import { parse as parseYaml } from 'yaml';
const i18nFiles = import.meta.glob('../tools/*/locales/**.yml', { as: 'raw' });
const messagesByTools = await Promise.all(_.map(i18nFiles, async (fileDescriptor, path) => {
const [, locale] = path.match(/\.\/tools\/.*?\/locales\/(.*)\.ya?ml$/i) ?? [];
const content = parseYaml(await fileDescriptor());
return { [locale]: content };
}));
const messages = _.merge(
baseMessages,
_.merge({}, ...messagesByTools),
);
const i18n = createI18n({
legacy: false,
@@ -8,8 +24,8 @@ const i18n = createI18n({
messages,
});
export const i18nPlugin = {
install: (app: App) => {
export const i18nPlugin: Plugin = {
install: (app) => {
app.use(i18n);
},
};

View File

@@ -45,7 +45,7 @@ const compareMatch = computed(() => compareSync(compareString.value, compareHash
<c-input-text v-model:value="compareString" placeholder="Your string to compare..." raw-text />
</n-form-item>
<n-form-item label="Your hash: " label-placement="left">
<c-input-text v-model:value="compareHash" placeholder="Your hahs to compare..." raw-text />
<c-input-text v-model:value="compareHash" placeholder="Your hash to compare..." raw-text />
</n-form-item>
<n-form-item label="Do they match ? " label-placement="left" :show-feedback="false">
<div class="compare-result" :class="{ positive: compareMatch }">

View File

@@ -146,7 +146,7 @@ function formatDateUsingFormatter(formatter: (date: Date) => string, date?: Date
<c-input-text
v-model:value="inputDate"
autofocus
placeholder="Put you date string here..."
placeholder="Put your date string here..."
clearable
test-id="date-time-converter-input"
:validation="validation"

View File

@@ -0,0 +1,52 @@
import { type Page, expect, test } from '@playwright/test';
async function extractIbanInfo({ page }: { page: Page }) {
const itemsLines = await page
.locator('.c-key-value-list__item').all();
return await Promise.all(
itemsLines.map(async item => [
(await item.locator('.c-key-value-list__key').textContent() ?? '').trim(),
(await item.locator('.c-key-value-list__value').textContent() ?? '').trim(),
]),
);
}
test.describe('Tool - Iban validator and parser', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/iban-validator-and-parser');
});
test('Has correct title', async ({ page }) => {
await expect(page).toHaveTitle('IBAN validator and parser - IT Tools');
});
test('iban info are extracted from a valid iban', async ({ page }) => {
await page.getByTestId('iban-input').fill('DE89370400440532013000');
const ibanInfo = await extractIbanInfo({ page });
expect(ibanInfo).toEqual([
['Is IBAN valid ?', 'Yes'],
['Is IBAN a QR-IBAN ?', 'No'],
['Country code', 'DE'],
['BBAN', '370400440532013000'],
['IBAN friendly format', 'DE89 3704 0044 0532 0130 00'],
]);
});
test('invalid iban errors are displayed', async ({ page }) => {
await page.getByTestId('iban-input').fill('FR7630006060011234567890189');
const ibanInfo = await extractIbanInfo({ page });
expect(ibanInfo).toEqual([
['Is IBAN valid ?', 'No'],
['IBAN errors', 'Wrong account bank branch checksum Wrong IBAN checksum'],
['Is IBAN a QR-IBAN ?', 'No'],
['Country code', 'N/A'],
['BBAN', 'N/A'],
['IBAN friendly format', 'FR76 3000 6060 0112 3456 7890 189'],
]);
});
});

View File

@@ -0,0 +1,18 @@
import { ValidationErrorsIBAN } from 'ibantools';
export { getFriendlyErrors };
const ibanErrorToMessage = {
[ValidationErrorsIBAN.NoIBANProvided]: 'No IBAN provided',
[ValidationErrorsIBAN.NoIBANCountry]: 'No IBAN country',
[ValidationErrorsIBAN.WrongBBANLength]: 'Wrong BBAN length',
[ValidationErrorsIBAN.WrongBBANFormat]: 'Wrong BBAN format',
[ValidationErrorsIBAN.ChecksumNotNumber]: 'Checksum is not a number',
[ValidationErrorsIBAN.WrongIBANChecksum]: 'Wrong IBAN checksum',
[ValidationErrorsIBAN.WrongAccountBankBranchChecksum]: 'Wrong account bank branch checksum',
[ValidationErrorsIBAN.QRIBANNotAllowed]: 'QR-IBAN not allowed',
};
function getFriendlyErrors(errorCodes: ValidationErrorsIBAN[]) {
return errorCodes.map(errorCode => ibanErrorToMessage[errorCode]).filter(Boolean);
}

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import { extractIBAN, friendlyFormatIBAN, isQRIBAN, validateIBAN } from 'ibantools';
import { getFriendlyErrors } from './iban-validator-and-parser.service';
import type { CKeyValueListItems } from '@/ui/c-key-value-list/c-key-value-list.types';
const rawIban = ref('');
const ibanInfo = computed<CKeyValueListItems>(() => {
const iban = rawIban.value.toUpperCase().replace(/\s/g, '').replace(/-/g, '');
if (iban === '') {
return [];
}
const { valid: isIbanValid, errorCodes } = validateIBAN(iban);
const { countryCode, bban } = extractIBAN(iban);
const errors = getFriendlyErrors(errorCodes);
return [
{
label: 'Is IBAN valid ?',
value: isIbanValid,
showCopyButton: false,
},
{
label: 'IBAN errors',
value: errors.length === 0 ? undefined : errors,
hideOnNil: true,
showCopyButton: false,
},
{
label: 'Is IBAN a QR-IBAN ?',
value: isQRIBAN(iban),
showCopyButton: false,
},
{
label: 'Country code',
value: countryCode,
},
{
label: 'BBAN',
value: bban,
},
{
label: 'IBAN friendly format',
value: friendlyFormatIBAN(iban),
},
];
});
const ibanExamples = [
'FR7630006000011234567890189',
'DE89370400440532013000',
'GB29NWBK60161331926819',
];
</script>
<template>
<div>
<c-input-text v-model:value="rawIban" placeholder="Enter an IBAN to check for validity..." test-id="iban-input" />
<c-key-value-list :items="ibanInfo" my-5 data-test-id="iban-info" />
<c-card title="Valid IBAN examples">
<div v-for="iban in ibanExamples" :key="iban">
<c-text-copyable :value="iban" font-mono :displayed-value="friendlyFormatIBAN(iban)" />
</div>
</c-card>
</div>
</template>

View File

@@ -0,0 +1,12 @@
import { defineTool } from '../tool';
import Bank from '~icons/mdi/bank';
export const tool = defineTool({
name: 'IBAN validator and parser',
path: '/iban-validator-and-parser',
description: 'Validate and parse IBAN numbers. Check if IBAN is valid and get the country, BBAN, if it is a QR-IBAN and the IBAN friendly format.',
keywords: ['iban', 'validator', 'and', 'parser', 'bic', 'bank'],
component: () => import('./iban-validator-and-parser.vue'),
icon: Bank,
createdAt: new Date('2023-08-26'),
});

View File

@@ -1,7 +1,9 @@
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 ibanValidatorAndParser } from './iban-validator-and-parser';
import { tool as stringObfuscator } from './string-obfuscator';
import { tool as textDiff } from './text-diff';
import { tool as emojiPicker } from './emoji-picker';
import { tool as passwordStrengthAnalyser } from './password-strength-analyser';
import { tool as yamlToToml } from './yaml-to-toml';
@@ -54,6 +56,7 @@ import { tool as metaTagGenerator } from './meta-tag-generator';
import { tool as mimeTypes } from './mime-types';
import { tool as otpCodeGeneratorAndValidator } from './otp-code-generator-and-validator';
import { tool as qrCodeGenerator } from './qr-code-generator';
import { tool as wifiQrCodeGenerator } from './wifi-qr-code-generator';
import { tool as randomPortGenerator } from './random-port-generator';
import { tool as romanNumeralConverter } from './roman-numeral-converter';
import { tool as sqlPrettify } from './sql-prettify';
@@ -115,7 +118,7 @@ export const toolsByCategory: ToolCategory[] = [
},
{
name: 'Images and videos',
components: [qrCodeGenerator, svgPlaceholderGenerator, cameraRecorder],
components: [qrCodeGenerator, wifiQrCodeGenerator, svgPlaceholderGenerator, cameraRecorder],
},
{
name: 'Development',
@@ -146,11 +149,11 @@ export const toolsByCategory: ToolCategory[] = [
},
{
name: 'Text',
components: [loremIpsumGenerator, textStatistics, emojiPicker, stringObfuscator],
components: [loremIpsumGenerator, textStatistics, emojiPicker, stringObfuscator, textDiff],
},
{
name: 'Data',
components: [phoneParserAndFormatter],
components: [phoneParserAndFormatter, ibanValidatorAndParser],
},
];

View File

@@ -14,6 +14,6 @@ test.describe('Tool - Password strength analyser', () => {
const crackDuration = await page.getByTestId('crack-duration').textContent();
expect(crackDuration).toEqual('15,091 milleniums, 3 centurys');
expect(crackDuration).toEqual('15,091 millennia, 3 centuries');
});
});

View File

@@ -19,20 +19,20 @@ function getHumanFriendlyDuration({ seconds }: { seconds: number }) {
}
const timeUnits = [
{ unit: 'millenium', secondsInUnit: 31536000000, format: prettifyExponentialNotation },
{ unit: 'century', secondsInUnit: 3153600000 },
{ unit: 'decade', secondsInUnit: 315360000 },
{ unit: 'year', secondsInUnit: 31536000 },
{ unit: 'month', secondsInUnit: 2592000 },
{ unit: 'week', secondsInUnit: 604800 },
{ unit: 'day', secondsInUnit: 86400 },
{ unit: 'hour', secondsInUnit: 3600 },
{ unit: 'minute', secondsInUnit: 60 },
{ unit: 'second', secondsInUnit: 1 },
{ unit: 'millenium', secondsInUnit: 31536000000, format: prettifyExponentialNotation, plural: 'millennia' },
{ unit: 'century', secondsInUnit: 3153600000, plural: 'centuries' },
{ unit: 'decade', secondsInUnit: 315360000, plural: 'decades' },
{ unit: 'year', secondsInUnit: 31536000, plural: 'years' },
{ unit: 'month', secondsInUnit: 2592000, plural: 'months' },
{ unit: 'week', secondsInUnit: 604800, plural: 'weeks' },
{ unit: 'day', secondsInUnit: 86400, plural: 'days' },
{ unit: 'hour', secondsInUnit: 3600, plural: 'hours' },
{ unit: 'minute', secondsInUnit: 60, plural: 'minutes' },
{ unit: 'second', secondsInUnit: 1, plural: 'seconds' },
];
return _.chain(timeUnits)
.map(({ unit, secondsInUnit, format = _.identity }) => {
.map(({ unit, secondsInUnit, plural, format = _.identity }) => {
const quantity = Math.floor(seconds / secondsInUnit);
seconds %= secondsInUnit;
@@ -41,7 +41,7 @@ function getHumanFriendlyDuration({ seconds }: { seconds: number }) {
}
const formattedQuantity = format(quantity);
return `${formattedQuantity} ${unit}${quantity > 1 ? 's' : ''}`;
return `${formattedQuantity} ${quantity > 1 ? plural : unit}`;
})
.compact()
.take(2)

View File

@@ -0,0 +1,12 @@
import { FileDiff } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Text diff',
path: '/text-diff',
description: 'Compare two texts and see the differences between them.',
keywords: ['text', 'diff', 'compare', 'string', 'text diff', 'code'],
component: () => import('./text-diff.vue'),
icon: FileDiff,
createdAt: new Date('2023-08-16'),
});

View File

@@ -0,0 +1,5 @@
<template>
<c-card w-full important:flex-1 important:pa-0>
<c-diff-editor />
</c-card>
</template>

View File

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

View File

@@ -0,0 +1,13 @@
import { Qrcode } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'WiFi QR Code generator',
path: '/wifi-qrcode-generator',
description:
'Generate and download QR-codes for quick connections to WiFi networks.',
keywords: ['qr', 'code', 'generator', 'square', 'color', 'link', 'low', 'medium', 'quartile', 'high', 'transparent', 'wifi'],
component: () => import('./wifi-qr-code-generator.vue'),
icon: Qrcode,
createdAt: new Date('2023-09-06'),
});

View File

@@ -0,0 +1,146 @@
import { type MaybeRef, get } from '@vueuse/core';
import QRCode, { type QRCodeToDataURLOptions } from 'qrcode';
import { isRef, ref, watch } from 'vue';
export const wifiEncryptions = ['WEP', 'WPA', 'nopass', 'WPA2-EAP'] as const;
export type WifiEncryption = typeof wifiEncryptions[number];
// @see https://en.wikipedia.org/wiki/Extensible_Authentication_Protocol
// for a list of available EAP methods. There are a lot (40!) of them.
export const EAPMethods = [
'MD5',
'POTP',
'GTC',
'TLS',
'IKEv2',
'SIM',
'AKA',
'AKA\'',
'TTLS',
'PWD',
'LEAP',
'PSK',
'FAST',
'TEAP',
'EKE',
'NOOB',
'PEAP',
] as const;
export type EAPMethod = typeof EAPMethods[number];
export const EAPPhase2Methods = [
'None',
'MSCHAPV2',
] as const;
export type EAPPhase2Method = typeof EAPPhase2Methods[number];
interface IWifiQRCodeOptions {
ssid: MaybeRef<string>
password: MaybeRef<string>
eapMethod: MaybeRef<EAPMethod>
isHiddenSSID: MaybeRef<boolean>
eapAnonymous: MaybeRef<boolean>
eapIdentity: MaybeRef<string>
eapPhase2Method: MaybeRef<EAPPhase2Method>
color: { foreground: MaybeRef<string>; background: MaybeRef<string> }
options?: QRCodeToDataURLOptions
}
interface GetQrCodeTextOptions {
ssid: string
password: string
encryption: WifiEncryption
eapMethod: EAPMethod
isHiddenSSID: boolean
eapAnonymous: boolean
eapIdentity: string
eapPhase2Method: EAPPhase2Method
}
function escapeString(str: string) {
// replaces \, ;, ,, " and : with the same character preceded by a backslash
return str.replace(/([\\;,:"])/g, '\\$1');
}
function getQrCodeText(options: GetQrCodeTextOptions): string | null {
const { ssid, password, encryption, eapMethod, isHiddenSSID, eapAnonymous, eapIdentity, eapPhase2Method } = options;
if (!ssid) {
return null;
}
if (encryption === 'nopass') {
return `WIFI:S:${escapeString(ssid)};;`; // type can be omitted in that case, and password is not needed, makes the QR Code smaller
}
if (encryption !== 'WPA2-EAP' && password) {
// EAP has a lot of options, so we'll handle it separately
// WPA and WEP are pretty simple though.
return `WIFI:S:${escapeString(ssid)};T:${encryption};P:${escapeString(password)};${isHiddenSSID ? 'H:true' : ''};`;
}
if (encryption === 'WPA2-EAP' && password && eapMethod) {
// WPA2-EAP string is a lot more complex, first off, we drop the text if there is no identity, and it's not anonymous.
if (!eapIdentity && !eapAnonymous) {
return null;
}
// From reading, I could only find that a phase 2 is required for the PEAP method, I may be wrong though, I didn't read the whole spec.
if (eapMethod === 'PEAP' && !eapPhase2Method) {
return null;
}
// The string is built in the following order:
// 1. SSID
// 2. Authentication type
// 3. Password
// 4. EAP method
// 5. EAP phase 2 method
// 6. Identity or anonymous if checked
// 7. Hidden SSID if checked
const identity = eapAnonymous ? 'A:anon' : `I:${escapeString(eapIdentity)}`;
const phase2 = eapPhase2Method !== 'None' ? `PH2:${eapPhase2Method};` : '';
return `WIFI:S:${escapeString(ssid)};T:WPA2-EAP;P:${escapeString(password)};E:${eapMethod};${phase2}${identity};${isHiddenSSID ? 'H:true' : ''};`;
}
return null;
}
export function useWifiQRCode({
ssid,
password,
eapMethod,
isHiddenSSID,
eapAnonymous,
eapIdentity,
eapPhase2Method,
color: { background, foreground },
options,
}: IWifiQRCodeOptions) {
const qrcode = ref('');
const encryption = ref<WifiEncryption>('WPA');
watch(
[ssid, password, encryption, eapMethod, isHiddenSSID, eapAnonymous, eapIdentity, eapPhase2Method, background, foreground].filter(isRef),
async () => {
// @see https://github.com/zxing/zxing/wiki/Barcode-Contents#wi-fi-network-config-android-ios-11
// This is the full spec, there's quite a bit of logic to generate the string embeddedin the QR code.
const text = getQrCodeText({
ssid: get(ssid),
password: get(password),
encryption: get(encryption),
eapMethod: get(eapMethod),
isHiddenSSID: get(isHiddenSSID),
eapAnonymous: get(eapAnonymous),
eapIdentity: get(eapIdentity),
eapPhase2Method: get(eapPhase2Method),
});
if (text) {
qrcode.value = await QRCode.toDataURL(get(text).trim(), {
color: {
dark: get(foreground),
light: get(background),
...options?.color,
},
errorCorrectionLevel: 'M',
...options,
});
}
},
{ immediate: true },
);
return { qrcode, encryption };
}

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
import {
EAPMethods,
EAPPhase2Methods,
useWifiQRCode,
} from './useQRCode';
import { useDownloadFileFromBase64 } from '@/composable/downloadBase64';
const foreground = ref('#000000ff');
const background = ref('#ffffffff');
const ssid = ref();
const password = ref();
const eapMethod = ref();
const isHiddenSSID = ref(false);
const eapAnonymous = ref(false);
const eapIdentity = ref();
const eapPhase2Method = ref();
const { qrcode, encryption } = useWifiQRCode({
ssid,
password,
eapMethod,
isHiddenSSID,
eapAnonymous,
eapIdentity,
eapPhase2Method,
color: {
background,
foreground,
},
options: { width: 1024 },
});
const { download } = useDownloadFileFromBase64({ source: qrcode, filename: 'qr-code.png' });
</script>
<template>
<c-card>
<div grid grid-cols-1 gap-12>
<div>
<c-select
v-model:value="encryption"
mb-4
label="Encryption method"
default-value="WPA"
label-position="left"
label-width="130px"
label-align="right"
:options="[
{
label: 'No password',
value: 'nopass',
},
{
label: 'WPA/WPA2',
value: 'WPA',
},
{
label: 'WEP',
value: 'WEP',
},
{
label: 'WPA2-EAP',
value: 'WPA2-EAP',
},
]"
/>
<div class="mb-6 flex flex-row items-center gap-2">
<c-input-text
v-model:value="ssid"
label-position="left"
label-width="130px"
label-align="right"
label="SSID:"
rows="1"
autosize
placeholder="Your WiFi SSID..."
mb-6
/>
<n-checkbox v-model:checked="isHiddenSSID">
Hidden SSID
</n-checkbox>
</div>
<c-input-text
v-if="encryption !== 'nopass'"
v-model:value="password"
label-position="left"
label-width="130px"
label-align="right"
label="Password:"
rows="1"
autosize
type="password"
placeholder="Your WiFi Password..."
mb-6
/>
<c-select
v-if="encryption === 'WPA2-EAP'"
v-model:value="eapMethod"
label="EAP method"
label-position="left"
label-width="130px"
label-align="right"
:options="EAPMethods.map((method) => ({ label: method, value: method }))"
searchable mb-4
/>
<div v-if="encryption === 'WPA2-EAP'" class="mb-6 flex flex-row items-center gap-2">
<c-input-text
v-model:value="eapIdentity"
label-position="left"
label-width="130px"
label-align="right"
label="Identity:"
rows="1"
autosize
placeholder="Your EAP Identity..."
mb-6
/>
<n-checkbox v-model:checked="eapAnonymous">
Anonymous?
</n-checkbox>
</div>
<c-select
v-if="encryption === 'WPA2-EAP'"
v-model:value="eapPhase2Method"
label="EAP Phase 2 method"
label-position="left"
label-width="130px"
label-align="right"
:options="EAPPhase2Methods.map((method) => ({ label: method, value: method }))"
searchable mb-4
/>
<n-form label-width="130" label-placement="left">
<n-form-item label="Foreground color:">
<n-color-picker v-model:value="foreground" :modes="['hex']" />
</n-form-item>
<n-form-item label="Background color:">
<n-color-picker v-model:value="background" :modes="['hex']" />
</n-form-item>
</n-form>
</div>
<div v-if="qrcode">
<div flex flex-col items-center gap-3>
<img alt="wifi-qrcode" :src="qrcode" width="200">
<c-button @click="download">
Download qr-code
</c-button>
</div>
</div>
</div>
</c-card>
</template>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import * as monaco from 'monaco-editor';
import { useStyleStore } from '@/stores/style.store';
const props = withDefaults(defineProps<{ options?: monaco.editor.IDiffEditorOptions }>(), { options: () => ({}) });
const { options } = toRefs(props);
const editorContainer = ref<HTMLElement | null>(null);
let editor: monaco.editor.IStandaloneDiffEditor | null = null;
monaco.editor.defineTheme('it-tools-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
},
});
monaco.editor.defineTheme('it-tools-light', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': '#00000000',
},
});
const styleStore = useStyleStore();
watch(
() => styleStore.isDarkTheme,
isDarkTheme => monaco.editor.setTheme(isDarkTheme ? 'it-tools-dark' : 'it-tools-light'),
{ immediate: true },
);
watch(
() => options.value,
options => editor?.updateOptions(options),
{ immediate: true, deep: true },
);
useResizeObserver(editorContainer, () => {
editor?.layout();
});
onMounted(() => {
if (!editorContainer.value) {
return;
}
editor = monaco.editor.createDiffEditor(editorContainer.value, {
originalEditable: true,
minimap: {
enabled: false,
},
});
editor.setModel({
original: monaco.editor.createModel('original text', 'txt'),
modified: monaco.editor.createModel('modified text', 'txt'),
});
});
</script>
<template>
<div ref="editorContainer" h-600px />
</template>

View File

@@ -0,0 +1,27 @@
<script lang="ts" setup>
import _ from 'lodash';
import type { CKeyValueListItem } from './c-key-value-list.types';
const props = defineProps<{ item: CKeyValueListItem }>();
const { item } = toRefs(props);
</script>
<template>
<div v-if="_.isArray(item.value)">
<div v-for="value in item.value" :key="value">
<c-text-copyable :value="value" :show-icon="item.showCopyButton ?? true" />
</div>
</div>
<div v-else-if="_.isBoolean(item.value)">
<c-text-copyable :value="item.value ? 'true' : 'false'" :displayed-value="item.value ? 'Yes' : 'No'" :show-icon="item.showCopyButton ?? true" />
</div>
<div v-else-if="_.isNumber(item.value)" font-mono>
<c-text-copyable :value="String(item.value)" :show-icon="item.showCopyButton ?? true" />
</div>
<div v-else-if="_.isNil(item.value) || item.value === ''" op-70>
{{ item.placeholder ?? 'N/A' }}
</div>
<div v-else>
<c-text-copyable :value="item.value" :show-icon="item.showCopyButton ?? true" />
</div>
</template>

View File

@@ -0,0 +1,9 @@
export interface CKeyValueListItem {
label: string
value: string | string[] | number | boolean | undefined | null
hideOnNil?: boolean
placeholder?: string
showCopyButton?: boolean
}
export type CKeyValueListItems = CKeyValueListItem[];

View File

@@ -0,0 +1,21 @@
<script lang="ts" setup>
import _ from 'lodash';
import type { CKeyValueListItems } from './c-key-value-list.types';
const props = withDefaults(defineProps<{ items?: CKeyValueListItems }>(), { items: () => [] });
const { items } = toRefs(props);
const formattedItems = computed(() => items.value.filter(item => !_.isNil(item.value) || !item.hideOnNil));
</script>
<template>
<div my-5>
<div v-for="item in formattedItems" :key="item.label" flex gap-2 py-1 class="c-key-value-list__item">
<div flex-basis-180px text-right font-bold class="c-key-value-list__key">
{{ item.label }}
</div>
<c-key-value-list-item :item="item" class="c-key-value-list__value" />
</div>
</div>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<c-text-copyable value="value" displayed-value="displayedValue" />
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { useCopy } from '@/composable/copy';
const props = withDefaults(defineProps<{ value?: string; displayedValue?: string; showIcon?: boolean }>(), { value: '', displayedValue: undefined, showIcon: true });
const { value, displayedValue, showIcon } = toRefs(props);
const { copy, isJustCopied } = useCopy({ source: value, createToast: false });
</script>
<template>
<c-tooltip :tooltip="isJustCopied ? 'Copied!' : 'Copy to clipboard'" cursor-pointer @click="copy">
<span flex items-center gap-2>
{{ displayedValue ?? value }}
<icon-mdi-content-copy v-if="showIcon" op-40 />
</span>
</c-tooltip>
</template>

View File

@@ -0,0 +1,17 @@
<template>
<div>
<c-tooltip>
Hover me
<template #tooltip>
Tooltip content
</template>
</c-tooltip>
</div>
<div mt-5>
<c-tooltip tooltip="Tooltip content">
Hover me
</c-tooltip>
</div>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{ tooltip?: string }>(), { tooltip: '' });
const { tooltip } = toRefs(props);
const targetRef = ref();
const isTargetHovered = useElementHover(targetRef);
</script>
<template>
<div class="relative" inline-block>
<div ref="targetRef">
<slot />
</div>
<div
class="absolute bottom-100% left-50% z-10 mb-5px whitespace-nowrap rounded bg-black px-12px py-6px text-sm text-white shadow-lg transition transition transition-duration-0.2s -translate-x-1/2"
:class="{
'op-0 scale-0': isTargetHovered === false,
'op-100 scale-100': isTargetHovered,
}"
>
<slot
v-if="isTargetHovered"
name="tooltip"
>
{{ tooltip }}
</slot>
</div>
</div>
</template>

View File

@@ -25,7 +25,7 @@ export default defineConfig({
runtimeOnly: true,
compositionOnly: true,
fullInstall: true,
include: [resolve(__dirname, 'locales/**'), resolve(__dirname, 'src/tools/*/locales/**')],
include: [resolve(__dirname, 'locales/**')],
}),
AutoImport({
imports: [
@@ -106,4 +106,7 @@ export default defineConfig({
test: {
exclude: [...configDefaults.exclude, '**/*.e2e.spec.ts'],
},
build: {
target: 'esnext',
},
});