mirror of
https://github.com/CorentinTh/it-tools.git
synced 2025-05-08 15:15:02 -04:00
Merge remote-tracking branch 'CorentinTh/main' into json-mono-font
This commit is contained in:
commit
49cb3ca88f
113 changed files with 2823 additions and 1366 deletions
|
@ -173,6 +173,7 @@
|
|||
"useFullscreen": true,
|
||||
"useGamepad": true,
|
||||
"useGeolocation": true,
|
||||
"useI18n": true,
|
||||
"useIdle": true,
|
||||
"useImage": true,
|
||||
"useInfiniteScroll": true,
|
||||
|
|
|
@ -8,6 +8,9 @@ jobs:
|
|||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
shard: [1/3, 2/3, 3/3]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- run: corepack enable
|
||||
|
@ -20,4 +23,4 @@ jobs:
|
|||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
- name: Run Playwright tests
|
||||
run: pnpm exec playwright test
|
||||
run: pnpm run test:e2e --shard=${{ matrix.shard }}
|
2
.vscode/extensions.json
vendored
2
.vscode/extensions.json
vendored
|
@ -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"]
|
||||
}
|
||||
|
|
24
README.md
24
README.md
|
@ -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.
|
||||
|
||||
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
|
||||
|
||||
|
@ -33,7 +33,27 @@ docker run -d --name it-tools --restart unless-stopped -p 8080:80 ghcr.io/corent
|
|||
|
||||
### 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
|
||||
|
||||
|
|
2
auto-imports.d.ts
vendored
2
auto-imports.d.ts
vendored
|
@ -170,6 +170,7 @@ declare global {
|
|||
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
|
||||
const useGamepad: typeof import('@vueuse/core')['useGamepad']
|
||||
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
|
||||
const useI18n: typeof import('vue-i18n')['useI18n']
|
||||
const useIdle: typeof import('@vueuse/core')['useIdle']
|
||||
const useImage: typeof import('@vueuse/core')['useImage']
|
||||
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
|
||||
|
@ -459,6 +460,7 @@ declare module 'vue' {
|
|||
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
|
||||
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
|
||||
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 useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
|
||||
readonly useInfiniteScroll: UnwrapRef<typeof import('@vueuse/core')['useInfiniteScroll']>
|
||||
|
|
11
components.d.ts
vendored
11
components.d.ts
vendored
|
@ -33,9 +33,13 @@ declare module '@vue/runtime-core' {
|
|||
'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.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']
|
||||
ColorConverter: typeof import('./src/tools/color-converter/color-converter.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']
|
||||
DateTimeConverter: typeof import('./src/tools/date-time-converter/date-time-converter.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']
|
||||
FavoriteButton: typeof import('./src/components/FavoriteButton.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']
|
||||
HmacGenerator: typeof import('./src/tools/hmac-generator/hmac-generator.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']
|
||||
IconMdiRecord: typeof import('~icons/mdi/record')['default']
|
||||
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
|
||||
IconMdiSearch: typeof import('~icons/mdi/search')['default']
|
||||
IconMdiVideo: typeof import('~icons/mdi/video')['default']
|
||||
InputCopyable: typeof import('./src/components/InputCopyable.vue')['default']
|
||||
IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default']
|
||||
|
@ -76,6 +82,7 @@ declare module '@vue/runtime-core' {
|
|||
Ipv6UlaGenerator: typeof import('./src/tools/ipv6-ula-generator/ipv6-ula-generator.vue')['default']
|
||||
JsonDiff: typeof import('./src/tools/json-diff/json-diff.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']
|
||||
JsonViewer: typeof import('./src/tools/json-viewer/json-viewer.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']
|
||||
NUploadDragger: typeof import('naive-ui')['NUploadDragger']
|
||||
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']
|
||||
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']
|
||||
|
@ -160,6 +168,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']
|
||||
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']
|
||||
}
|
||||
}
|
||||
|
|
12
index.html
12
index.html
|
@ -2,7 +2,7 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<title>IT Tools - Handy online tools for developers</title>
|
||||
<meta itemprop="name" content="IT Tools - Handy online tools for developers" />
|
||||
|
@ -14,13 +14,13 @@
|
|||
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."
|
||||
/>
|
||||
<link rel="author" href="/humans.txt" />
|
||||
<link rel="author" href="humans.txt" />
|
||||
<link rel="canonical" href="https://it-tools.tech" />
|
||||
|
||||
<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="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#18a058" />
|
||||
<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="16x16" href="favicon-16x16.png" />
|
||||
<link rel="mask-icon" href="safari-pinned-tab.svg" color="#18a058" />
|
||||
<meta name="msapplication-TileColor" content="#da532c" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
|
||||
|
|
4
locales/en.yml
Normal file
4
locales/en.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
home:
|
||||
categories:
|
||||
newestTools: Newest tools
|
||||
|
3
locales/fr.yml
Normal file
3
locales/fr.yml
Normal file
|
@ -0,0 +1,3 @@
|
|||
home:
|
||||
categories:
|
||||
newestTools: "Nouveaux outils"
|
4
netlify.toml
Normal file
4
netlify.toml
Normal file
|
@ -0,0 +1,4 @@
|
|||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
12
package.json
12
package.json
|
@ -36,9 +36,9 @@
|
|||
"@it-tools/bip39": "^0.0.4",
|
||||
"@it-tools/oggen": "^1.3.0",
|
||||
"@sindresorhus/slugify": "^2.2.0",
|
||||
"@tiptap/pm": "2.0.0-beta.220",
|
||||
"@tiptap/starter-kit": "2.0.0-beta.220",
|
||||
"@tiptap/vue-3": "2.0.0-beta.220",
|
||||
"@tiptap/pm": "^2.0.3",
|
||||
"@tiptap/starter-kit": "^2.0.3",
|
||||
"@tiptap/vue-3": "^2.0.3",
|
||||
"@vicons/material": "^0.12.0",
|
||||
"@vicons/tabler": "^0.12.0",
|
||||
"@vueuse/core": "^10.1.2",
|
||||
|
@ -74,13 +74,16 @@
|
|||
"ts-pattern": "^4.2.2",
|
||||
"ua-parser-js": "^1.0.35",
|
||||
"uuid": "^8.3.2",
|
||||
"vue": "^3.2.47",
|
||||
"vue": "^3.3.4",
|
||||
"vue-i18n": "^9.2.2",
|
||||
"vue-router": "^4.1.6",
|
||||
"xml-formatter": "^3.3.2",
|
||||
"yaml": "^2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^0.39.3",
|
||||
"@iconify-json/mdi": "^1.1.50",
|
||||
"@intlify/unplugin-vue-i18n": "^0.11.0",
|
||||
"@playwright/test": "^1.32.3",
|
||||
"@rushstack/eslint-patch": "^1.2.0",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
|
@ -102,6 +105,7 @@
|
|||
"@vitejs/plugin-vue-jsx": "^1.3.10",
|
||||
"@vue/compiler-sfc": "^3.2.47",
|
||||
"@vue/runtime-core": "^3.2.47",
|
||||
"@vue/runtime-dom": "^3.3.4",
|
||||
"@vue/test-utils": "^2.3.2",
|
||||
"@vue/tsconfig": "^0.1.3",
|
||||
"c8": "^7.13.0",
|
||||
|
|
2731
pnpm-lock.yaml
generated
2731
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterView, useRoute } from 'vue-router';
|
||||
import { NGlobalStyle, NMessageProvider, NNotificationProvider, darkTheme } from 'naive-ui';
|
||||
import { darkThemeOverrides, lightThemeOverrides } from './themes';
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
import { ChevronRight } from '@vicons/tabler';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useThemeVars } from 'naive-ui';
|
||||
import { computed, h, toRefs } from 'vue';
|
||||
import { RouterLink, useRoute } from 'vue-router';
|
||||
import MenuIconItem from './MenuIconItem.vue';
|
||||
import type { Tool, ToolCategory } from '@/tools/tools.types';
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { type Component, toRefs } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
|
||||
const props = defineProps<{ icon: Component; title: string }>();
|
||||
const { icon, title } = toRefs(props);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { FavoriteFilled } from '@vicons/material';
|
||||
import { computed, toRefs } from 'vue';
|
||||
|
||||
import { useToolStore } from '@/tools/tools.store';
|
||||
import type { Tool } from '@/tools/tools.types';
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { useClipboard, useVModel } from '@vueuse/core';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps<{ value: string }>();
|
||||
const emit = defineEmits(['update:value']);
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { useThemeVars } from 'naive-ui';
|
||||
import { toRefs } from 'vue';
|
||||
import type { Tool } from '@/tools/tools.types';
|
||||
|
||||
const props = defineProps<{ tool: Tool }>();
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, toRefs } from 'vue';
|
||||
import { useStyleStore } from '@/stores/style.store';
|
||||
|
||||
const styleStore = useStyleStore();
|
||||
|
|
|
@ -1,19 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import { BrandGithub, BrandTwitter, InfoCircle, Moon, Sun } from '@vicons/tabler';
|
||||
import { toRefs } from 'vue';
|
||||
import { useStyleStore } from '@/stores/style.store';
|
||||
import { useThemeStore } from '@/ui/theme/theme.store';
|
||||
|
||||
const styleStore = useStyleStore();
|
||||
const { isDarkTheme } = toRefs(styleStore);
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
function toggleDarkTheme() {
|
||||
isDarkTheme.value = !isDarkTheme.value;
|
||||
|
||||
themeStore.toggleTheme();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -59,7 +49,7 @@ function toggleDarkTheme() {
|
|||
</n-tooltip>
|
||||
<n-tooltip trigger="hover">
|
||||
<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-else size="25" :component="Moon" />
|
||||
</c-button>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { ref, toRefs } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<{ value?: string }>(), { value: '' });
|
||||
const { value } = toRefs(props);
|
||||
|
|
|
@ -6,7 +6,6 @@ import jsonHljs from 'highlight.js/lib/languages/json';
|
|||
import sqlHljs from 'highlight.js/lib/languages/sql';
|
||||
import xmlHljs from 'highlight.js/lib/languages/xml';
|
||||
import yamlHljs from 'highlight.js/lib/languages/yaml';
|
||||
import { ref, toRefs } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
@ -26,6 +25,7 @@ const props = withDefaults(
|
|||
hljs.registerLanguage('sql', sqlHljs);
|
||||
hljs.registerLanguage('json', jsonHljs);
|
||||
hljs.registerLanguage('html', xmlHljs);
|
||||
hljs.registerLanguage('xml', xmlHljs);
|
||||
hljs.registerLanguage('yaml', yamlHljs);
|
||||
|
||||
const { value, language, followHeightOf, copyPlacement, copyMessage } = toRefs(props);
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { useThemeVars } from 'naive-ui';
|
||||
import { toRefs } from 'vue';
|
||||
import FavoriteButton from './FavoriteButton.vue';
|
||||
import { useAppTheme } from '@/ui/theme/themes';
|
||||
import type { Tool } from '@/tools/tools.types';
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<script lang="ts" setup>
|
||||
import { NIcon, useThemeVars } from 'naive-ui';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { Heart, Home2, Menu2 } from '@vicons/tabler';
|
||||
import SearchBar from '../components/SearchBar.vue';
|
||||
|
||||
import HeroGradient from '../assets/hero-gradient.svg?component';
|
||||
import MenuLayout from '../components/MenuLayout.vue';
|
||||
import NavbarButtons from '../components/NavbarButtons.vue';
|
||||
|
@ -104,7 +104,7 @@ const tools = computed<ToolCategory[]>(() => [
|
|||
Home
|
||||
</n-tooltip>
|
||||
|
||||
<SearchBar />
|
||||
<command-palette mx-2 />
|
||||
|
||||
<NavbarButtons v-if="!styleStore.isSmallScreen" />
|
||||
|
||||
|
@ -218,10 +218,6 @@ const tools = computed<ToolCategory[]>(() => [
|
|||
justify-content: center;
|
||||
flex-direction: row;
|
||||
|
||||
& > *:not(:last-child) {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
// width: 100%;
|
||||
flex-grow: 1;
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import { useRoute } from 'vue-router';
|
||||
import { useHead } from '@vueuse/head';
|
||||
import type { HeadObject } from '@vueuse/head';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import BaseLayout from './base.layout.vue';
|
||||
import FavoriteButton from '@/components/FavoriteButton.vue';
|
||||
import type { Tool } from '@/tools/tools.types';
|
||||
|
@ -23,6 +23,11 @@ const head = computed<HeadObject>(() => ({
|
|||
],
|
||||
}));
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
@ -31,7 +36,7 @@ useHead(head);
|
|||
<div class="tool-header">
|
||||
<div flex flex-nowrap items-center justify-between>
|
||||
<n-h1>
|
||||
{{ route.meta.name }}
|
||||
{{ toolTitle }}
|
||||
</n-h1>
|
||||
|
||||
<div>
|
||||
|
@ -42,7 +47,7 @@ useHead(head);
|
|||
<div class="separator" />
|
||||
|
||||
<div class="description">
|
||||
{{ route.meta.description }}
|
||||
{{ toolDescription }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -11,6 +11,7 @@ import { naive } from './plugins/naive.plugin';
|
|||
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import { i18nPlugin } from './plugins/i18n.plugin';
|
||||
|
||||
registerSW();
|
||||
|
||||
|
@ -18,6 +19,7 @@ const app = createApp(App);
|
|||
|
||||
app.use(createPinia());
|
||||
app.use(createHead());
|
||||
app.use(i18nPlugin);
|
||||
app.use(router);
|
||||
app.use(naive);
|
||||
app.use(plausible);
|
||||
|
|
82
src/modules/command-palette/command-palette.store.ts
Normal file
82
src/modules/command-palette/command-palette.store.ts
Normal 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,
|
||||
};
|
||||
});
|
14
src/modules/command-palette/command-palette.types.ts
Normal file
14
src/modules/command-palette/command-palette.types.ts
Normal 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
|
||||
}
|
153
src/modules/command-palette/command-palette.vue
Normal file
153
src/modules/command-palette/command-palette.vue
Normal 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' }} + 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>
|
|
@ -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>
|
|
@ -14,12 +14,12 @@ const { tracker } = useTracker();
|
|||
<c-link href="https://github.com/CorentinTh" target="_blank" rel="noopener">
|
||||
Corentin Thomasset
|
||||
</c-link>,
|
||||
aggregates useful tools for developer and people working in IT. If you find it useful, please fell free to share
|
||||
it to people you think may find it useful too and don't forget to pin it in your shortcut bar !
|
||||
aggregates useful tools for developer and people working in IT. If you find it useful, please feel free to share
|
||||
it to people you think may find it useful too and don't forget to bookmark it in your shortcut bar!
|
||||
</n-p>
|
||||
<n-p>
|
||||
IT Tools is open-source (under the MIT license) and free, and will always be, but it cost 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
|
||||
IT Tools is open-source (under the MIT license) and free, and will always be, but it costs me money to host and
|
||||
renew the domain name. If you want to support my work, and encourage me to add more tools, please consider
|
||||
supporting by
|
||||
<c-link
|
||||
href="https://www.buymeacoffee.com/cthmsst"
|
||||
|
@ -33,8 +33,8 @@ const { tracker } = useTracker();
|
|||
|
||||
<n-h2>Technologies</n-h2>
|
||||
<n-p>
|
||||
IT Tools is made in Vue JS (vue 3) with the the naive-ui component library and is hosted and continuously deployed
|
||||
by Vercel. Third party open-source libraries are used in some tools, you may find the complete list in the
|
||||
IT Tools is made in Vue.js (Vue 3) with the the Naive UI component library and is hosted and continuously deployed
|
||||
by Vercel. Third-party open-source libraries are used in some tools, you may find the complete list in the
|
||||
<c-link href="https://github.com/CorentinTh/it-tools/blob/main/package.json" rel="noopener" target="_blank">
|
||||
package.json
|
||||
</c-link>
|
||||
|
@ -43,10 +43,10 @@ const { tracker } = useTracker();
|
|||
|
||||
<n-h2>Found a bug? A tool is missing?</n-h2>
|
||||
<n-p>
|
||||
If you need a tool that is currently not present here, and you think can be 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
|
||||
<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"
|
||||
target="_blank"
|
||||
>
|
||||
|
@ -55,9 +55,9 @@ const { tracker } = useTracker();
|
|||
in the GitHub repository.
|
||||
</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
|
||||
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"
|
||||
target="_blank"
|
||||
>
|
||||
|
|
|
@ -9,6 +9,7 @@ import { config } from '@/config';
|
|||
const toolStore = useToolStore();
|
||||
|
||||
useHead({ title: 'IT Tools - Handy online tools for developers' });
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -48,7 +49,7 @@ useHead({ title: 'IT Tools - Handy online tools for developers' });
|
|||
</transition>
|
||||
|
||||
<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-gi v-for="tool in toolStore.newTools" :key="tool.name">
|
||||
<ToolCard :tool="tool" />
|
||||
|
|
15
src/plugins/i18n.plugin.ts
Normal file
15
src/plugins/i18n.plugin.ts
Normal 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);
|
||||
},
|
||||
};
|
|
@ -1,10 +1,11 @@
|
|||
import { useMediaQuery, useStorage } from '@vueuse/core';
|
||||
import { useDark, useMediaQuery, useStorage, useToggle } from '@vueuse/core';
|
||||
import { defineStore } from 'pinia';
|
||||
import { type Ref, watch } from 'vue';
|
||||
|
||||
export const useStyleStore = defineStore('style', {
|
||||
state: () => {
|
||||
const isDarkTheme = useStorage('isDarkTheme', true) as Ref<boolean>;
|
||||
const isDarkTheme = useDark();
|
||||
const toggleDark = useToggle(isDarkTheme);
|
||||
const isSmallScreen = useMediaQuery('(max-width: 700px)');
|
||||
const isMenuCollapsed = useStorage('isMenuCollapsed', isSmallScreen.value) as Ref<boolean>;
|
||||
|
||||
|
@ -12,6 +13,7 @@ export const useStyleStore = defineStore('style', {
|
|||
|
||||
return {
|
||||
isDarkTheme,
|
||||
toggleDark,
|
||||
isMenuCollapsed,
|
||||
isSmallScreen,
|
||||
};
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import { Upload } from '@vicons/tabler';
|
||||
import { useBase64 } from '@vueuse/core';
|
||||
import type { UploadFileInfo } from 'naive-ui';
|
||||
import { type Ref, ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { useCopy } from '@/composable/copy';
|
||||
import { useDownloadFileFromBase64 } from '@/composable/downloadBase64';
|
||||
import { useValidation } from '@/composable/validation';
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useCopy } from '@/composable/copy';
|
||||
import { base64ToText, isValidBase64, textToBase64 } from '@/utils/base64';
|
||||
import { withDefaultOnError } from '@/utils/defaults';
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useCopy } from '@/composable/copy';
|
||||
import { textToBase64 } from '@/utils/base64';
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { compareSync, hashSync } from 'bcryptjs';
|
||||
import { useThemeVars } from 'naive-ui';
|
||||
import { useCopy } from '@/composable/copy';
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import { Plus, Trash } from '@vicons/tabler';
|
||||
import { useClipboard, useStorage } from '@vueuse/core';
|
||||
import _ from 'lodash';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { arrayToMarkdownTable, computeAverage, computeVariance } from './benchmark-builder.models';
|
||||
import DynamicValues from './dynamic-values.vue';
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ import {
|
|||
spanishWordList,
|
||||
} from '@it-tools/bip39';
|
||||
import { Copy, Refresh } from '@vicons/tabler';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useCopy } from '@/composable/copy';
|
||||
import { useValidation } from '@/composable/validation';
|
||||
import { isNotThrowing } from '@/utils/boolean';
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
camelCase,
|
||||
capitalCase,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { computeChmodOctalRepresentation } from './chmod-calculator.service';
|
||||
import { computeChmodOctalRepresentation, computeChmodSymbolicRepresentation } from './chmod-calculator.service';
|
||||
|
||||
describe('chmod-calculator', () => {
|
||||
describe('computeChmodOctalRepresentation', () => {
|
||||
|
@ -64,5 +64,67 @@ describe('chmod-calculator', () => {
|
|||
}),
|
||||
).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-');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import _ from 'lodash';
|
||||
import type { GroupPermissions, Permissions } from './chmod-calculator.types';
|
||||
|
||||
export { computeChmodOctalRepresentation };
|
||||
export { computeChmodOctalRepresentation, computeChmodSymbolicRepresentation };
|
||||
|
||||
function computeChmodOctalRepresentation({ permissions }: { permissions: Permissions }): string {
|
||||
const permissionValue = { read: 4, write: 2, execute: 1 };
|
||||
|
@ -15,3 +15,16 @@ function computeChmodOctalRepresentation({ permissions }: { permissions: Permiss
|
|||
getGroupPermissionValue(permissions.public),
|
||||
].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('');
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import { useThemeVars } from 'naive-ui';
|
||||
import { computed, ref } from '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';
|
||||
|
||||
|
@ -22,6 +22,7 @@ const permissions = ref({
|
|||
});
|
||||
|
||||
const octal = computed(() => computeChmodOctalRepresentation({ permissions: permissions.value }));
|
||||
const symbolic = computed(() => computeChmodSymbolicRepresentation({ permissions: permissions.value }));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -57,6 +58,9 @@ const octal = computed(() => computeChmodOctalRepresentation({ permissions: perm
|
|||
<div class="octal-result">
|
||||
{{ octal }}
|
||||
</div>
|
||||
<div class="octal-result">
|
||||
{{ symbolic }}
|
||||
</div>
|
||||
|
||||
<InputCopyable :value="`chmod ${octal} path`" readonly />
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { useRafFn } from '@vueuse/core';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { formatMs } from './chronometer.service';
|
||||
|
||||
const isRunning = ref(false);
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { colord, extend } from 'colord';
|
||||
|
||||
import cmykPlugin from 'colord/plugins/cmyk';
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import cronstrue from 'cronstrue';
|
||||
import { isValidCron } from 'cron-validator';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useStyleStore } from '@/stores/style.store';
|
||||
|
||||
function isCronValid(v: string) {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { useWindowSize } from '@vueuse/core';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const { width, height } = useWindowSize();
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { MessageType, composerize } from 'composerize-ts';
|
||||
import { withDefaultOnError } from '@/utils/defaults';
|
||||
import { useDownloadFileFromBase64 } from '@/composable/downloadBase64';
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { AES, RC4, Rabbit, TripleDES, enc } from 'crypto-js';
|
||||
|
||||
const algos = { AES, TripleDES, Rabbit, RC4 };
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
import { addMilliseconds, formatRelative } from 'date-fns';
|
||||
|
||||
import { enGB } from 'date-fns/locale';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { formatMsDuration } from './eta-calculator.service';
|
||||
|
||||
const unitCount = ref(3 * 62);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { useThemeVars } from 'naive-ui';
|
||||
import Memo from './git-memo.md';
|
||||
import Memo from './git-memo.content.md';
|
||||
|
||||
const themeVars = useThemeVars();
|
||||
</script>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import type { lib } 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 { convertHexToBin } from './hash-text.service';
|
||||
import { useQueryParam } from '@/composable/queryParams';
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
HmacSHA512,
|
||||
enc,
|
||||
} from 'crypto-js';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { convertHexToBin } from '../hash-text/hash-text.service';
|
||||
import { useCopy } from '@/composable/copy';
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { escape, unescape } from 'lodash';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useCopy } from '@/composable/copy';
|
||||
|
||||
const escapeInput = ref('<title>IT Tool</title>');
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<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 { icon, title, action, isActive } = toRefs(props);
|
||||
|
|
|
@ -18,7 +18,7 @@ import {
|
|||
Strikethrough,
|
||||
TextWrap,
|
||||
} from '@vicons/tabler';
|
||||
import { type Component, toRefs } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
import MenuBarItem from './menu-bar-item.vue';
|
||||
|
||||
const props = defineProps<{ editor: Editor }>();
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
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 jsonToCsv } from './json-to-csv';
|
||||
import { tool as cameraRecorder } from './camera-recorder';
|
||||
import { tool as listConverter } from './list-converter';
|
||||
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 cypher } from './encryption';
|
||||
import { tool as etaCalculator } from './eta-calculator';
|
||||
import { tool as percentageCalculator } from './percentage-calculator';
|
||||
import { tool as gitMemo } from './git-memo';
|
||||
import { tool as hashText } from './hash-text';
|
||||
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 uuidGenerator } from './uuid-generator';
|
||||
import { tool as macAddressLookup } from './mac-address-lookup';
|
||||
import { tool as xmlFormatter } from './xml-formatter';
|
||||
|
||||
export const toolsByCategory: ToolCategory[] = [
|
||||
{
|
||||
|
@ -111,9 +114,11 @@ export const toolsByCategory: ToolCategory[] = [
|
|||
crontabGenerator,
|
||||
jsonViewer,
|
||||
jsonMinify,
|
||||
jsonToCsv,
|
||||
sqlPrettify,
|
||||
chmodCalculator,
|
||||
dockerRunToDockerComposeConverter,
|
||||
xmlFormatter,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -122,7 +127,7 @@ export const toolsByCategory: ToolCategory[] = [
|
|||
},
|
||||
{
|
||||
name: 'Math',
|
||||
components: [mathEvaluator, etaCalculator],
|
||||
components: [mathEvaluator, etaCalculator, percentageCalculator],
|
||||
},
|
||||
{
|
||||
name: 'Measurement',
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import InputCopyable from '../../components/InputCopyable.vue';
|
||||
import { convertBase } from './integer-base-converter.model';
|
||||
import { useStyleStore } from '@/stores/style.store';
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Netmask } from 'netmask';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { ArrowLeft, ArrowRight } from '@vicons/tabler';
|
||||
|
|
12
src/tools/json-to-csv/index.ts
Normal file
12
src/tools/json-to-csv/index.ts
Normal 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'),
|
||||
});
|
29
src/tools/json-to-csv/json-to-csv.e2e.spec.ts
Normal file
29
src/tools/json-to-csv/json-to-csv.e2e.spec.ts
Normal 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(),
|
||||
);
|
||||
});
|
||||
});
|
89
src/tools/json-to-csv/json-to-csv.service.test.ts
Normal file
89
src/tools/json-to-csv/json-to-csv.service.test.ts
Normal 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"
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
35
src/tools/json-to-csv/json-to-csv.service.ts
Normal file
35
src/tools/json-to-csv/json-to-csv.service.ts
Normal 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');
|
||||
}
|
32
src/tools/json-to-csv/json-to-csv.vue
Normal file
32
src/tools/json-to-csv/json-to-csv.vue
Normal 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>
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import JSON5 from 'json5';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { formatJson } from './json.models';
|
||||
|
|
|
@ -13,7 +13,7 @@ function sortObjectKeys<T>(obj: T): T {
|
|||
}
|
||||
|
||||
return Object.keys(obj)
|
||||
.sort()
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.reduce((sortedObj, key) => {
|
||||
sortedObj[key] = sortObjectKeys((obj as Record<string, unknown>)[key]);
|
||||
return sortedObj;
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { decodeJwt } from './jwt-parser.service';
|
||||
import { useValidation } from '@/composable/validation';
|
||||
import { isNotThrowing } from '@/utils/boolean';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import InputCopyable from '../../components/InputCopyable.vue';
|
||||
|
||||
const event = ref<KeyboardEvent>();
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { generateLoremIpsum } from './lorem-ipsum-generator.service';
|
||||
import { useCopy } from '@/composable/copy';
|
||||
import { randIntFromInterval } from '@/utils/random';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { evaluate } from 'mathjs';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { withDefaultOnError } from '@/utils/defaults';
|
||||
|
||||
const expression = ref('');
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { generateMeta } from '@it-tools/oggen';
|
||||
import _ from 'lodash';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { image, ogSchemas, twitter, website } from './og-schemas';
|
||||
import type { OGSchemaType, OGSchemaTypeElementSelect } from './OGSchemaType.type';
|
||||
import TextareaCopyable from '@/components/TextareaCopyable.vue';
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
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 }));
|
||||
|
||||
|
@ -25,7 +24,7 @@ const mimeTypeFound = computed(() => (selectedExtension.value ? extensionToMimeT
|
|||
Mime type to extension
|
||||
</n-h2>
|
||||
<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>
|
||||
<n-form-item>
|
||||
<n-select
|
||||
|
@ -61,7 +60,7 @@ const mimeTypeFound = computed(() => (selectedExtension.value ? extensionToMimeT
|
|||
File extension to mime type
|
||||
</n-h2>
|
||||
<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>
|
||||
<n-form-item>
|
||||
<n-select
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useTimestamp } from '@vueuse/core';
|
||||
import { useThemeVars } from 'naive-ui';
|
||||
import { useQRCode } from '../qr-code-generator/useQRCode';
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { toRefs } from 'vue';
|
||||
|
||||
const props = defineProps<{ tokens: { previous: string; current: string; next: string } }>();
|
||||
const { copy: copyPrevious, copied: previousCopied } = useClipboard();
|
||||
|
|
12
src/tools/percentage-calculator/index.ts
Normal file
12
src/tools/percentage-calculator/index.ts
Normal 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'),
|
||||
});
|
|
@ -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('');
|
||||
});
|
||||
});
|
78
src/tools/percentage-calculator/percentage-calculator.vue
Normal file
78
src/tools/percentage-calculator/percentage-calculator.vue
Normal 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>
|
|
@ -86,7 +86,7 @@ const countriesOptions = getCountries().map(code => ({
|
|||
<template>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<c-input-text
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import type { QRCodeErrorCorrectionLevel } from 'qrcode';
|
||||
import { useQRCode } from './useQRCode';
|
||||
import { useDownloadFileFromBase64 } from '@/composable/downloadBase64';
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import {
|
||||
MAX_ARABIC_TO_ROMAN,
|
||||
MIN_ARABIC_TO_ROMAN,
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { generateKeyPair } from './rsa-key-pair-generator.service';
|
||||
import TextareaCopyable from '@/components/TextareaCopyable.vue';
|
||||
import { withDefaultOnErrorAsync } from '@/utils/defaults';
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import slugify from '@sindresorhus/slugify';
|
||||
import { withDefaultOnError } from '@/utils/defaults';
|
||||
import { useCopy } from '@/composable/copy';
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { type FormatFnOptions, format as formatSQL } from 'sql-formatter';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import TextareaCopyable from '@/components/TextareaCopyable.vue';
|
||||
import { useStyleStore } from '@/stores/style.store';
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import TextareaCopyable from '@/components/TextareaCopyable.vue';
|
||||
import { useCopy } from '@/composable/copy';
|
||||
import { useDownloadFileFromBase64 } from '@/composable/downloadBase64';
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import _ from 'lodash';
|
||||
import { reactive } from 'vue';
|
||||
import {
|
||||
convertCelsiusToKelvin,
|
||||
convertDelisleToKelvin,
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { getStringSizeInBytes } from './text-statistics.service';
|
||||
import { formatBytes } from '@/utils/convert';
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { textToNatoAlphabet } from './text-to-nato-alphabet.service';
|
||||
import { useCopy } from '@/composable/copy';
|
||||
|
||||
|
|
9
src/tools/token-generator/locales/en.yml
Normal file
9
src/tools/token-generator/locales/en.yml
Normal 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 (!-;...)
|
9
src/tools/token-generator/locales/fr.yml
Normal file
9
src/tools/token-generator/locales/fr.yml
Normal 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 (!-;...)
|
|
@ -9,6 +9,7 @@ const withUppercase = useQueryParam({ name: 'uppercase', defaultValue: true });
|
|||
const withLowercase = useQueryParam({ name: 'lowercase', defaultValue: true });
|
||||
const withNumbers = useQueryParam({ name: 'numbers', defaultValue: true });
|
||||
const withSymbols = useQueryParam({ name: 'symbols', defaultValue: false });
|
||||
const { t } = useI18n();
|
||||
|
||||
const [token, refreshToken] = computedRefreshable(() =>
|
||||
createToken({
|
||||
|
@ -29,21 +30,21 @@ const { copy } = useCopy({ source: token, text: 'Token copied to the clipboard'
|
|||
<n-form label-placement="left" label-width="140">
|
||||
<div flex justify-center>
|
||||
<div>
|
||||
<n-form-item label="Uppercase (ABC...)">
|
||||
<n-form-item :label="t('tools.token-generator.uppercase')">
|
||||
<n-switch v-model:value="withUppercase" />
|
||||
</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-form-item>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<n-form-item label="Numbers (012...)">
|
||||
<n-form-item :label="t('tools.token-generator.numbers')">
|
||||
<n-switch v-model:value="withNumbers" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="Symbols (;-!...)">
|
||||
<n-form-item :label="t('tools.token-generator.symbols')">
|
||||
<n-switch v-model:value="withSymbols" />
|
||||
</n-form-item>
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useCopy } from '@/composable/copy';
|
||||
import { useValidation } from '@/composable/validation';
|
||||
import { isNotThrowing } from '@/utils/boolean';
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import InputCopyable from '../../components/InputCopyable.vue';
|
||||
import { isNotThrowing } from '@/utils/boolean';
|
||||
import { withDefaultOnError } from '@/utils/defaults';
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { Adjustments, Browser, Cpu, Devices, Engine } from '@vicons/tabler';
|
||||
import UserAgentResultCards from './user-agent-result-cards.vue';
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { toRefs } from 'vue';
|
||||
import type { UAParser } from 'ua-parser-js';
|
||||
import type { UserAgentResultSection } from './user-agent-parser.types';
|
||||
|
||||
|
|
12
src/tools/xml-formatter/index.ts
Normal file
12
src/tools/xml-formatter/index.ts
Normal 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'),
|
||||
});
|
23
src/tools/xml-formatter/xml-formatter.e2e.spec.ts
Normal file
23
src/tools/xml-formatter/xml-formatter.e2e.spec.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue