Merge remote-tracking branch 'CorentinTh/main' into json-mono-font

This commit is contained in:
marvin-j97 2023-06-23 00:55:08 +02:00
commit 49cb3ca88f
113 changed files with 2823 additions and 1366 deletions

View file

@ -173,6 +173,7 @@
"useFullscreen": true, "useFullscreen": true,
"useGamepad": true, "useGamepad": true,
"useGeolocation": true, "useGeolocation": true,
"useI18n": true,
"useIdle": true, "useIdle": true,
"useImage": true, "useImage": true,
"useInfiniteScroll": true, "useInfiniteScroll": true,

View file

@ -8,6 +8,9 @@ jobs:
test: test:
timeout-minutes: 60 timeout-minutes: 60
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
shard: [1/3, 2/3, 3/3]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- run: corepack enable - run: corepack enable
@ -20,4 +23,4 @@ jobs:
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps run: pnpm exec playwright install --with-deps
- name: Run Playwright tests - name: Run Playwright tests
run: pnpm exec playwright test run: pnpm run test:e2e --shard=${{ matrix.shard }}

View file

@ -1,3 +1,3 @@
{ {
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin", "dbaeumer.vscode-eslint"] "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin", "dbaeumer.vscode-eslint", "lokalise.i18n-ally"]
} }

View file

@ -6,7 +6,7 @@ Useful tools for developer and people working in IT. [Have a look !](https://it-
Please check the [issues](https://github.com/CorentinTh/it-tools/issues) to see if some feature listed to be implemented. Please check the [issues](https://github.com/CorentinTh/it-tools/issues) to see if some feature listed to be implemented.
You have an idea of a tool? Submit a [feature request](https://github.com/CorentinTh/it-tools/issues/new?assignees=corentinth&labels=&template=feature_request.md&title=)! You have an idea of a tool? Submit a [feature request](https://github.com/CorentinTh/it-tools/issues/new/choose)!
## Self host ## Self host
@ -33,7 +33,27 @@ docker run -d --name it-tools --restart unless-stopped -p 8080:80 ghcr.io/corent
### Recommended IDE Setup ### Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). [VSCode](https://code.visualstudio.com/) with the following extensions:
- [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
{
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"i18n-ally.localesPaths": [
"locales",
"src/tools/*/locales"
],
"i18n-ally.keystyle": "nested"
}
```
### Type Support for `.vue` Imports in TS ### Type Support for `.vue` Imports in TS

2
auto-imports.d.ts vendored
View file

@ -170,6 +170,7 @@ declare global {
const useFullscreen: typeof import('@vueuse/core')['useFullscreen'] const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad'] const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation'] const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useI18n: typeof import('vue-i18n')['useI18n']
const useIdle: typeof import('@vueuse/core')['useIdle'] const useIdle: typeof import('@vueuse/core')['useIdle']
const useImage: typeof import('@vueuse/core')['useImage'] const useImage: typeof import('@vueuse/core')['useImage']
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll'] const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
@ -459,6 +460,7 @@ declare module 'vue' {
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']> readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']> readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']> readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']> readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']> readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
readonly useInfiniteScroll: UnwrapRef<typeof import('@vueuse/core')['useInfiniteScroll']> readonly useInfiniteScroll: UnwrapRef<typeof import('@vueuse/core')['useInfiniteScroll']>

11
components.d.ts vendored
View file

@ -33,9 +33,13 @@ declare module '@vue/runtime-core' {
'CInputText.demo': typeof import('./src/ui/c-input-text/c-input-text.demo.vue')['default'] 'CInputText.demo': typeof import('./src/ui/c-input-text/c-input-text.demo.vue')['default']
CLink: typeof import('./src/ui/c-link/c-link.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'] 'CLink.demo': typeof import('./src/ui/c-link/c-link.demo.vue')['default']
CModal: typeof import('./src/ui/c-modal/c-modal.vue')['default']
'CModal.demo': typeof import('./src/ui/c-modal/c-modal.demo.vue')['default']
CollapsibleToolMenu: typeof import('./src/components/CollapsibleToolMenu.vue')['default'] CollapsibleToolMenu: typeof import('./src/components/CollapsibleToolMenu.vue')['default']
ColorConverter: typeof import('./src/tools/color-converter/color-converter.vue')['default'] ColorConverter: typeof import('./src/tools/color-converter/color-converter.vue')['default']
ColoredCard: typeof import('./src/components/ColoredCard.vue')['default'] ColoredCard: typeof import('./src/components/ColoredCard.vue')['default']
CommandPalette: typeof import('./src/modules/command-palette/command-palette.vue')['default']
CommandPaletteOption: typeof import('./src/modules/command-palette/components/command-palette-option.vue')['default']
CrontabGenerator: typeof import('./src/tools/crontab-generator/crontab-generator.vue')['default'] CrontabGenerator: typeof import('./src/tools/crontab-generator/crontab-generator.vue')['default']
DateTimeConverter: typeof import('./src/tools/date-time-converter/date-time-converter.vue')['default'] DateTimeConverter: typeof import('./src/tools/date-time-converter/date-time-converter.vue')['default']
DemoWrapper: typeof import('./src/ui/demo/demo-wrapper.vue')['default'] DemoWrapper: typeof import('./src/ui/demo/demo-wrapper.vue')['default']
@ -48,7 +52,8 @@ declare module '@vue/runtime-core' {
EtaCalculator: typeof import('./src/tools/eta-calculator/eta-calculator.vue')['default'] EtaCalculator: typeof import('./src/tools/eta-calculator/eta-calculator.vue')['default']
FavoriteButton: typeof import('./src/components/FavoriteButton.vue')['default'] FavoriteButton: typeof import('./src/components/FavoriteButton.vue')['default']
FormatTransformer: typeof import('./src/components/FormatTransformer.vue')['default'] FormatTransformer: typeof import('./src/components/FormatTransformer.vue')['default']
GitMemo: typeof import('./src/tools/git-memo/git-memo.md')['default'] GitMemo: typeof import('./src/tools/git-memo/git-memo.vue')['default']
'GitMemo.content': typeof import('./src/tools/git-memo/git-memo.content.md')['default']
HashText: typeof import('./src/tools/hash-text/hash-text.vue')['default'] HashText: typeof import('./src/tools/hash-text/hash-text.vue')['default']
HmacGenerator: typeof import('./src/tools/hmac-generator/hmac-generator.vue')['default'] HmacGenerator: typeof import('./src/tools/hmac-generator/hmac-generator.vue')['default']
'Home.page': typeof import('./src/pages/Home.page.vue')['default'] 'Home.page': typeof import('./src/pages/Home.page.vue')['default']
@ -67,6 +72,7 @@ declare module '@vue/runtime-core' {
IconMdiPlay: typeof import('~icons/mdi/play')['default'] IconMdiPlay: typeof import('~icons/mdi/play')['default']
IconMdiRecord: typeof import('~icons/mdi/record')['default'] IconMdiRecord: typeof import('~icons/mdi/record')['default']
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default'] IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
IconMdiSearch: typeof import('~icons/mdi/search')['default']
IconMdiVideo: typeof import('~icons/mdi/video')['default'] IconMdiVideo: typeof import('~icons/mdi/video')['default']
InputCopyable: typeof import('./src/components/InputCopyable.vue')['default'] InputCopyable: typeof import('./src/components/InputCopyable.vue')['default']
IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default'] IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default']
@ -76,6 +82,7 @@ declare module '@vue/runtime-core' {
Ipv6UlaGenerator: typeof import('./src/tools/ipv6-ula-generator/ipv6-ula-generator.vue')['default'] Ipv6UlaGenerator: typeof import('./src/tools/ipv6-ula-generator/ipv6-ula-generator.vue')['default']
JsonDiff: typeof import('./src/tools/json-diff/json-diff.vue')['default'] JsonDiff: typeof import('./src/tools/json-diff/json-diff.vue')['default']
JsonMinify: typeof import('./src/tools/json-minify/json-minify.vue')['default'] JsonMinify: typeof import('./src/tools/json-minify/json-minify.vue')['default']
JsonToCsv: typeof import('./src/tools/json-to-csv/json-to-csv.vue')['default']
JsonToYaml: typeof import('./src/tools/json-to-yaml-converter/json-to-yaml.vue')['default'] JsonToYaml: typeof import('./src/tools/json-to-yaml-converter/json-to-yaml.vue')['default']
JsonViewer: typeof import('./src/tools/json-viewer/json-viewer.vue')['default'] JsonViewer: typeof import('./src/tools/json-viewer/json-viewer.vue')['default']
JwtParser: typeof import('./src/tools/jwt-parser/jwt-parser.vue')['default'] JwtParser: typeof import('./src/tools/jwt-parser/jwt-parser.vue')['default']
@ -133,6 +140,7 @@ declare module '@vue/runtime-core' {
NUpload: typeof import('naive-ui')['NUpload'] NUpload: typeof import('naive-ui')['NUpload']
NUploadDragger: typeof import('naive-ui')['NUploadDragger'] NUploadDragger: typeof import('naive-ui')['NUploadDragger']
OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default'] OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default']
PercentageCalculator: typeof import('./src/tools/percentage-calculator/percentage-calculator.vue')['default']
PhoneParserAndFormatter: typeof import('./src/tools/phone-parser-and-formatter/phone-parser-and-formatter.vue')['default'] PhoneParserAndFormatter: typeof import('./src/tools/phone-parser-and-formatter/phone-parser-and-formatter.vue')['default']
QrCodeGenerator: typeof import('./src/tools/qr-code-generator/qr-code-generator.vue')['default'] QrCodeGenerator: typeof import('./src/tools/qr-code-generator/qr-code-generator.vue')['default']
RandomPortGenerator: typeof import('./src/tools/random-port-generator/random-port-generator.vue')['default'] RandomPortGenerator: typeof import('./src/tools/random-port-generator/random-port-generator.vue')['default']
@ -160,6 +168,7 @@ declare module '@vue/runtime-core' {
UserAgentParser: typeof import('./src/tools/user-agent-parser/user-agent-parser.vue')['default'] UserAgentParser: typeof import('./src/tools/user-agent-parser/user-agent-parser.vue')['default']
UserAgentResultCards: typeof import('./src/tools/user-agent-parser/user-agent-result-cards.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'] UuidGenerator: typeof import('./src/tools/uuid-generator/uuid-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'] YamlToJson: typeof import('./src/tools/yaml-to-json-converter/yaml-to-json.vue')['default']
} }
} }

View file

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IT Tools - Handy online tools for developers</title> <title>IT Tools - Handy online tools for developers</title>
<meta itemprop="name" content="IT Tools - Handy online tools for developers" /> <meta itemprop="name" content="IT Tools - Handy online tools for developers" />
@ -14,13 +14,13 @@
itemprop="description" itemprop="description"
content="Collection of handy online tools for developers, with great UX. IT Tools is a free and open-source collection of handy online tools for developers & people working in IT." content="Collection of handy online tools for developers, with great UX. IT Tools is a free and open-source collection of handy online tools for developers & people working in IT."
/> />
<link rel="author" href="/humans.txt" /> <link rel="author" href="humans.txt" />
<link rel="canonical" href="https://it-tools.tech" /> <link rel="canonical" href="https://it-tools.tech" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#18a058" /> <link rel="mask-icon" href="safari-pinned-tab.svg" color="#18a058" />
<meta name="msapplication-TileColor" content="#da532c" /> <meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#ffffff" /> <meta name="theme-color" content="#ffffff" />

4
locales/en.yml Normal file
View file

@ -0,0 +1,4 @@
home:
categories:
newestTools: Newest tools

3
locales/fr.yml Normal file
View file

@ -0,0 +1,3 @@
home:
categories:
newestTools: "Nouveaux outils"

4
netlify.toml Normal file
View file

@ -0,0 +1,4 @@
[[redirects]]
from = "/*"
to = "/index.html"
status = 200

View file

@ -36,9 +36,9 @@
"@it-tools/bip39": "^0.0.4", "@it-tools/bip39": "^0.0.4",
"@it-tools/oggen": "^1.3.0", "@it-tools/oggen": "^1.3.0",
"@sindresorhus/slugify": "^2.2.0", "@sindresorhus/slugify": "^2.2.0",
"@tiptap/pm": "2.0.0-beta.220", "@tiptap/pm": "^2.0.3",
"@tiptap/starter-kit": "2.0.0-beta.220", "@tiptap/starter-kit": "^2.0.3",
"@tiptap/vue-3": "2.0.0-beta.220", "@tiptap/vue-3": "^2.0.3",
"@vicons/material": "^0.12.0", "@vicons/material": "^0.12.0",
"@vicons/tabler": "^0.12.0", "@vicons/tabler": "^0.12.0",
"@vueuse/core": "^10.1.2", "@vueuse/core": "^10.1.2",
@ -74,13 +74,16 @@
"ts-pattern": "^4.2.2", "ts-pattern": "^4.2.2",
"ua-parser-js": "^1.0.35", "ua-parser-js": "^1.0.35",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"vue": "^3.2.47", "vue": "^3.3.4",
"vue-i18n": "^9.2.2",
"vue-router": "^4.1.6", "vue-router": "^4.1.6",
"xml-formatter": "^3.3.2",
"yaml": "^2.2.1" "yaml": "^2.2.1"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^0.39.3", "@antfu/eslint-config": "^0.39.3",
"@iconify-json/mdi": "^1.1.50", "@iconify-json/mdi": "^1.1.50",
"@intlify/unplugin-vue-i18n": "^0.11.0",
"@playwright/test": "^1.32.3", "@playwright/test": "^1.32.3",
"@rushstack/eslint-patch": "^1.2.0", "@rushstack/eslint-patch": "^1.2.0",
"@types/bcryptjs": "^2.4.2", "@types/bcryptjs": "^2.4.2",
@ -102,6 +105,7 @@
"@vitejs/plugin-vue-jsx": "^1.3.10", "@vitejs/plugin-vue-jsx": "^1.3.10",
"@vue/compiler-sfc": "^3.2.47", "@vue/compiler-sfc": "^3.2.47",
"@vue/runtime-core": "^3.2.47", "@vue/runtime-core": "^3.2.47",
"@vue/runtime-dom": "^3.3.4",
"@vue/test-utils": "^2.3.2", "@vue/test-utils": "^2.3.2",
"@vue/tsconfig": "^0.1.3", "@vue/tsconfig": "^0.1.3",
"c8": "^7.13.0", "c8": "^7.13.0",

2731
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { RouterView, useRoute } from 'vue-router'; import { RouterView, useRoute } from 'vue-router';
import { NGlobalStyle, NMessageProvider, NNotificationProvider, darkTheme } from 'naive-ui'; import { NGlobalStyle, NMessageProvider, NNotificationProvider, darkTheme } from 'naive-ui';
import { darkThemeOverrides, lightThemeOverrides } from './themes'; import { darkThemeOverrides, lightThemeOverrides } from './themes';

View file

@ -2,7 +2,6 @@
import { ChevronRight } from '@vicons/tabler'; import { ChevronRight } from '@vicons/tabler';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useThemeVars } from 'naive-ui'; import { useThemeVars } from 'naive-ui';
import { computed, h, toRefs } from 'vue';
import { RouterLink, useRoute } from 'vue-router'; import { RouterLink, useRoute } from 'vue-router';
import MenuIconItem from './MenuIconItem.vue'; import MenuIconItem from './MenuIconItem.vue';
import type { Tool, ToolCategory } from '@/tools/tools.types'; import type { Tool, ToolCategory } from '@/tools/tools.types';

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { type Component, toRefs } from 'vue'; import type { Component } from 'vue';
const props = defineProps<{ icon: Component; title: string }>(); const props = defineProps<{ icon: Component; title: string }>();
const { icon, title } = toRefs(props); const { icon, title } = toRefs(props);

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { FavoriteFilled } from '@vicons/material'; import { FavoriteFilled } from '@vicons/material';
import { computed, toRefs } from 'vue';
import { useToolStore } from '@/tools/tools.store'; import { useToolStore } from '@/tools/tools.store';
import type { Tool } from '@/tools/tools.types'; import type { Tool } from '@/tools/tools.types';

View file

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClipboard, useVModel } from '@vueuse/core'; import { useClipboard, useVModel } from '@vueuse/core';
import { ref } from 'vue';
const props = defineProps<{ value: string }>(); const props = defineProps<{ value: string }>();
const emit = defineEmits(['update:value']); const emit = defineEmits(['update:value']);

View file

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useThemeVars } from 'naive-ui'; import { useThemeVars } from 'naive-ui';
import { toRefs } from 'vue';
import type { Tool } from '@/tools/tools.types'; import type { Tool } from '@/tools/tools.types';
const props = defineProps<{ tool: Tool }>(); const props = defineProps<{ tool: Tool }>();

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, toRefs } from 'vue';
import { useStyleStore } from '@/stores/style.store'; import { useStyleStore } from '@/stores/style.store';
const styleStore = useStyleStore(); const styleStore = useStyleStore();

View file

@ -1,19 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { BrandGithub, BrandTwitter, InfoCircle, Moon, Sun } from '@vicons/tabler'; import { BrandGithub, BrandTwitter, InfoCircle, Moon, Sun } from '@vicons/tabler';
import { toRefs } from 'vue';
import { useStyleStore } from '@/stores/style.store'; import { useStyleStore } from '@/stores/style.store';
import { useThemeStore } from '@/ui/theme/theme.store';
const styleStore = useStyleStore(); const styleStore = useStyleStore();
const { isDarkTheme } = toRefs(styleStore); const { isDarkTheme } = toRefs(styleStore);
const themeStore = useThemeStore();
function toggleDarkTheme() {
isDarkTheme.value = !isDarkTheme.value;
themeStore.toggleTheme();
}
</script> </script>
<template> <template>
@ -59,7 +49,7 @@ function toggleDarkTheme() {
</n-tooltip> </n-tooltip>
<n-tooltip trigger="hover"> <n-tooltip trigger="hover">
<template #trigger> <template #trigger>
<c-button circle variant="text" aria-label="Toggle dark/light mode" @click="toggleDarkTheme"> <c-button circle variant="text" aria-label="Toggle dark/light mode" @click="() => styleStore.toggleDark()">
<n-icon v-if="isDarkTheme" size="25" :component="Sun" /> <n-icon v-if="isDarkTheme" size="25" :component="Sun" />
<n-icon v-else size="25" :component="Moon" /> <n-icon v-else size="25" :component="Moon" />
</c-button> </c-button>

View file

@ -1,110 +0,0 @@
<script lang="ts" setup>
import { SearchRound } from '@vicons/material';
import { useMagicKeys, whenever } from '@vueuse/core';
import { NInput } from 'naive-ui';
import { computed, h, ref } from 'vue';
import { useRouter } from 'vue-router';
import SearchBarItem from './SearchBarItem.vue';
import type { Tool } from '@/tools/tools.types';
import { tools } from '@/tools';
import { useTracker } from '@/modules/tracker/tracker.services';
import { useFuzzySearch } from '@/composable/fuzzySearch';
const toolToOption = (tool: Tool) => ({ label: tool.name, value: tool.path, tool });
const router = useRouter();
const { tracker } = useTracker();
const queryString = ref('');
const inputEl = ref<HTMLElement>();
const displayDropDown = ref(true);
const isMac = computed(() => window.navigator.userAgent.toLowerCase().includes('mac'));
const { searchResult } = useFuzzySearch({
search: queryString,
data: tools,
options: { keys: [{ name: 'name', weight: 2 }, 'description', 'keywords'] },
});
const options = computed(() => {
if (queryString.value === '') {
return tools.map(toolToOption);
}
return searchResult.value.map(toolToOption);
});
const keys = useMagicKeys({
passive: false,
onEventFired(e) {
if (e.ctrlKey && e.key === 'k' && e.type === 'keydown') {
e.preventDefault();
}
if (e.metaKey && e.key === 'k' && e.type === 'keydown') {
e.preventDefault();
}
},
});
whenever(keys.ctrl_k, claimFocus);
whenever(keys.meta_k, claimFocus);
whenever(keys.escape, releaseFocus);
function renderOption({ tool }: { tool: Tool }) {
return h(SearchBarItem, { tool });
}
function onSelect(path: string) {
router.push(path);
queryString.value = '';
}
function claimFocus() {
displayDropDown.value = true;
inputEl.value?.focus();
}
function releaseFocus() {
displayDropDown.value = false;
}
function onFocus() {
tracker.trackEvent({ eventName: 'Search-bar focused' });
displayDropDown.value = true;
}
</script>
<template>
<div class="search-bar">
<n-auto-complete
v-model:value="queryString"
:options="options"
:on-select="(value: string | number) => onSelect(String(value))"
:render-label="renderOption"
default-value="aa"
:get-show="() => displayDropDown"
:on-focus="onFocus"
@update:value="() => (displayDropDown = true)"
>
<template #default="{ handleInput, handleBlur, handleFocus, value: slotValue }">
<NInput
ref="inputEl"
round
clearable
:placeholder="`Search a tool (use ${isMac ? 'Cmd' : 'Ctrl'} + K to focus)`"
:value="slotValue"
:input-props="{ autocomplete: 'disabled' }"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
>
<template #prefix>
<n-icon :component="SearchRound" />
</template>
</NInput>
</template>
</n-auto-complete>
</div>
</template>

View file

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

View file

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClipboard } from '@vueuse/core'; import { useClipboard } from '@vueuse/core';
import { ref, toRefs } from 'vue';
const props = withDefaults(defineProps<{ value?: string }>(), { value: '' }); const props = withDefaults(defineProps<{ value?: string }>(), { value: '' });
const { value } = toRefs(props); const { value } = toRefs(props);

View file

@ -6,7 +6,6 @@ import jsonHljs from 'highlight.js/lib/languages/json';
import sqlHljs from 'highlight.js/lib/languages/sql'; import sqlHljs from 'highlight.js/lib/languages/sql';
import xmlHljs from 'highlight.js/lib/languages/xml'; import xmlHljs from 'highlight.js/lib/languages/xml';
import yamlHljs from 'highlight.js/lib/languages/yaml'; import yamlHljs from 'highlight.js/lib/languages/yaml';
import { ref, toRefs } from 'vue';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -26,6 +25,7 @@ const props = withDefaults(
hljs.registerLanguage('sql', sqlHljs); hljs.registerLanguage('sql', sqlHljs);
hljs.registerLanguage('json', jsonHljs); hljs.registerLanguage('json', jsonHljs);
hljs.registerLanguage('html', xmlHljs); hljs.registerLanguage('html', xmlHljs);
hljs.registerLanguage('xml', xmlHljs);
hljs.registerLanguage('yaml', yamlHljs); hljs.registerLanguage('yaml', yamlHljs);
const { value, language, followHeightOf, copyPlacement, copyMessage } = toRefs(props); const { value, language, followHeightOf, copyPlacement, copyMessage } = toRefs(props);

View file

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useThemeVars } from 'naive-ui'; import { useThemeVars } from 'naive-ui';
import { toRefs } from 'vue';
import FavoriteButton from './FavoriteButton.vue'; import FavoriteButton from './FavoriteButton.vue';
import { useAppTheme } from '@/ui/theme/themes'; import { useAppTheme } from '@/ui/theme/themes';
import type { Tool } from '@/tools/tools.types'; import type { Tool } from '@/tools/tools.types';

View file

@ -1,9 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { NIcon, useThemeVars } from 'naive-ui'; import { NIcon, useThemeVars } from 'naive-ui';
import { computed } from 'vue';
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import { Heart, Home2, Menu2 } from '@vicons/tabler'; import { Heart, Home2, Menu2 } from '@vicons/tabler';
import SearchBar from '../components/SearchBar.vue';
import HeroGradient from '../assets/hero-gradient.svg?component'; import HeroGradient from '../assets/hero-gradient.svg?component';
import MenuLayout from '../components/MenuLayout.vue'; import MenuLayout from '../components/MenuLayout.vue';
import NavbarButtons from '../components/NavbarButtons.vue'; import NavbarButtons from '../components/NavbarButtons.vue';
@ -104,7 +104,7 @@ const tools = computed<ToolCategory[]>(() => [
Home Home
</n-tooltip> </n-tooltip>
<SearchBar /> <command-palette mx-2 />
<NavbarButtons v-if="!styleStore.isSmallScreen" /> <NavbarButtons v-if="!styleStore.isSmallScreen" />
@ -218,10 +218,6 @@ const tools = computed<ToolCategory[]>(() => [
justify-content: center; justify-content: center;
flex-direction: row; flex-direction: row;
& > *:not(:last-child) {
margin-right: 5px;
}
.search-bar { .search-bar {
// width: 100%; // width: 100%;
flex-grow: 1; flex-grow: 1;

View file

@ -2,7 +2,7 @@
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useHead } from '@vueuse/head'; import { useHead } from '@vueuse/head';
import type { HeadObject } from '@vueuse/head'; import type { HeadObject } from '@vueuse/head';
import { computed } from 'vue';
import BaseLayout from './base.layout.vue'; import BaseLayout from './base.layout.vue';
import FavoriteButton from '@/components/FavoriteButton.vue'; import FavoriteButton from '@/components/FavoriteButton.vue';
import type { Tool } from '@/tools/tools.types'; import type { Tool } from '@/tools/tools.types';
@ -23,6 +23,11 @@ const head = computed<HeadObject>(() => ({
], ],
})); }));
useHead(head); useHead(head);
const { t } = useI18n();
const i18nKey = computed<string>(() => route.path.trim().replace('/', ''));
const toolTitle = computed<string>(() => t(`tools.${i18nKey.value}.title`, String(route.meta.name)));
const toolDescription = computed<string>(() => t(`tools.${i18nKey.value}.description`, String(route.meta.description)));
</script> </script>
<template> <template>
@ -31,7 +36,7 @@ useHead(head);
<div class="tool-header"> <div class="tool-header">
<div flex flex-nowrap items-center justify-between> <div flex flex-nowrap items-center justify-between>
<n-h1> <n-h1>
{{ route.meta.name }} {{ toolTitle }}
</n-h1> </n-h1>
<div> <div>
@ -42,7 +47,7 @@ useHead(head);
<div class="separator" /> <div class="separator" />
<div class="description"> <div class="description">
{{ route.meta.description }} {{ toolDescription }}
</div> </div>
</div> </div>
</div> </div>

View file

@ -11,6 +11,7 @@ import { naive } from './plugins/naive.plugin';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';
import { i18nPlugin } from './plugins/i18n.plugin';
registerSW(); registerSW();
@ -18,6 +19,7 @@ const app = createApp(App);
app.use(createPinia()); app.use(createPinia());
app.use(createHead()); app.use(createHead());
app.use(i18nPlugin);
app.use(router); app.use(router);
app.use(naive); app.use(naive);
app.use(plausible); app.use(plausible);

View file

@ -0,0 +1,82 @@
import { defineStore } from 'pinia';
import _ from 'lodash';
import type { PaletteOption } from './command-palette.types';
import { useToolStore } from '@/tools/tools.store';
import { useFuzzySearch } from '@/composable/fuzzySearch';
import { useStyleStore } from '@/stores/style.store';
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';
export const useCommandPaletteStore = defineStore('command-palette', () => {
const toolStore = useToolStore();
const styleStore = useStyleStore();
const router = useRouter();
const searchPrompt = ref('');
const toolsOptions = toolStore.tools.map(tool => ({
...tool,
to: tool.path,
toolCategory: tool.category,
category: 'Tools',
}));
const searchOptions: PaletteOption[] = [
...toolsOptions,
{
name: 'Random tool',
description: 'Get a random tool from the list.',
action: () => {
const { path } = _.sample(toolStore.tools)!;
router.push(path);
},
icon: DiceIcon,
category: 'Tools',
keywords: ['random', 'tool', 'pick', 'choose', 'select'],
closeOnSelect: true,
},
{
name: 'Toggle dark mode',
description: 'Toggle dark mode on or off.',
action: () => styleStore.toggleDark(),
icon: SunIcon,
category: 'Actions',
keywords: ['dark', 'theme', 'toggle', 'mode', 'light', 'system'],
},
{
name: 'Github repository',
href: 'https://github.com/CorentinTh/it-tools',
category: 'External',
description: 'View the source code of it-tools on Github.',
keywords: ['github', 'repo', 'repository', 'source', 'code'],
icon: GithubIcon,
},
{
name: 'Report a bug or an issue',
description: 'Report a bug or an issue to help improve it-tools.',
href: 'https://github.com/CorentinTh/it-tools/issues/new/choose',
category: 'Actions',
keywords: ['report', 'issue', 'bug', 'problem', 'error'],
icon: BugIcon,
},
];
const { searchResult } = useFuzzySearch({
search: searchPrompt,
data: searchOptions,
options: {
keys: [{ name: 'name', weight: 2 }, 'description', 'keywords', 'category'],
threshold: 0.3,
},
});
const filteredSearchResult = computed(() =>
_.chain(searchResult.value).groupBy('category').mapValues(categoryOptions => _.take(categoryOptions, 5)).value());
return {
filteredSearchResult,
searchPrompt,
};
});

View file

@ -0,0 +1,14 @@
import type { Component } from 'vue';
import type { RouteLocationRaw } from 'vue-router';
export interface PaletteOption {
name: string
description?: string
icon?: Component
action?: () => void
to?: RouteLocationRaw
category: string
keywords?: string[]
href?: string
closeOnSelect?: boolean
}

View file

@ -0,0 +1,153 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import _ from 'lodash';
import { useCommandPaletteStore } from './command-palette.store';
import type { PaletteOption } from './command-palette.types';
const isModalOpen = ref(false);
const inputRef = ref();
const router = useRouter();
const isMac = computed(() => window.navigator.userAgent.toLowerCase().includes('mac'));
const commandPaletteStore = useCommandPaletteStore();
const { searchPrompt, filteredSearchResult } = storeToRefs(commandPaletteStore);
const keys = useMagicKeys({
passive: false,
onEventFired(e) {
if (e.ctrlKey && e.key === 'k' && e.type === 'keydown') {
e.preventDefault();
}
if (e.metaKey && e.key === 'k' && e.type === 'keydown') {
e.preventDefault();
}
},
});
whenever(isModalOpen, () => inputRef.value?.focus());
whenever(keys.ctrl_k, open);
whenever(keys.meta_k, open);
whenever(keys.escape, close);
function open() {
return isModalOpen.value = true;
}
function close() {
isModalOpen.value = false;
}
const selectedOptionIndex = ref(0);
function handleKeydown(event: KeyboardEvent) {
const { key } = event;
const isEnterPressed = key === 'Enter';
const isArrowUpOrDown = ['ArrowUp', 'ArrowDown'].includes(key);
const isArrowDown = key === 'ArrowDown';
if (isArrowUpOrDown) {
const increment = isArrowDown ? 1 : -1;
const maxIndex = Math.max(_.chain(filteredSearchResult.value).values().flatten().size().value() - 1, 0);
selectedOptionIndex.value = Math.min(Math.max(selectedOptionIndex.value + increment, 0), maxIndex);
return;
}
if (isEnterPressed) {
const option = _.chain(filteredSearchResult.value)
.values()
.flatten()
.nth(selectedOptionIndex.value)
.value();
activateOption(option);
}
}
function getOptionIndex(option: PaletteOption) {
return _.chain(filteredSearchResult.value)
.values()
.flatten()
.findIndex(o => o === option)
.value();
}
function activateOption(option: PaletteOption) {
const { closeOnSelect } = option;
if (option.action) {
option.action();
if (closeOnSelect) {
close();
}
return;
}
const closeAfterNavigation = closeOnSelect || _.isUndefined(closeOnSelect);
if (option.to) {
router.push(option.to);
if (closeAfterNavigation) {
close();
}
return;
}
if (option.href) {
window.open(option.href, '_blank');
if (closeAfterNavigation) {
close();
}
}
}
</script>
<template>
<div flex-1>
<c-button w-full important:justify-start @click="isModalOpen = true">
<span flex items-center gap-3 op-40>
<icon-mdi-search />
Search...
<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
</span>
</span>
</c-button>
<c-modal v-model:open="isModalOpen" class="palette-modal" shadow-xl important:max-w-650px important:pa-12px @keydown="handleKeydown">
<c-input-text ref="inputRef" v-model:value="searchPrompt" raw-text placeholder="Type to search a tool or a command..." autofocus clearable />
<div v-for="(options, category) in filteredSearchResult" :key="category">
<div ml-3 mt-3 text-sm font-bold text-primary op-60>
{{ category }}
</div>
<command-palette-option v-for="option in options" :key="option.name" :option="option" :selected="selectedOptionIndex === getOptionIndex(option)" @activated="activateOption" />
</div>
</c-modal>
</div>
</template>
<style scoped lang="less">
.c-input-text {
font-size: 18px;
::v-deep(.input-wrapper) {
padding: 4px;
padding-left: 18px;
}
}
.c-modal--overlay {
align-items: flex-start !important;
padding-top: 80px;
}
</style>

View file

@ -0,0 +1,36 @@
<script setup lang="ts">
import type { PaletteOption } from '../command-palette.types';
const props = withDefaults(defineProps<{ option: PaletteOption; selected?: boolean }>(), {
selected: false,
});
const emit = defineEmits(['activated']);
const { option } = toRefs(props);
const { selected } = toRefs(props);
</script>
<template>
<div
role="option"
:aria-selected="selected"
:class="{
'text-white': selected,
'bg-primary': selected,
}"
w-full flex cursor-pointer items-center overflow-hidden rounded pa-3 transition hover:bg-primary hover:text-white
@click="() => emit('activated', option)"
>
<component :is="option.icon" v-if="option.icon" mr-3 h-30px w-30px shrink-0 op-50 />
<div flex-1 overflow-hidden>
<div truncate font-bold lh-tight op-90>
{{ option.name }}
</div>
<div v-if="option.description" truncate lh-tight op-60>
{{ option.description }}
</div>
</div>
</div>
</template>

View file

@ -14,12 +14,12 @@ const { tracker } = useTracker();
<c-link href="https://github.com/CorentinTh" target="_blank" rel="noopener"> <c-link href="https://github.com/CorentinTh" target="_blank" rel="noopener">
Corentin Thomasset Corentin Thomasset
</c-link>, </c-link>,
aggregates useful tools for developer and people working in IT. If you find it useful, please fell free to share 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 pin it in your shortcut bar ! it to people you think may find it useful too and don't forget to bookmark it in your shortcut bar!
</n-p> </n-p>
<n-p> <n-p>
IT Tools is open-source (under the MIT license) and free, and will always be, but it cost me money to host and 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 renew the domain name. If you want to support my work, and encourage me to add more tools, please consider
supporting by supporting by
<c-link <c-link
href="https://www.buymeacoffee.com/cthmsst" href="https://www.buymeacoffee.com/cthmsst"
@ -33,8 +33,8 @@ const { tracker } = useTracker();
<n-h2>Technologies</n-h2> <n-h2>Technologies</n-h2>
<n-p> <n-p>
IT Tools is made in Vue JS (vue 3) with the the naive-ui component library and is hosted and continuously deployed IT Tools is made in Vue.js (Vue 3) with the the Naive UI component library and is hosted and continuously deployed
by Vercel. Third party open-source libraries are used in some tools, you may find the complete list in the by Vercel. Third-party open-source libraries are used in some tools, you may find the complete list in the
<c-link href="https://github.com/CorentinTh/it-tools/blob/main/package.json" rel="noopener" target="_blank"> <c-link href="https://github.com/CorentinTh/it-tools/blob/main/package.json" rel="noopener" target="_blank">
package.json package.json
</c-link> </c-link>
@ -43,10 +43,10 @@ const { tracker } = useTracker();
<n-h2>Found a bug? A tool is missing?</n-h2> <n-h2>Found a bug? A tool is missing?</n-h2>
<n-p> <n-p>
If you need a tool that is currently not present here, and you think can be relevant, you are welcome to submit a 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 feature request in the
<c-link <c-link
href="https://github.com/CorentinTh/it-tools/issues/new?assignees=CorentinTh&labels=enhancement&template=feature_request.md&title=%5BFEAT%5D%20My%20feature" href="https://github.com/CorentinTh/it-tools/issues/new/choose"
rel="noopener" rel="noopener"
target="_blank" target="_blank"
> >
@ -55,9 +55,9 @@ const { tracker } = useTracker();
in the GitHub repository. in the GitHub repository.
</n-p> </n-p>
<n-p> <n-p>
And if you found a bug, or something broken that doesn't work as expected, please fill a bug report in the And if you found a bug, or something doesn't work as expected, please fill a bug report in the
<c-link <c-link
href="https://github.com/CorentinTh/it-tools/issues/new?assignees=CorentinTh&labels=bug&template=bug_report.md&title=%5BBUG%5D%20My%20bug" href="https://github.com/CorentinTh/it-tools/issues/new/choose"
rel="noopener" rel="noopener"
target="_blank" target="_blank"
> >

View file

@ -9,6 +9,7 @@ import { config } from '@/config';
const toolStore = useToolStore(); const toolStore = useToolStore();
useHead({ title: 'IT Tools - Handy online tools for developers' }); useHead({ title: 'IT Tools - Handy online tools for developers' });
const { t } = useI18n();
</script> </script>
<template> <template>
@ -48,7 +49,7 @@ useHead({ title: 'IT Tools - Handy online tools for developers' });
</transition> </transition>
<div v-if="toolStore.newTools.length > 0"> <div v-if="toolStore.newTools.length > 0">
<n-h3>Newest tools</n-h3> <n-h3>{{ t('home.categories.newestTools') }}</n-h3>
<n-grid x-gap="12" y-gap="12" cols="1 400:2 800:3 1200:4 2000:8"> <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"> <n-gi v-for="tool in toolStore.newTools" :key="tool.name">
<ToolCard :tool="tool" /> <ToolCard :tool="tool" />

View file

@ -0,0 +1,15 @@
import type { App } from 'vue';
import { createI18n } from 'vue-i18n';
import messages from '@intlify/unplugin-vue-i18n/messages';
const i18n = createI18n({
legacy: false,
locale: 'en',
messages,
});
export const i18nPlugin = {
install: (app: App) => {
app.use(i18n);
},
};

View file

@ -1,10 +1,11 @@
import { useMediaQuery, useStorage } from '@vueuse/core'; import { useDark, useMediaQuery, useStorage, useToggle } from '@vueuse/core';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { type Ref, watch } from 'vue'; import { type Ref, watch } from 'vue';
export const useStyleStore = defineStore('style', { export const useStyleStore = defineStore('style', {
state: () => { state: () => {
const isDarkTheme = useStorage('isDarkTheme', true) as Ref<boolean>; const isDarkTheme = useDark();
const toggleDark = useToggle(isDarkTheme);
const isSmallScreen = useMediaQuery('(max-width: 700px)'); const isSmallScreen = useMediaQuery('(max-width: 700px)');
const isMenuCollapsed = useStorage('isMenuCollapsed', isSmallScreen.value) as Ref<boolean>; const isMenuCollapsed = useStorage('isMenuCollapsed', isSmallScreen.value) as Ref<boolean>;
@ -12,6 +13,7 @@ export const useStyleStore = defineStore('style', {
return { return {
isDarkTheme, isDarkTheme,
toggleDark,
isMenuCollapsed, isMenuCollapsed,
isSmallScreen, isSmallScreen,
}; };

View file

@ -2,7 +2,7 @@
import { Upload } from '@vicons/tabler'; import { Upload } from '@vicons/tabler';
import { useBase64 } from '@vueuse/core'; import { useBase64 } from '@vueuse/core';
import type { UploadFileInfo } from 'naive-ui'; import type { UploadFileInfo } from 'naive-ui';
import { type Ref, ref } from 'vue'; import type { Ref } from 'vue';
import { useCopy } from '@/composable/copy'; import { useCopy } from '@/composable/copy';
import { useDownloadFileFromBase64 } from '@/composable/downloadBase64'; import { useDownloadFileFromBase64 } from '@/composable/downloadBase64';
import { useValidation } from '@/composable/validation'; import { useValidation } from '@/composable/validation';

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import { useCopy } from '@/composable/copy'; import { useCopy } from '@/composable/copy';
import { base64ToText, isValidBase64, textToBase64 } from '@/utils/base64'; import { base64ToText, isValidBase64, textToBase64 } from '@/utils/base64';
import { withDefaultOnError } from '@/utils/defaults'; import { withDefaultOnError } from '@/utils/defaults';

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import { useCopy } from '@/composable/copy'; import { useCopy } from '@/composable/copy';
import { textToBase64 } from '@/utils/base64'; import { textToBase64 } from '@/utils/base64';

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import { compareSync, hashSync } from 'bcryptjs'; import { compareSync, hashSync } from 'bcryptjs';
import { useThemeVars } from 'naive-ui'; import { useThemeVars } from 'naive-ui';
import { useCopy } from '@/composable/copy'; import { useCopy } from '@/composable/copy';

View file

@ -2,7 +2,7 @@
import { Plus, Trash } from '@vicons/tabler'; import { Plus, Trash } from '@vicons/tabler';
import { useClipboard, useStorage } from '@vueuse/core'; import { useClipboard, useStorage } from '@vueuse/core';
import _ from 'lodash'; import _ from 'lodash';
import { computed } from 'vue';
import { arrayToMarkdownTable, computeAverage, computeVariance } from './benchmark-builder.models'; import { arrayToMarkdownTable, computeAverage, computeVariance } from './benchmark-builder.models';
import DynamicValues from './dynamic-values.vue'; import DynamicValues from './dynamic-values.vue';

View file

@ -15,7 +15,7 @@ import {
spanishWordList, spanishWordList,
} from '@it-tools/bip39'; } from '@it-tools/bip39';
import { Copy, Refresh } from '@vicons/tabler'; import { Copy, Refresh } from '@vicons/tabler';
import { computed, ref } from 'vue';
import { useCopy } from '@/composable/copy'; import { useCopy } from '@/composable/copy';
import { useValidation } from '@/composable/validation'; import { useValidation } from '@/composable/validation';
import { isNotThrowing } from '@/utils/boolean'; import { isNotThrowing } from '@/utils/boolean';

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue';
import { import {
camelCase, camelCase,
capitalCase, capitalCase,

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { computeChmodOctalRepresentation } from './chmod-calculator.service'; import { computeChmodOctalRepresentation, computeChmodSymbolicRepresentation } from './chmod-calculator.service';
describe('chmod-calculator', () => { describe('chmod-calculator', () => {
describe('computeChmodOctalRepresentation', () => { describe('computeChmodOctalRepresentation', () => {
@ -64,5 +64,67 @@ describe('chmod-calculator', () => {
}), }),
).to.eql('222'); ).to.eql('222');
}); });
it('get the symbolic representation from permissions', () => {
expect(
computeChmodSymbolicRepresentation({
permissions: {
owner: { read: true, write: true, execute: true },
group: { read: true, write: true, execute: true },
public: { read: true, write: true, execute: true },
},
}),
).to.eql('rwxrwxrwx');
expect(
computeChmodSymbolicRepresentation({
permissions: {
owner: { read: false, write: false, execute: false },
group: { read: false, write: false, execute: false },
public: { read: false, write: false, execute: false },
},
}),
).to.eql('---------');
expect(
computeChmodSymbolicRepresentation({
permissions: {
owner: { read: false, write: true, execute: false },
group: { read: false, write: true, execute: true },
public: { read: true, write: false, execute: true },
},
}),
).to.eql('-w--wxr-x');
expect(
computeChmodSymbolicRepresentation({
permissions: {
owner: { read: true, write: false, execute: false },
group: { read: false, write: true, execute: false },
public: { read: false, write: false, execute: true },
},
}),
).to.eql('r---w---x');
expect(
computeChmodSymbolicRepresentation({
permissions: {
owner: { read: false, write: false, execute: true },
group: { read: false, write: true, execute: false },
public: { read: true, write: false, execute: false },
},
}),
).to.eql('--x-w-r--');
expect(
computeChmodSymbolicRepresentation({
permissions: {
owner: { read: false, write: true, execute: false },
group: { read: false, write: true, execute: false },
public: { read: false, write: true, execute: false },
},
}),
).to.eql('-w--w--w-');
});
}); });
}); });

View file

@ -1,7 +1,7 @@
import _ from 'lodash'; import _ from 'lodash';
import type { GroupPermissions, Permissions } from './chmod-calculator.types'; import type { GroupPermissions, Permissions } from './chmod-calculator.types';
export { computeChmodOctalRepresentation }; export { computeChmodOctalRepresentation, computeChmodSymbolicRepresentation };
function computeChmodOctalRepresentation({ permissions }: { permissions: Permissions }): string { function computeChmodOctalRepresentation({ permissions }: { permissions: Permissions }): string {
const permissionValue = { read: 4, write: 2, execute: 1 }; const permissionValue = { read: 4, write: 2, execute: 1 };
@ -15,3 +15,16 @@ function computeChmodOctalRepresentation({ permissions }: { permissions: Permiss
getGroupPermissionValue(permissions.public), getGroupPermissionValue(permissions.public),
].join(''); ].join('');
} }
function computeChmodSymbolicRepresentation({ permissions }: { permissions: Permissions }): string {
const permissionValue = { read: 'r', write: 'w', execute: 'x' };
const getGroupPermissionValue = (permission: GroupPermissions) =>
_.reduce(permission, (acc, isPermSet, key) => acc + (isPermSet ? _.get(permissionValue, key, '') : '-'), '');
return [
getGroupPermissionValue(permissions.owner),
getGroupPermissionValue(permissions.group),
getGroupPermissionValue(permissions.public),
].join('');
}

View file

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { useThemeVars } from 'naive-ui'; import { useThemeVars } from 'naive-ui';
import { computed, ref } from 'vue';
import InputCopyable from '../../components/InputCopyable.vue'; import InputCopyable from '../../components/InputCopyable.vue';
import { computeChmodOctalRepresentation } from './chmod-calculator.service'; import { computeChmodOctalRepresentation, computeChmodSymbolicRepresentation } from './chmod-calculator.service';
import type { Group, Scope } from './chmod-calculator.types'; import type { Group, Scope } from './chmod-calculator.types';
@ -22,6 +22,7 @@ const permissions = ref({
}); });
const octal = computed(() => computeChmodOctalRepresentation({ permissions: permissions.value })); const octal = computed(() => computeChmodOctalRepresentation({ permissions: permissions.value }));
const symbolic = computed(() => computeChmodSymbolicRepresentation({ permissions: permissions.value }));
</script> </script>
<template> <template>
@ -57,6 +58,9 @@ const octal = computed(() => computeChmodOctalRepresentation({ permissions: perm
<div class="octal-result"> <div class="octal-result">
{{ octal }} {{ octal }}
</div> </div>
<div class="octal-result">
{{ symbolic }}
</div>
<InputCopyable :value="`chmod ${octal} path`" readonly /> <InputCopyable :value="`chmod ${octal} path`" readonly />
</div> </div>

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRafFn } from '@vueuse/core'; import { useRafFn } from '@vueuse/core';
import { ref } from 'vue';
import { formatMs } from './chronometer.service'; import { formatMs } from './chronometer.service';
const isRunning = ref(false); const isRunning = ref(false);

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue';
import { colord, extend } from 'colord'; import { colord, extend } from 'colord';
import cmykPlugin from 'colord/plugins/cmyk'; import cmykPlugin from 'colord/plugins/cmyk';

View file

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import cronstrue from 'cronstrue'; import cronstrue from 'cronstrue';
import { isValidCron } from 'cron-validator'; import { isValidCron } from 'cron-validator';
import { computed, reactive, ref } from 'vue';
import { useStyleStore } from '@/stores/style.store'; import { useStyleStore } from '@/stores/style.store';
function isCronValid(v: string) { function isCronValid(v: string) {

View file

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useWindowSize } from '@vueuse/core'; import { useWindowSize } from '@vueuse/core';
import { computed } from 'vue';
const { width, height } = useWindowSize(); const { width, height } = useWindowSize();

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import { MessageType, composerize } from 'composerize-ts'; import { MessageType, composerize } from 'composerize-ts';
import { withDefaultOnError } from '@/utils/defaults'; import { withDefaultOnError } from '@/utils/defaults';
import { useDownloadFileFromBase64 } from '@/composable/downloadBase64'; import { useDownloadFileFromBase64 } from '@/composable/downloadBase64';

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import { AES, RC4, Rabbit, TripleDES, enc } from 'crypto-js'; import { AES, RC4, Rabbit, TripleDES, enc } from 'crypto-js';
const algos = { AES, TripleDES, Rabbit, RC4 }; const algos = { AES, TripleDES, Rabbit, RC4 };

View file

@ -4,7 +4,7 @@
import { addMilliseconds, formatRelative } from 'date-fns'; import { addMilliseconds, formatRelative } from 'date-fns';
import { enGB } from 'date-fns/locale'; import { enGB } from 'date-fns/locale';
import { computed, ref } from 'vue';
import { formatMsDuration } from './eta-calculator.service'; import { formatMsDuration } from './eta-calculator.service';
const unitCount = ref(3 * 62); const unitCount = ref(3 * 62);

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useThemeVars } from 'naive-ui'; import { useThemeVars } from 'naive-ui';
import Memo from './git-memo.md'; import Memo from './git-memo.content.md';
const themeVars = useThemeVars(); const themeVars = useThemeVars();
</script> </script>

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { lib } from 'crypto-js'; import type { lib } from 'crypto-js';
import { MD5, RIPEMD160, SHA1, SHA224, SHA256, SHA3, SHA384, SHA512, enc } from 'crypto-js'; import { MD5, RIPEMD160, SHA1, SHA224, SHA256, SHA3, SHA384, SHA512, enc } from 'crypto-js';
import { ref } from 'vue';
import InputCopyable from '../../components/InputCopyable.vue'; import InputCopyable from '../../components/InputCopyable.vue';
import { convertHexToBin } from './hash-text.service'; import { convertHexToBin } from './hash-text.service';
import { useQueryParam } from '@/composable/queryParams'; import { useQueryParam } from '@/composable/queryParams';

View file

@ -11,7 +11,7 @@ import {
HmacSHA512, HmacSHA512,
enc, enc,
} from 'crypto-js'; } from 'crypto-js';
import { computed, ref } from 'vue';
import { convertHexToBin } from '../hash-text/hash-text.service'; import { convertHexToBin } from '../hash-text/hash-text.service';
import { useCopy } from '@/composable/copy'; import { useCopy } from '@/composable/copy';

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { escape, unescape } from 'lodash'; import { escape, unescape } from 'lodash';
import { computed, ref } from 'vue';
import { useCopy } from '@/composable/copy'; import { useCopy } from '@/composable/copy';
const escapeInput = ref('<title>IT Tool</title>'); const escapeInput = ref('<title>IT Tool</title>');

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { type Component, toRefs } from 'vue'; import type { Component } from 'vue';
const props = defineProps<{ icon: Component; title: string; action: () => void; isActive?: () => boolean }>(); const props = defineProps<{ icon: Component; title: string; action: () => void; isActive?: () => boolean }>();
const { icon, title, action, isActive } = toRefs(props); const { icon, title, action, isActive } = toRefs(props);

View file

@ -18,7 +18,7 @@ import {
Strikethrough, Strikethrough,
TextWrap, TextWrap,
} from '@vicons/tabler'; } from '@vicons/tabler';
import { type Component, toRefs } from 'vue'; import type { Component } from 'vue';
import MenuBarItem from './menu-bar-item.vue'; import MenuBarItem from './menu-bar-item.vue';
const props = defineProps<{ editor: Editor }>(); const props = defineProps<{ editor: Editor }>();

View file

@ -1,6 +1,7 @@
import { tool as base64FileConverter } from './base64-file-converter'; import { tool as base64FileConverter } from './base64-file-converter';
import { tool as base64StringConverter } from './base64-string-converter'; import { tool as base64StringConverter } from './base64-string-converter';
import { tool as basicAuthGenerator } from './basic-auth-generator'; import { tool as basicAuthGenerator } from './basic-auth-generator';
import { tool as jsonToCsv } from './json-to-csv';
import { tool as cameraRecorder } from './camera-recorder'; import { tool as cameraRecorder } from './camera-recorder';
import { tool as listConverter } from './list-converter'; import { tool as listConverter } from './list-converter';
import { tool as phoneParserAndFormatter } from './phone-parser-and-formatter'; import { tool as phoneParserAndFormatter } from './phone-parser-and-formatter';
@ -32,6 +33,7 @@ import { tool as dateTimeConverter } from './date-time-converter';
import { tool as deviceInformation } from './device-information'; import { tool as deviceInformation } from './device-information';
import { tool as cypher } from './encryption'; import { tool as cypher } from './encryption';
import { tool as etaCalculator } from './eta-calculator'; import { tool as etaCalculator } from './eta-calculator';
import { tool as percentageCalculator } from './percentage-calculator';
import { tool as gitMemo } from './git-memo'; import { tool as gitMemo } from './git-memo';
import { tool as hashText } from './hash-text'; import { tool as hashText } from './hash-text';
import { tool as hmacGenerator } from './hmac-generator'; import { tool as hmacGenerator } from './hmac-generator';
@ -57,6 +59,7 @@ import { tool as urlEncoder } from './url-encoder';
import { tool as urlParser } from './url-parser'; import { tool as urlParser } from './url-parser';
import { tool as uuidGenerator } from './uuid-generator'; import { tool as uuidGenerator } from './uuid-generator';
import { tool as macAddressLookup } from './mac-address-lookup'; import { tool as macAddressLookup } from './mac-address-lookup';
import { tool as xmlFormatter } from './xml-formatter';
export const toolsByCategory: ToolCategory[] = [ export const toolsByCategory: ToolCategory[] = [
{ {
@ -111,9 +114,11 @@ export const toolsByCategory: ToolCategory[] = [
crontabGenerator, crontabGenerator,
jsonViewer, jsonViewer,
jsonMinify, jsonMinify,
jsonToCsv,
sqlPrettify, sqlPrettify,
chmodCalculator, chmodCalculator,
dockerRunToDockerComposeConverter, dockerRunToDockerComposeConverter,
xmlFormatter,
], ],
}, },
{ {
@ -122,7 +127,7 @@ export const toolsByCategory: ToolCategory[] = [
}, },
{ {
name: 'Math', name: 'Math',
components: [mathEvaluator, etaCalculator], components: [mathEvaluator, etaCalculator, percentageCalculator],
}, },
{ {
name: 'Measurement', name: 'Measurement',

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import InputCopyable from '../../components/InputCopyable.vue'; import InputCopyable from '../../components/InputCopyable.vue';
import { convertBase } from './integer-base-converter.model'; import { convertBase } from './integer-base-converter.model';
import { useStyleStore } from '@/stores/style.store'; import { useStyleStore } from '@/stores/style.store';

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { Netmask } from 'netmask'; import { Netmask } from 'netmask';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { ArrowLeft, ArrowRight } from '@vicons/tabler'; import { ArrowLeft, ArrowRight } from '@vicons/tabler';

View file

@ -0,0 +1,12 @@
import { List } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'JSON to CSV',
path: '/json-to-csv',
description: 'Convert JSON to CSV with automatic header detection.',
keywords: ['json', 'to', 'csv', 'convert'],
component: () => import('./json-to-csv.vue'),
icon: List,
createdAt: new Date('2023-06-18'),
});

View file

@ -0,0 +1,29 @@
import { expect, test } from '@playwright/test';
test.describe('Tool - JSON to CSV', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/json-to-csv');
});
test('Has correct title', async ({ page }) => {
await expect(page).toHaveTitle('JSON to CSV - IT Tools');
});
test('Provided json is converted to csv', async ({ page }) => {
await page.getByTestId('input').fill(`
[
{'Age': 18.0, 'Salary': 20000.0, 'Gender': 'Male', 'Country': 'Germany', 'Purchased': 'N'},
{'Age': 19.0, 'Salary': 22000.0, 'Gender': 'Female', 'Country': 'France', 'Purchased': 'N'},
]
`);
const generatedJson = await page.getByTestId('area-content').innerText();
expect(generatedJson.trim()).toEqual(`
Age,Salary,Gender,Country,Purchased
18,20000,Male,Germany,N
19,22000,Female,France,N
`.trim(),
);
});
});

View file

@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import { convertArrayToCsv, getHeaders } from './json-to-csv.service';
describe('json-to-csv service', () => {
describe('getHeaders', () => {
it('extracts all the keys from the array of objects', () => {
expect(getHeaders({ array: [{ a: 1, b: 2 }, { a: 3, c: 4 }] })).toEqual(['a', 'b', 'c']);
});
it('returns an empty array if the array is empty', () => {
expect(getHeaders({ array: [] })).toEqual([]);
});
});
describe('convertArrayToCsv', () => {
it('converts an array of objects to a CSV string', () => {
const array = [
{ a: 1, b: 2 },
{ a: 3, b: 4 },
];
expect(convertArrayToCsv({ array })).toMatchInlineSnapshot(`
"a,b
1,2
3,4"
`);
});
it('converts an array of objects with different keys to a CSV string', () => {
const array = [
{ a: 1, b: 2 },
{ a: 3, c: 4 },
];
expect(convertArrayToCsv({ array })).toMatchInlineSnapshot(`
"a,b,c
1,2,
3,,4"
`);
});
it('when a value is null, it is converted to the string "null"', () => {
const array = [
{ a: null, b: 2 },
];
expect(convertArrayToCsv({ array })).toMatchInlineSnapshot(`
"a,b
null,2"
`);
});
it('when a value is undefined, it is converted to an empty string', () => {
const array = [
{ a: undefined, b: 2 },
{ b: 3 },
];
expect(convertArrayToCsv({ array })).toMatchInlineSnapshot(`
"a,b
,2
,3"
`);
});
it('when a value contains a comma, it is wrapped in double quotes', () => {
const array = [
{ a: 'hello, world', b: 2 },
];
expect(convertArrayToCsv({ array })).toMatchInlineSnapshot(`
"a,b
\\"hello, world\\",2"
`);
});
it('when a value contains a double quote, it is escaped with another double quote', () => {
const array = [
{ a: 'hello "world"', b: 2 },
];
expect(convertArrayToCsv({ array })).toMatchInlineSnapshot(`
"a,b
hello \\\\\\"world\\\\\\",2"
`);
});
});
});

View file

@ -0,0 +1,35 @@
export { getHeaders, convertArrayToCsv };
function getHeaders({ array }: { array: Record<string, unknown>[] }): string[] {
const headers = new Set<string>();
array.forEach(item => Object.keys(item).forEach(key => headers.add(key)));
return Array.from(headers);
}
function serializeValue(value: unknown): string {
if (value === null) {
return 'null';
}
if (value === undefined) {
return '';
}
const valueAsString = String(value).replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/"/g, '\\"');
if (valueAsString.includes(',')) {
return `"${valueAsString}"`;
}
return valueAsString;
}
function convertArrayToCsv({ array }: { array: Record<string, unknown>[] }): string {
const headers = getHeaders({ array });
const rows = array.map(item => headers.map(header => serializeValue(item[header])));
return [headers.join(','), ...rows].join('\n');
}

View file

@ -0,0 +1,32 @@
<script setup lang="ts">
import JSON5 from 'json5';
import { convertArrayToCsv } from './json-to-csv.service';
import type { UseValidationRule } from '@/composable/validation';
import { withDefaultOnError } from '@/utils/defaults';
function transformer(value: string) {
return withDefaultOnError(() => {
if (value === '') {
return '';
}
return convertArrayToCsv({ array: JSON5.parse(value) });
}, '');
}
const rules: UseValidationRule<string>[] = [
{
validator: (v: string) => v === '' || JSON5.parse(v),
message: 'Provided JSON is not valid.',
},
];
</script>
<template>
<format-transformer
input-label="Your raw json"
input-placeholder="Paste your raw json here..."
output-label="CSV version of your JSON"
:input-validation-rules="rules"
:transformer="transformer"
/>
</template>

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import JSON5 from 'json5'; import JSON5 from 'json5';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { formatJson } from './json.models'; import { formatJson } from './json.models';

View file

@ -13,7 +13,7 @@ function sortObjectKeys<T>(obj: T): T {
} }
return Object.keys(obj) return Object.keys(obj)
.sort() .sort((a, b) => a.localeCompare(b))
.reduce((sortedObj, key) => { .reduce((sortedObj, key) => {
sortedObj[key] = sortObjectKeys((obj as Record<string, unknown>)[key]); sortedObj[key] = sortObjectKeys((obj as Record<string, unknown>)[key]);
return sortedObj; return sortedObj;

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import { decodeJwt } from './jwt-parser.service'; import { decodeJwt } from './jwt-parser.service';
import { useValidation } from '@/composable/validation'; import { useValidation } from '@/composable/validation';
import { isNotThrowing } from '@/utils/boolean'; import { isNotThrowing } from '@/utils/boolean';

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useEventListener } from '@vueuse/core'; import { useEventListener } from '@vueuse/core';
import { computed, ref } from 'vue';
import InputCopyable from '../../components/InputCopyable.vue'; import InputCopyable from '../../components/InputCopyable.vue';
const event = ref<KeyboardEvent>(); const event = ref<KeyboardEvent>();

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import { generateLoremIpsum } from './lorem-ipsum-generator.service'; import { generateLoremIpsum } from './lorem-ipsum-generator.service';
import { useCopy } from '@/composable/copy'; import { useCopy } from '@/composable/copy';
import { randIntFromInterval } from '@/utils/random'; import { randIntFromInterval } from '@/utils/random';

View file

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { evaluate } from 'mathjs'; import { evaluate } from 'mathjs';
import { computed, ref } from 'vue';
import { withDefaultOnError } from '@/utils/defaults'; import { withDefaultOnError } from '@/utils/defaults';
const expression = ref(''); const expression = ref('');

View file

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { generateMeta } from '@it-tools/oggen'; import { generateMeta } from '@it-tools/oggen';
import _ from 'lodash'; import _ from 'lodash';
import { computed, ref, watch } from 'vue';
import { image, ogSchemas, twitter, website } from './og-schemas'; import { image, ogSchemas, twitter, website } from './og-schemas';
import type { OGSchemaType, OGSchemaTypeElementSelect } from './OGSchemaType.type'; import type { OGSchemaType, OGSchemaTypeElementSelect } from './OGSchemaType.type';
import TextareaCopyable from '@/components/TextareaCopyable.vue'; import TextareaCopyable from '@/components/TextareaCopyable.vue';

View file

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { types as extensionToMimeType, extensions as mimeTypeToExtension } from 'mime-types'; import { types as extensionToMimeType, extensions as mimeTypeToExtension } from 'mime-types';
import { computed, ref } from 'vue';
const mimeInfos = Object.entries(mimeTypeToExtension).map(([mimeType, extensions]) => ({ mimeType, extensions })); const mimeInfos = Object.entries(mimeTypeToExtension).map(([mimeType, extensions]) => ({ mimeType, extensions }));
@ -25,7 +24,7 @@ const mimeTypeFound = computed(() => (selectedExtension.value ? extensionToMimeT
Mime type to extension Mime type to extension
</n-h2> </n-h2>
<div style="opacity: 0.8"> <div style="opacity: 0.8">
Now witch file extensions are associated to a mime-type Know which file extensions are associated to a mime-type
</div> </div>
<n-form-item> <n-form-item>
<n-select <n-select
@ -61,7 +60,7 @@ const mimeTypeFound = computed(() => (selectedExtension.value ? extensionToMimeT
File extension to mime type File extension to mime type
</n-h2> </n-h2>
<div style="opacity: 0.8"> <div style="opacity: 0.8">
Now witch mime type is associated to a file extension Know which mime type is associated to a file extension
</div> </div>
<n-form-item> <n-form-item>
<n-select <n-select

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import { useTimestamp } from '@vueuse/core'; import { useTimestamp } from '@vueuse/core';
import { useThemeVars } from 'naive-ui'; import { useThemeVars } from 'naive-ui';
import { useQRCode } from '../qr-code-generator/useQRCode'; import { useQRCode } from '../qr-code-generator/useQRCode';

View file

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClipboard } from '@vueuse/core'; import { useClipboard } from '@vueuse/core';
import { toRefs } from 'vue';
const props = defineProps<{ tokens: { previous: string; current: string; next: string } }>(); const props = defineProps<{ tokens: { previous: string; current: string; next: string } }>();
const { copy: copyPrevious, copied: previousCopied } = useClipboard(); const { copy: copyPrevious, copied: previousCopied } = useClipboard();

View file

@ -0,0 +1,12 @@
import { Percentage } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Percentage calculator',
path: '/percentage-calculator',
description: 'Easily calculate percentages from a value to another value, or from a percentage to a value.',
keywords: ['percentage', 'calculator', 'calculate', 'value', 'number', '%'],
component: () => import('./percentage-calculator.vue'),
icon: Percentage,
createdAt: new Date('2023-06-18'),
});

View file

@ -0,0 +1,36 @@
import { expect, test } from '@playwright/test';
test.describe('Tool - Percentage calculator', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/percentage-calculator');
});
test('Has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Percentage calculator - IT Tools');
});
test('Correctly works out percentages', async ({ page }) => {
await page.getByTestId('percentageX').locator('input').fill('123');
await page.getByTestId('percentageY').locator('input').fill('456');
await expect(page.getByTestId('percentageResult').locator('input')).toHaveValue('560.88');
await page.getByTestId('numberX').locator('input').fill('123');
await page.getByTestId('numberY').locator('input').fill('456');
await expect(page.getByTestId('numberResult').locator('input')).toHaveValue('26.973684210526315');
await page.getByTestId('numberFrom').locator('input').fill('123');
await page.getByTestId('numberTo').locator('input').fill('456');
await expect(page.getByTestId('percentageIncreaseDecrease').locator('input')).toHaveValue('270.7317073170732');
});
test('Displays empty results for incomplete input', async ({ page }) => {
await page.getByTestId('percentageX').locator('input').fill('123');
await expect(page.getByTestId('percentageResult').locator('input')).toHaveValue('');
await page.getByTestId('numberY').locator('input').fill('456');
await expect(page.getByTestId('numberResult').locator('input')).toHaveValue('');
await page.getByTestId('numberFrom').locator('input').fill('123');
await expect(page.getByTestId('percentageIncreaseDecrease').locator('input')).toHaveValue('');
});
});

View file

@ -0,0 +1,78 @@
<script setup lang="ts">
const percentageX = ref();
const percentageY = ref();
const percentageResult = computed(() => {
if (percentageX.value === undefined || percentageY.value === undefined) {
return '';
}
return (percentageX.value / 100 * percentageY.value).toString();
});
const numberX = ref();
const numberY = ref();
const numberResult = computed(() => {
if (numberX.value === undefined || numberY.value === undefined) {
return '';
}
const result = 100 * numberX.value / numberY.value;
return (!Number.isFinite(result) || Number.isNaN(result)) ? '' : result.toString();
});
const numberFrom = ref();
const numberTo = ref();
const percentageIncreaseDecrease = computed(() => {
if (numberFrom.value === undefined || numberTo.value === undefined) {
return '';
}
const result = (numberTo.value - numberFrom.value) / numberFrom.value * 100;
return (!Number.isFinite(result) || Number.isNaN(result)) ? '' : result.toString();
});
</script>
<template>
<div style="flex: 0 0 100%">
<div style="margin: 0 auto; max-width: 600px">
<c-card mb-3>
<div mb-3 sm:hidden>
What is
</div>
<div flex gap-2>
<div hidden pt-1 sm:block style="min-width: 48px;">
What is
</div>
<n-input-number v-model:value="percentageX" data-test-id="percentageX" placeholder="X" />
<div min-w-fit pt-1>
% of
</div>
<n-input-number v-model:value="percentageY" data-test-id="percentageY" placeholder="Y" />
<input-copyable v-model:value="percentageResult" data-test-id="percentageResult" readonly placeholder="Result" style="max-width: 150px;" />
</div>
</c-card>
<c-card mb-3>
<div mb-3 sm:hidden>
X is what percent of Y
</div>
<div flex gap-2>
<n-input-number v-model:value="numberX" data-test-id="numberX" placeholder="X" />
<div hidden min-w-fit pt-1 sm:block>
is what percent of
</div>
<n-input-number v-model:value="numberY" data-test-id="numberY" placeholder="Y" />
<input-copyable v-model:value="numberResult" data-test-id="numberResult" readonly placeholder="Result" style="max-width: 150px;" />
</div>
</c-card>
<c-card mb-3>
<div mb-3>
What is the percentage increase/decrease
</div>
<div flex gap-2>
<n-input-number v-model:value="numberFrom" data-test-id="numberFrom" placeholder="From" />
<n-input-number v-model:value="numberTo" data-test-id="numberTo" placeholder="To" />
<input-copyable v-model:value="percentageIncreaseDecrease" data-test-id="percentageIncreaseDecrease" readonly placeholder="Result" style="max-width: 150px;" />
</div>
</c-card>
</div>
</div>
</template>

View file

@ -86,7 +86,7 @@ const countriesOptions = getCountries().map(code => ({
<template> <template>
<div> <div>
<n-form-item label="Default country code:"> <n-form-item label="Default country code:">
<n-select v-model:value="defaultCountryCode" :options="countriesOptions" /> <n-select v-model:value="defaultCountryCode" :options="countriesOptions" filterable />
</n-form-item> </n-form-item>
<c-input-text <c-input-text

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue';
import type { QRCodeErrorCorrectionLevel } from 'qrcode'; import type { QRCodeErrorCorrectionLevel } from 'qrcode';
import { useQRCode } from './useQRCode'; import { useQRCode } from './useQRCode';
import { useDownloadFileFromBase64 } from '@/composable/downloadBase64'; import { useDownloadFileFromBase64 } from '@/composable/downloadBase64';

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import { import {
MAX_ARABIC_TO_ROMAN, MAX_ARABIC_TO_ROMAN,
MIN_ARABIC_TO_ROMAN, MIN_ARABIC_TO_ROMAN,

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue';
import { generateKeyPair } from './rsa-key-pair-generator.service'; import { generateKeyPair } from './rsa-key-pair-generator.service';
import TextareaCopyable from '@/components/TextareaCopyable.vue'; import TextareaCopyable from '@/components/TextareaCopyable.vue';
import { withDefaultOnErrorAsync } from '@/utils/defaults'; import { withDefaultOnErrorAsync } from '@/utils/defaults';

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import slugify from '@sindresorhus/slugify'; import slugify from '@sindresorhus/slugify';
import { withDefaultOnError } from '@/utils/defaults'; import { withDefaultOnError } from '@/utils/defaults';
import { useCopy } from '@/composable/copy'; import { useCopy } from '@/composable/copy';

View file

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { type FormatFnOptions, format as formatSQL } from 'sql-formatter'; import { type FormatFnOptions, format as formatSQL } from 'sql-formatter';
import { computed, reactive, ref } from 'vue';
import TextareaCopyable from '@/components/TextareaCopyable.vue'; import TextareaCopyable from '@/components/TextareaCopyable.vue';
import { useStyleStore } from '@/stores/style.store'; import { useStyleStore } from '@/stores/style.store';

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import TextareaCopyable from '@/components/TextareaCopyable.vue'; import TextareaCopyable from '@/components/TextareaCopyable.vue';
import { useCopy } from '@/composable/copy'; import { useCopy } from '@/composable/copy';
import { useDownloadFileFromBase64 } from '@/composable/downloadBase64'; import { useDownloadFileFromBase64 } from '@/composable/downloadBase64';

View file

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import _ from 'lodash'; import _ from 'lodash';
import { reactive } from 'vue';
import { import {
convertCelsiusToKelvin, convertCelsiusToKelvin,
convertDelisleToKelvin, convertDelisleToKelvin,

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue';
import { getStringSizeInBytes } from './text-statistics.service'; import { getStringSizeInBytes } from './text-statistics.service';
import { formatBytes } from '@/utils/convert'; import { formatBytes } from '@/utils/convert';

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import { textToNatoAlphabet } from './text-to-nato-alphabet.service'; import { textToNatoAlphabet } from './text-to-nato-alphabet.service';
import { useCopy } from '@/composable/copy'; import { useCopy } from '@/composable/copy';

View file

@ -0,0 +1,9 @@
tools:
token-generator:
title: Token generator
description: Generate random string with the chars you want, uppercase or lowercase letters, numbers and/or symbols.
uppercase: Uppercase (ABC...)
lowercase: Lowercase (abc...)
numbers: Numbers (123...)
symbols: Symbols (!-;...)

View file

@ -0,0 +1,9 @@
tools:
token-generator:
title: Générateur de token
description: Génère une chaîne aléatoire avec les caractères que vous voulez, lettres majuscules ou minuscules, chiffres et/ou symboles.
uppercase: Majuscules (ABC...)
lowercase: Minuscules (abc...)
numbers: Chiffres (123...)
symbols: Symboles (!-;...)

View file

@ -9,6 +9,7 @@ const withUppercase = useQueryParam({ name: 'uppercase', defaultValue: true });
const withLowercase = useQueryParam({ name: 'lowercase', defaultValue: true }); const withLowercase = useQueryParam({ name: 'lowercase', defaultValue: true });
const withNumbers = useQueryParam({ name: 'numbers', defaultValue: true }); const withNumbers = useQueryParam({ name: 'numbers', defaultValue: true });
const withSymbols = useQueryParam({ name: 'symbols', defaultValue: false }); const withSymbols = useQueryParam({ name: 'symbols', defaultValue: false });
const { t } = useI18n();
const [token, refreshToken] = computedRefreshable(() => const [token, refreshToken] = computedRefreshable(() =>
createToken({ createToken({
@ -29,21 +30,21 @@ const { copy } = useCopy({ source: token, text: 'Token copied to the clipboard'
<n-form label-placement="left" label-width="140"> <n-form label-placement="left" label-width="140">
<div flex justify-center> <div flex justify-center>
<div> <div>
<n-form-item label="Uppercase (ABC...)"> <n-form-item :label="t('tools.token-generator.uppercase')">
<n-switch v-model:value="withUppercase" /> <n-switch v-model:value="withUppercase" />
</n-form-item> </n-form-item>
<n-form-item label="Lowercase (abc...)"> <n-form-item :label="t('tools.token-generator.lowercase')">
<n-switch v-model:value="withLowercase" /> <n-switch v-model:value="withLowercase" />
</n-form-item> </n-form-item>
</div> </div>
<div> <div>
<n-form-item label="Numbers (012...)"> <n-form-item :label="t('tools.token-generator.numbers')">
<n-switch v-model:value="withNumbers" /> <n-switch v-model:value="withNumbers" />
</n-form-item> </n-form-item>
<n-form-item label="Symbols (;-!...)"> <n-form-item :label="t('tools.token-generator.symbols')">
<n-switch v-model:value="withSymbols" /> <n-switch v-model:value="withSymbols" />
</n-form-item> </n-form-item>
</div> </div>

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import { useCopy } from '@/composable/copy'; import { useCopy } from '@/composable/copy';
import { useValidation } from '@/composable/validation'; import { useValidation } from '@/composable/validation';
import { isNotThrowing } from '@/utils/boolean'; import { isNotThrowing } from '@/utils/boolean';

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import InputCopyable from '../../components/InputCopyable.vue'; import InputCopyable from '../../components/InputCopyable.vue';
import { isNotThrowing } from '@/utils/boolean'; import { isNotThrowing } from '@/utils/boolean';
import { withDefaultOnError } from '@/utils/defaults'; import { withDefaultOnError } from '@/utils/defaults';

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import { UAParser } from 'ua-parser-js'; import { UAParser } from 'ua-parser-js';
import { Adjustments, Browser, Cpu, Devices, Engine } from '@vicons/tabler'; import { Adjustments, Browser, Cpu, Devices, Engine } from '@vicons/tabler';
import UserAgentResultCards from './user-agent-result-cards.vue'; import UserAgentResultCards from './user-agent-result-cards.vue';

View file

@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { toRefs } from 'vue';
import type { UAParser } from 'ua-parser-js'; import type { UAParser } from 'ua-parser-js';
import type { UserAgentResultSection } from './user-agent-parser.types'; import type { UserAgentResultSection } from './user-agent-parser.types';

View file

@ -0,0 +1,12 @@
import { Code } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'XML formatter',
path: '/xml-formatter',
description: 'Prettify your XML string to a human friendly readable format.',
keywords: ['xml', 'prettify', 'format'],
component: () => import('./xml-formatter.vue'),
icon: Code,
createdAt: new Date('2023-06-17'),
});

View file

@ -0,0 +1,23 @@
import { expect, test } from '@playwright/test';
test.describe('Tool - XML formatter', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/xml-formatter');
});
test('Has correct title', async ({ page }) => {
await expect(page).toHaveTitle('XML formatter - IT Tools');
});
test('XML is converted into a human readable format', async ({ page }) => {
await page.getByTestId('input').fill('<foo><bar>baz</bar><bar>baz</bar></foo>');
const formattedXml = await page.getByTestId('area-content').innerText();
expect(formattedXml.trim()).toEqual(`
<foo>
<bar>baz</bar>
<bar>baz</bar>
</foo>`.trim());
});
});

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