mirror of
https://github.com/CorentinTh/it-tools.git
synced 2025-05-08 15:15:02 -04:00
Merge branch 'main' into html-md-converter
# Conflicts: # components.d.ts # package.json # pnpm-lock.yaml
This commit is contained in:
commit
dc21e503c5
118 changed files with 3814 additions and 1859 deletions
|
@ -11,6 +11,13 @@ const styleStore = useStyleStore();
|
|||
|
||||
const theme = computed(() => (styleStore.isDarkTheme ? darkTheme : null));
|
||||
const themeOverrides = computed(() => (styleStore.isDarkTheme ? darkThemeOverrides : lightThemeOverrides));
|
||||
|
||||
const { locale } = useI18n();
|
||||
|
||||
syncRef(
|
||||
locale,
|
||||
useStorage('locale', locale),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
@ -36,7 +36,7 @@ const menuOptions = computed(() =>
|
|||
tools: components.map(tool => ({
|
||||
label: makeLabel(tool),
|
||||
icon: makeIcon(tool),
|
||||
key: tool.name,
|
||||
key: tool.path,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
|
@ -62,7 +62,7 @@ const themeVars = useThemeVars();
|
|||
|
||||
<n-menu
|
||||
class="menu"
|
||||
:value="route.name as string"
|
||||
:value="route.path"
|
||||
:collapsed-width="64"
|
||||
:collapsed-icon-size="22"
|
||||
:options="tools"
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { FavoriteFilled } from '@vicons/material';
|
||||
|
||||
import { useToolStore } from '@/tools/tools.store';
|
||||
import type { Tool } from '@/tools/tools.types';
|
||||
|
||||
|
@ -26,18 +24,15 @@ function toggleFavorite(event: MouseEvent) {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<c-button
|
||||
variant="text"
|
||||
circle
|
||||
:type="buttonType"
|
||||
:style="{ opacity: isFavorite ? 1 : 0.2 }"
|
||||
@click="toggleFavorite"
|
||||
>
|
||||
<n-icon :component="FavoriteFilled" />
|
||||
</c-button>
|
||||
</template>
|
||||
{{ isFavorite ? 'Remove from favorites' : 'Add to favorites' }}
|
||||
</n-tooltip>
|
||||
<c-tooltip :tooltip="isFavorite ? $t('favoriteButton.remove') : $t('favoriteButton.add') ">
|
||||
<c-button
|
||||
variant="text"
|
||||
circle
|
||||
:type="buttonType"
|
||||
:style="{ opacity: isFavorite ? 1 : 0.2 }"
|
||||
@click="toggleFavorite"
|
||||
>
|
||||
<icon-mdi-heart />
|
||||
</c-button>
|
||||
</c-tooltip>
|
||||
</template>
|
||||
|
|
|
@ -13,14 +13,11 @@ const tooltipText = computed(() => isJustCopied.value ? 'Copied!' : 'Copy to cli
|
|||
<template>
|
||||
<c-input-text v-model:value="value">
|
||||
<template #suffix>
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<c-button circle variant="text" size="small" @click="copy()">
|
||||
<icon-mdi-content-copy />
|
||||
</c-button>
|
||||
</template>
|
||||
{{ tooltipText }}
|
||||
</n-tooltip>
|
||||
<c-tooltip :tooltip="tooltipText">
|
||||
<c-button circle variant="text" size="small" @click="copy()">
|
||||
<icon-mdi-content-copy />
|
||||
</c-button>
|
||||
</c-tooltip>
|
||||
</template>
|
||||
</c-input-text>
|
||||
</template>
|
||||
|
|
|
@ -7,56 +7,43 @@ const { isDarkTheme } = toRefs(styleStore);
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<c-button
|
||||
circle
|
||||
variant="text"
|
||||
href="https://github.com/CorentinTh/it-tools"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="IT-Tools' GitHub repository"
|
||||
>
|
||||
<n-icon size="25" :component="BrandGithub" />
|
||||
</c-button>
|
||||
</template>
|
||||
Github repository
|
||||
</n-tooltip>
|
||||
<c-tooltip :tooltip="$t('home.nav.github')" position="bottom">
|
||||
<c-button
|
||||
circle
|
||||
variant="text"
|
||||
href="https://github.com/CorentinTh/it-tools"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="$t('home.nav.githubRepository')"
|
||||
>
|
||||
<n-icon size="25" :component="BrandGithub" />
|
||||
</c-button>
|
||||
</c-tooltip>
|
||||
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<c-button
|
||||
circle
|
||||
variant="text"
|
||||
href="https://twitter.com/ittoolsdottech"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
aria-label="IT Tools' Twitter account"
|
||||
>
|
||||
<n-icon size="25" :component="BrandTwitter" />
|
||||
</c-button>
|
||||
</template>
|
||||
IT Tools' Twitter account
|
||||
</n-tooltip>
|
||||
<c-tooltip :tooltip="$t('home.nav.twitter')" position="bottom">
|
||||
<c-button
|
||||
circle
|
||||
variant="text"
|
||||
href="https://twitter.com/ittoolsdottech"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
:aria-label="$t('home.nav.twitterAccount')"
|
||||
>
|
||||
<n-icon size="25" :component="BrandTwitter" />
|
||||
</c-button>
|
||||
</c-tooltip>
|
||||
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<c-button circle variant="text" to="/about" aria-label="About">
|
||||
<n-icon size="25" :component="InfoCircle" />
|
||||
</c-button>
|
||||
</template>
|
||||
About
|
||||
</n-tooltip>
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<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>
|
||||
</template>
|
||||
<span v-if="isDarkTheme">Light mode</span>
|
||||
<span v-else>Dark mode</span>
|
||||
</n-tooltip>
|
||||
<c-tooltip :tooltip="$t('home.nav.about')" position="bottom">
|
||||
<c-button circle variant="text" to="/about" :aria-label="$t('home.nav.aboutLabel')">
|
||||
<n-icon size="25" :component="InfoCircle" />
|
||||
</c-button>
|
||||
</c-tooltip>
|
||||
<c-tooltip :tooltip="isDarkTheme ? $t('home.nav.lightMode') : $t('home.nav.darkMode')" position="bottom">
|
||||
<c-button circle variant="text" :aria-label="$t('home.nav.mode')" @click="() => styleStore.toggleDark()">
|
||||
<n-icon v-if="isDarkTheme" size="25" :component="Sun" />
|
||||
<n-icon v-else size="25" :component="Moon" />
|
||||
</c-button>
|
||||
</c-tooltip>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
|
|
|
@ -11,17 +11,7 @@ const tooltipText = computed(() => isJustCopied.value ? 'Copied!' : initialText)
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<span class="value" @click="copy()">{{ value }}</span>
|
||||
</template>
|
||||
{{ tooltipText }}
|
||||
</n-tooltip>
|
||||
<c-tooltip :tooltip="tooltipText">
|
||||
<span cursor-pointer font-mono @click="copy()">{{ value }}</span>
|
||||
</c-tooltip>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.value {
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -40,7 +40,7 @@ const tooltipText = computed(() => isJustCopied.value ? 'Copied!' : copyMessage.
|
|||
|
||||
<template>
|
||||
<div style="overflow-x: hidden; width: 100%">
|
||||
<c-card class="result-card">
|
||||
<c-card relative>
|
||||
<n-scrollbar
|
||||
x-scrollable
|
||||
trigger="none"
|
||||
|
@ -50,16 +50,13 @@ const tooltipText = computed(() => isJustCopied.value ? 'Copied!' : copyMessage.
|
|||
<n-code :code="value" :language="language" :trim="false" data-test-id="area-content" />
|
||||
</n-config-provider>
|
||||
</n-scrollbar>
|
||||
<n-tooltip v-if="value" trigger="hover">
|
||||
<template #trigger>
|
||||
<div class="copy-button" :class="[copyPlacement]">
|
||||
<c-button circle important:h-10 important:w-10 @click="copy()">
|
||||
<n-icon size="22" :component="Copy" />
|
||||
</c-button>
|
||||
</div>
|
||||
</template>
|
||||
<span>{{ tooltipText }}</span>
|
||||
</n-tooltip>
|
||||
<div absolute right-10px top-10px>
|
||||
<c-tooltip v-if="value" :tooltip="tooltipText" position="left">
|
||||
<c-button circle important:h-10 important:w-10 @click="copy()">
|
||||
<n-icon size="22" :component="Copy" />
|
||||
</c-button>
|
||||
</c-tooltip>
|
||||
</div>
|
||||
</c-card>
|
||||
<div v-if="copyPlacement === 'outside'" mt-4 flex justify-center>
|
||||
<c-button @click="copy()">
|
||||
|
@ -74,25 +71,4 @@ const tooltipText = computed(() => isJustCopied.value ? 'Copied!' : copyMessage.
|
|||
padding-bottom: 10px;
|
||||
margin-bottom: -10px;
|
||||
}
|
||||
.result-card {
|
||||
position: relative;
|
||||
.copy-button {
|
||||
position: absolute;
|
||||
opacity: 1;
|
||||
|
||||
&.top-right {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
&.bottom-right {
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
&.outside,
|
||||
&.none {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -26,7 +26,7 @@ const appTheme = useAppTheme();
|
|||
:bordered="false"
|
||||
:color="{ color: theme.primaryColor, textColor: theme.tagColor }"
|
||||
>
|
||||
New
|
||||
{{ $t('toolCard.new') }}
|
||||
</n-tag>
|
||||
|
||||
<FavoriteButton :tool="tool" />
|
||||
|
|
22
src/composable/computed/catchedComputed.ts
Normal file
22
src/composable/computed/catchedComputed.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { type Ref, ref, watchEffect } from 'vue';
|
||||
|
||||
export { computedCatch };
|
||||
|
||||
function computedCatch<T, D>(getter: () => T, { defaultValue }: { defaultValue: D; defaultErrorMessage?: string }): [Ref<T | D>, Ref<string | undefined>];
|
||||
function computedCatch<T, D>(getter: () => T, { defaultValue, defaultErrorMessage = 'Unknown error' }: { defaultValue?: D; defaultErrorMessage?: string } = {}) {
|
||||
const error = ref<string | undefined>();
|
||||
const value = ref<T | D | undefined>();
|
||||
|
||||
watchEffect(() => {
|
||||
try {
|
||||
error.value = undefined;
|
||||
value.value = getter();
|
||||
}
|
||||
catch (err) {
|
||||
error.value = err instanceof Error ? err.message : err?.toString() ?? defaultErrorMessage;
|
||||
value.value = defaultValue;
|
||||
}
|
||||
});
|
||||
|
||||
return [value, error] as const;
|
||||
}
|
32
src/composable/downloadBase64.test.ts
Normal file
32
src/composable/downloadBase64.test.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { getMimeTypeFromBase64 } from './downloadBase64';
|
||||
|
||||
describe('downloadBase64', () => {
|
||||
describe('getMimeTypeFromBase64', () => {
|
||||
it('when the base64 string has a data URI, it returns the mime type', () => {
|
||||
expect(getMimeTypeFromBase64({ base64String: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA' })).to.deep.equal({ mimeType: 'image/png' });
|
||||
expect(getMimeTypeFromBase64({ base64String: 'data:image/jpg;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA' })).to.deep.equal({ mimeType: 'image/jpg' });
|
||||
});
|
||||
|
||||
it('when the base64 string has no data URI, it try to infer the mime type from the signature', () => {
|
||||
// https://en.wikipedia.org/wiki/List_of_file_signatures
|
||||
|
||||
// PNG
|
||||
expect(getMimeTypeFromBase64({ base64String: 'iVBORw0KGgoAAAANSUhEUgAAAAUA' })).to.deep.equal({ mimeType: 'image/png' });
|
||||
|
||||
// GIF
|
||||
expect(getMimeTypeFromBase64({ base64String: 'R0lGODdh' })).to.deep.equal({ mimeType: 'image/gif' });
|
||||
expect(getMimeTypeFromBase64({ base64String: 'R0lGODlh' })).to.deep.equal({ mimeType: 'image/gif' });
|
||||
|
||||
// JPG
|
||||
expect(getMimeTypeFromBase64({ base64String: '/9j/' })).to.deep.equal({ mimeType: 'image/jpg' });
|
||||
|
||||
// PDF
|
||||
expect(getMimeTypeFromBase64({ base64String: 'JVBERi0' })).to.deep.equal({ mimeType: 'application/pdf' });
|
||||
});
|
||||
|
||||
it('when the base64 string has no data URI and no signature, it returns an undefined mimeType', () => {
|
||||
expect(getMimeTypeFromBase64({ base64String: 'JVBERi' })).to.deep.equal({ mimeType: undefined });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,32 +1,60 @@
|
|||
import { extension as getExtensionFromMime } from 'mime-types';
|
||||
import type { Ref } from 'vue';
|
||||
import _ from 'lodash';
|
||||
|
||||
function getFileExtensionFromBase64({
|
||||
base64String,
|
||||
export { getMimeTypeFromBase64, useDownloadFileFromBase64 };
|
||||
|
||||
const commonMimeTypesSignatures = {
|
||||
'JVBERi0': 'application/pdf',
|
||||
'R0lGODdh': 'image/gif',
|
||||
'R0lGODlh': 'image/gif',
|
||||
'iVBORw0KGgo': 'image/png',
|
||||
'/9j/': 'image/jpg',
|
||||
};
|
||||
|
||||
function getMimeTypeFromBase64({ base64String }: { base64String: string }) {
|
||||
const [,mimeTypeFromBase64] = base64String.match(/data:(.*?);base64/i) ?? [];
|
||||
|
||||
if (mimeTypeFromBase64) {
|
||||
return { mimeType: mimeTypeFromBase64 };
|
||||
}
|
||||
|
||||
const inferredMimeType = _.find(commonMimeTypesSignatures, (_mimeType, signature) => base64String.startsWith(signature));
|
||||
|
||||
if (inferredMimeType) {
|
||||
return { mimeType: inferredMimeType };
|
||||
}
|
||||
|
||||
return { mimeType: undefined };
|
||||
}
|
||||
|
||||
function getFileExtensionFromMimeType({
|
||||
mimeType,
|
||||
defaultExtension = 'txt',
|
||||
}: {
|
||||
base64String: string
|
||||
mimeType: string | undefined
|
||||
defaultExtension?: string
|
||||
}) {
|
||||
const hasMimeType = base64String.match(/data:(.*?);base64/i);
|
||||
|
||||
if (hasMimeType) {
|
||||
return getExtensionFromMime(hasMimeType[1]) || defaultExtension;
|
||||
if (mimeType) {
|
||||
return getExtensionFromMime(mimeType) ?? defaultExtension;
|
||||
}
|
||||
|
||||
return defaultExtension;
|
||||
}
|
||||
|
||||
export function useDownloadFileFromBase64({ source, filename }: { source: Ref<string>; filename?: string }) {
|
||||
function useDownloadFileFromBase64({ source, filename }: { source: Ref<string>; filename?: string }) {
|
||||
return {
|
||||
download() {
|
||||
const base64String = source.value;
|
||||
|
||||
if (base64String === '') {
|
||||
if (source.value === '') {
|
||||
throw new Error('Base64 string is empty');
|
||||
}
|
||||
|
||||
const cleanFileName = filename ?? `file.${getFileExtensionFromBase64({ base64String })}`;
|
||||
const { mimeType } = getMimeTypeFromBase64({ base64String: source.value });
|
||||
const base64String = mimeType
|
||||
? source.value
|
||||
: `data:text/plain;base64,${source.value}`;
|
||||
|
||||
const cleanFileName = filename ?? `file.${getFileExtensionFromMimeType({ mimeType })}`;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = base64String;
|
||||
|
|
|
@ -4,10 +4,10 @@ import { NIcon, useThemeVars } from 'naive-ui';
|
|||
import { RouterLink } from 'vue-router';
|
||||
import { Heart, Home2, Menu2 } from '@vicons/tabler';
|
||||
|
||||
import { storeToRefs } from 'pinia';
|
||||
import HeroGradient from '../assets/hero-gradient.svg?component';
|
||||
import MenuLayout from '../components/MenuLayout.vue';
|
||||
import NavbarButtons from '../components/NavbarButtons.vue';
|
||||
import { toolsByCategory } from '@/tools';
|
||||
import { useStyleStore } from '@/stores/style.store';
|
||||
import { config } from '@/config';
|
||||
import type { ToolCategory } from '@/tools/tools.types';
|
||||
|
@ -21,12 +21,14 @@ const version = config.app.version;
|
|||
const commitSha = config.app.lastCommitSha.slice(0, 7);
|
||||
|
||||
const { tracker } = useTracker();
|
||||
const { t } = useI18n();
|
||||
|
||||
const toolStore = useToolStore();
|
||||
const { favoriteTools, toolsByCategory } = storeToRefs(toolStore);
|
||||
|
||||
const tools = computed<ToolCategory[]>(() => [
|
||||
...(toolStore.favoriteTools.length > 0 ? [{ name: 'Your favorite tools', components: toolStore.favoriteTools }] : []),
|
||||
...toolsByCategory,
|
||||
...(favoriteTools.value.length > 0 ? [{ name: t('tools.categories.favorite-tools'), components: favoriteTools.value }] : []),
|
||||
...toolsByCategory.value,
|
||||
]);
|
||||
</script>
|
||||
|
||||
|
@ -41,14 +43,18 @@ const tools = computed<ToolCategory[]>(() => [
|
|||
</div>
|
||||
<div class="divider" />
|
||||
<div class="subtitle">
|
||||
Handy tools for developers
|
||||
{{ $t('home.subtitle') }}
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<div class="sider-content">
|
||||
<div v-if="styleStore.isSmallScreen" flex justify-center>
|
||||
<NavbarButtons />
|
||||
<div v-if="styleStore.isSmallScreen" flex flex-col items-center>
|
||||
<locale-selector w="90%" />
|
||||
|
||||
<div flex justify-center>
|
||||
<NavbarButtons />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CollapsibleToolMenu :tools-by-category="tools" />
|
||||
|
@ -88,48 +94,46 @@ const tools = computed<ToolCategory[]>(() => [
|
|||
<c-button
|
||||
circle
|
||||
variant="text"
|
||||
aria-label="Toggle menu"
|
||||
:aria-label="$t('home.toggleMenu')"
|
||||
@click="styleStore.isMenuCollapsed = !styleStore.isMenuCollapsed"
|
||||
>
|
||||
<NIcon size="25" :component="Menu2" />
|
||||
</c-button>
|
||||
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<c-button to="/" circle variant="text" aria-label="Home">
|
||||
<NIcon size="25" :component="Home2" />
|
||||
</c-button>
|
||||
</template>
|
||||
Home
|
||||
</n-tooltip>
|
||||
<c-tooltip :tooltip="$t('home.home')" position="bottom">
|
||||
<c-button to="/" circle variant="text" :aria-label="$t('home.home')">
|
||||
<NIcon size="25" :component="Home2" />
|
||||
</c-button>
|
||||
</c-tooltip>
|
||||
|
||||
<c-button v-if="config.app.env === 'development'" to="/c-lib" circle variant="text" aria-label="UI Lib">
|
||||
<icon-mdi:brush-variant text-20px />
|
||||
</c-button>
|
||||
<c-tooltip :tooltip="$t('home.uiLib')" position="bottom">
|
||||
<c-button v-if="config.app.env === 'development'" to="/c-lib" circle variant="text" :aria-label="$t('home.uiLib')">
|
||||
<icon-mdi:brush-variant text-20px />
|
||||
</c-button>
|
||||
</c-tooltip>
|
||||
|
||||
<command-palette />
|
||||
|
||||
<locale-selector v-if="!styleStore.isSmallScreen" />
|
||||
|
||||
<div>
|
||||
<NavbarButtons v-if="!styleStore.isSmallScreen" />
|
||||
</div>
|
||||
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<c-button
|
||||
round
|
||||
href="https://www.buymeacoffee.com/cthmsst"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
class="support-button"
|
||||
:bordered="false"
|
||||
@click="() => tracker.trackEvent({ eventName: 'Support button clicked' })"
|
||||
>
|
||||
Buy me a coffee
|
||||
<NIcon v-if="!styleStore.isSmallScreen" :component="Heart" ml-2 />
|
||||
</c-button>
|
||||
</template>
|
||||
❤ Support IT Tools development !
|
||||
</n-tooltip>
|
||||
<c-tooltip position="bottom" :tooltip="$t('home.support')">
|
||||
<c-button
|
||||
round
|
||||
href="https://www.buymeacoffee.com/cthmsst"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
class="support-button"
|
||||
:bordered="false"
|
||||
@click="() => tracker.trackEvent({ eventName: 'Support button clicked' })"
|
||||
>
|
||||
{{ $t('home.buyMeACoffee') }}
|
||||
<NIcon v-if="!styleStore.isSmallScreen" :component="Heart" ml-2 />
|
||||
</c-button>
|
||||
</c-tooltip>
|
||||
</div>
|
||||
<slot />
|
||||
</template>
|
||||
|
|
|
@ -9,6 +9,7 @@ import SunIcon from '~icons/mdi/white-balance-sunny';
|
|||
import GithubIcon from '~icons/mdi/github';
|
||||
import BugIcon from '~icons/mdi/bug-outline';
|
||||
import DiceIcon from '~icons/mdi/dice-5';
|
||||
import InfoIcon from '~icons/mdi/information-outline';
|
||||
|
||||
export const useCommandPaletteStore = defineStore('command-palette', () => {
|
||||
const toolStore = useToolStore();
|
||||
|
@ -61,6 +62,14 @@ export const useCommandPaletteStore = defineStore('command-palette', () => {
|
|||
keywords: ['report', 'issue', 'bug', 'problem', 'error'],
|
||||
icon: BugIcon,
|
||||
},
|
||||
{
|
||||
name: 'About',
|
||||
description: 'Learn more about IT-Tools.',
|
||||
to: '/about',
|
||||
category: 'Pages',
|
||||
keywords: ['about', 'learn', 'more', 'info', 'information'],
|
||||
icon: InfoIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const { searchResult } = useFuzzySearch({
|
||||
|
|
|
@ -37,6 +37,7 @@ function open() {
|
|||
|
||||
function close() {
|
||||
isModalOpen.value = false;
|
||||
searchPrompt.value = '';
|
||||
}
|
||||
|
||||
const selectedOptionIndex = ref(0);
|
||||
|
@ -115,7 +116,7 @@ function activateOption(option: PaletteOption) {
|
|||
<span flex items-center gap-3 op-40>
|
||||
|
||||
<icon-mdi-search />
|
||||
Search...
|
||||
{{ $t('search.label') }}
|
||||
|
||||
<span hidden flex-1 border border-current border-op-40 rounded border-solid px-5px py-3px sm:inline>
|
||||
{{ isMac ? 'Cmd' : 'Ctrl' }} + K
|
||||
|
|
28
src/modules/i18n/components/locale-selector.vue
Normal file
28
src/modules/i18n/components/locale-selector.vue
Normal file
|
@ -0,0 +1,28 @@
|
|||
<script setup lang="ts">
|
||||
const { availableLocales, locale } = useI18n();
|
||||
|
||||
const localesLong: Record<string, string> = {
|
||||
en: 'English',
|
||||
es: 'Español',
|
||||
fr: 'Français',
|
||||
pt: 'Português',
|
||||
ru: 'Русский',
|
||||
zh: '中文',
|
||||
};
|
||||
|
||||
const localeOptions = computed(() =>
|
||||
availableLocales.map(locale => ({
|
||||
label: localesLong[locale] ?? locale,
|
||||
value: locale,
|
||||
})),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<c-select
|
||||
v-model:value="locale"
|
||||
:options="localeOptions"
|
||||
placeholder="Select a language"
|
||||
w-100px
|
||||
/>
|
||||
</template>
|
|
@ -11,17 +11,17 @@ useHead({ title: 'Page not found - IT Tools' });
|
|||
</span>
|
||||
|
||||
<h1 m-0 mt-3>
|
||||
404 Not Found
|
||||
{{ $t('404.notFound') }}
|
||||
</h1>
|
||||
<div mt-4 op-60>
|
||||
Sorry, this page does not seem to exist
|
||||
{{ $t('404.sorry') }}
|
||||
</div>
|
||||
<div mb-8 op-60>
|
||||
Maybe the cache is doing tricky things, try force-refreshing?
|
||||
{{ $t('404.maybe') }}
|
||||
</div>
|
||||
|
||||
<c-button to="/">
|
||||
Back home
|
||||
{{ $t('404.backHome') }}
|
||||
</c-button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,85 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import { useHead } from '@vueuse/head';
|
||||
import { useTracker } from '@/modules/tracker/tracker.services';
|
||||
|
||||
useHead({ title: 'About - IT Tools' });
|
||||
const { tracker } = useTracker();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="about-page">
|
||||
<n-h1>About</n-h1>
|
||||
<n-p>
|
||||
This wonderful website, made with ❤ by
|
||||
<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 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 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"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
@click="() => tracker.trackEvent({ eventName: 'Support button clicked' })"
|
||||
>
|
||||
sponsoring me
|
||||
</c-link>.
|
||||
</n-p>
|
||||
|
||||
<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
|
||||
<c-link href="https://github.com/CorentinTh/it-tools/blob/main/package.json" rel="noopener" target="_blank">
|
||||
package.json
|
||||
</c-link>
|
||||
file of the repository.
|
||||
</n-p>
|
||||
|
||||
<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 useful, you are welcome to submit a
|
||||
feature request in the
|
||||
<c-link
|
||||
href="https://github.com/CorentinTh/it-tools/issues/new/choose"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
issues section
|
||||
</c-link>
|
||||
in the GitHub repository.
|
||||
</n-p>
|
||||
<n-p>
|
||||
And if you found a bug, or something doesn't work as expected, please file a bug report in the
|
||||
<c-link
|
||||
href="https://github.com/CorentinTh/it-tools/issues/new/choose"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
issues section
|
||||
</c-link>
|
||||
in the GitHub repository.
|
||||
</n-p>
|
||||
</div>
|
||||
<c-markdown :markdown="$t('about.content')" mx-auto mt-50px max-w-600px />
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.about-page {
|
||||
max-width: 600px;
|
||||
margin: 50px auto;
|
||||
box-sizing: border-box;
|
||||
|
||||
.n-h2 {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.n-p {
|
||||
text-align: justify;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -17,21 +17,22 @@ const { t } = useI18n();
|
|||
<div class="grid-wrapper">
|
||||
<n-grid v-if="config.showBanner" x-gap="12" y-gap="12" cols="1 400:2 800:3 1200:4 2000:8">
|
||||
<n-gi>
|
||||
<ColoredCard title="You like it-tools?" :icon="Heart">
|
||||
Give us a star on
|
||||
<ColoredCard :title="$t('home.follow.title')" :icon="Heart">
|
||||
{{ $t('home.follow.p1') }}
|
||||
<a
|
||||
href="https://github.com/CorentinTh/it-tools"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
aria-label="IT-Tools' GitHub repository"
|
||||
:aria-label="$t('home.follow.githubRepository')"
|
||||
>GitHub</a>
|
||||
or follow us on
|
||||
{{ $t('home.follow.p2') }}
|
||||
<a
|
||||
href="https://twitter.com/ittoolsdottech"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
aria-label="IT-Tools' Twitter account"
|
||||
>Twitter</a>! Thank you
|
||||
:aria-label="$t('home.follow.twitterAccount')"
|
||||
>Twitter</a>.
|
||||
{{ $t('home.follow.thankYou') }}
|
||||
<n-icon :component="Heart" />
|
||||
</ColoredCard>
|
||||
</n-gi>
|
||||
|
@ -39,7 +40,7 @@ const { t } = useI18n();
|
|||
|
||||
<transition name="height">
|
||||
<div v-if="toolStore.favoriteTools.length > 0">
|
||||
<n-h3>Your favorite tools</n-h3>
|
||||
<n-h3>{{ $t('home.categories.favoriteTools') }}</n-h3>
|
||||
<n-grid x-gap="12" y-gap="12" cols="1 400:2 800:3 1200:4 2000:8">
|
||||
<n-gi v-for="tool in toolStore.favoriteTools" :key="tool.name">
|
||||
<ToolCard :tool="tool" />
|
||||
|
@ -57,7 +58,7 @@ const { t } = useI18n();
|
|||
</n-grid>
|
||||
</div>
|
||||
|
||||
<n-h3>All the tools</n-h3>
|
||||
<n-h3>{{ $t('home.categories.allTools') }}</n-h3>
|
||||
<n-grid x-gap="12" y-gap="12" cols="1 400:2 800:3 1200:4 2000:8">
|
||||
<n-gi v-for="tool in toolStore.tools" :key="tool.name">
|
||||
<transition>
|
||||
|
|
|
@ -29,3 +29,9 @@ export const i18nPlugin: Plugin = {
|
|||
app.use(i18n);
|
||||
},
|
||||
};
|
||||
|
||||
export const translate = function (localeKey: string) {
|
||||
// @ts-expect-error global
|
||||
const hasKey = i18n.global.te(localeKey, i18n.global.locale);
|
||||
return hasKey ? i18n.global.t(localeKey) : localeKey;
|
||||
};
|
||||
|
|
6
src/shims.d.ts
vendored
6
src/shims.d.ts
vendored
|
@ -32,4 +32,10 @@ declare module 'unicode-emoji-json' {
|
|||
}>;
|
||||
|
||||
export default emoji;
|
||||
}
|
||||
|
||||
declare module 'pdf-signature-reader' {
|
||||
const verifySignature: (pdf: ArrayBuffer) => ({signatures: SignatureInfo[]});
|
||||
|
||||
export default verifySignature;
|
||||
}
|
|
@ -1,7 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { Upload } from '@vicons/tabler';
|
||||
import { useBase64 } from '@vueuse/core';
|
||||
import type { UploadFileInfo } from 'naive-ui';
|
||||
import type { Ref } from 'vue';
|
||||
import { useCopy } from '@/composable/copy';
|
||||
import { useDownloadFileFromBase64 } from '@/composable/downloadBase64';
|
||||
|
@ -33,14 +31,12 @@ function downloadFile() {
|
|||
}
|
||||
}
|
||||
|
||||
const fileList = ref();
|
||||
const fileInput = ref() as Ref<File>;
|
||||
const { base64: fileBase64 } = useBase64(fileInput);
|
||||
const { copy: copyFileBase64 } = useCopy({ source: fileBase64, text: 'Base64 string copied to the clipboard' });
|
||||
|
||||
async function onUpload({ file: { file } }: { file: UploadFileInfo }) {
|
||||
async function onUpload(file: File) {
|
||||
if (file) {
|
||||
fileList.value = [];
|
||||
fileInput.value = file;
|
||||
}
|
||||
}
|
||||
|
@ -65,18 +61,8 @@ async function onUpload({ file: { file } }: { file: UploadFileInfo }) {
|
|||
</c-card>
|
||||
|
||||
<c-card title="File to base64">
|
||||
<n-upload v-model:file-list="fileList" :show-file-list="true" :on-before-upload="onUpload" list-type="image">
|
||||
<n-upload-dragger>
|
||||
<div mb-2>
|
||||
<n-icon size="35" :depth="3" :component="Upload" />
|
||||
</div>
|
||||
<div op-60>
|
||||
Click or drag a file to this area to upload
|
||||
</div>
|
||||
</n-upload-dragger>
|
||||
</n-upload>
|
||||
|
||||
<c-input-text :value="fileBase64" multiline readonly placeholder="File in base64 will be here" rows="5" mb-2 />
|
||||
<c-file-upload title="Drag and drop a file here, or click to select a file" @file-upload="onUpload" />
|
||||
<c-input-text :value="fileBase64" multiline readonly placeholder="File in base64 will be here" rows="5" my-2 />
|
||||
|
||||
<div flex justify-center>
|
||||
<c-button @click="copyFileBase64()">
|
||||
|
|
|
@ -23,6 +23,7 @@ const compareMatch = computed(() => compareSync(compareString.value, compareHash
|
|||
raw-text
|
||||
label="Your string: "
|
||||
label-position="left"
|
||||
label-align="right"
|
||||
label-width="120px"
|
||||
mb-2
|
||||
/>
|
||||
|
|
|
@ -51,11 +51,11 @@ const results = computed(() => {
|
|||
const { copy } = useCopy({ createToast: false });
|
||||
|
||||
const header = {
|
||||
position: 'Position',
|
||||
title: 'Suite',
|
||||
size: 'Samples',
|
||||
mean: 'Mean',
|
||||
variance: 'Variance',
|
||||
position: 'Position',
|
||||
};
|
||||
|
||||
function copyAsMarkdown() {
|
||||
|
@ -131,26 +131,8 @@ function copyAsBulletList() {
|
|||
</c-button>
|
||||
</div>
|
||||
|
||||
<n-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ header.position }}</th>
|
||||
<th>{{ header.title }}</th>
|
||||
<th>{{ header.size }}</th>
|
||||
<th>{{ header.mean }}</th>
|
||||
<th>{{ header.variance }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="{ title, size, mean, variance, position } of results" :key="title">
|
||||
<td>{{ position }}</td>
|
||||
<td>{{ title }}</td>
|
||||
<td>{{ size }}</td>
|
||||
<td>{{ mean }}</td>
|
||||
<td>{{ variance }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</n-table>
|
||||
<c-table :data="results" :headers="header" />
|
||||
|
||||
<div mt-5 flex justify-center gap-3>
|
||||
<c-button @click="copyAsMarkdown()">
|
||||
Copy as markdown table
|
||||
|
|
|
@ -39,14 +39,11 @@ function onInputEnter(index: number) {
|
|||
autofocus
|
||||
@keydown.enter="onInputEnter(index)"
|
||||
/>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<c-button circle variant="text" @click="values.splice(index, 1)">
|
||||
<n-icon :component="Trash" depth="3" size="18" />
|
||||
</c-button>
|
||||
</template>
|
||||
Delete value
|
||||
</n-tooltip>
|
||||
<c-tooltip tooltip="Delete this value">
|
||||
<c-button circle variant="text" @click="values.splice(index, 1)">
|
||||
<n-icon :component="Trash" depth="3" size="18" />
|
||||
</c-button>
|
||||
</c-tooltip>
|
||||
</div>
|
||||
|
||||
<c-button @click="addValue">
|
||||
|
|
|
@ -28,6 +28,7 @@ const permissionCannotBePrompted = ref(false);
|
|||
const {
|
||||
stream,
|
||||
start,
|
||||
stop,
|
||||
enabled: isMediaStreamAvailable,
|
||||
} = useUserMedia({
|
||||
constraints: computed(() => ({
|
||||
|
@ -83,6 +84,8 @@ watchEffect(() => {
|
|||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => stop());
|
||||
|
||||
async function requestPermissions() {
|
||||
try {
|
||||
await ensurePermissions();
|
||||
|
|
|
@ -23,11 +23,11 @@ const input = ref('lorem ipsum dolor sit amet');
|
|||
const formats = computed(() => [
|
||||
{
|
||||
label: 'Lowercase:',
|
||||
value: noCase(input.value, baseConfig).toLocaleLowerCase(),
|
||||
value: input.value.toLocaleLowerCase(),
|
||||
},
|
||||
{
|
||||
label: 'Uppercase:',
|
||||
value: noCase(input.value, baseConfig).toLocaleUpperCase(),
|
||||
value: input.value.toLocaleUpperCase(),
|
||||
},
|
||||
{
|
||||
label: 'Camelcase:',
|
||||
|
@ -73,6 +73,13 @@ const formats = computed(() => [
|
|||
label: 'Snakecase:',
|
||||
value: snakeCase(input.value, baseConfig),
|
||||
},
|
||||
{
|
||||
label: 'Mockingcase:',
|
||||
value: input.value
|
||||
.split('')
|
||||
.map((char, index) => (index % 2 === 0 ? char.toUpperCase() : char.toLowerCase()))
|
||||
.join(''),
|
||||
},
|
||||
]);
|
||||
|
||||
const inputLabelAlignmentConfig = {
|
||||
|
|
23
src/tools/color-converter/color-converter.e2e.spec.ts
Normal file
23
src/tools/color-converter/color-converter.e2e.spec.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Tool - Color converter', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/color-converter');
|
||||
});
|
||||
|
||||
test('Has title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Color converter - IT Tools');
|
||||
});
|
||||
|
||||
test('Color is converted from its name to other formats', async ({ page }) => {
|
||||
await page.getByTestId('input-name').fill('olive');
|
||||
|
||||
expect(await page.getByTestId('input-name').inputValue()).toEqual('olive');
|
||||
expect(await page.getByTestId('input-hex').inputValue()).toEqual('#808000');
|
||||
expect(await page.getByTestId('input-rgb').inputValue()).toEqual('rgb(128, 128, 0)');
|
||||
expect(await page.getByTestId('input-hsl').inputValue()).toEqual('hsl(60, 100%, 25%)');
|
||||
expect(await page.getByTestId('input-hwb').inputValue()).toEqual('hwb(60 0% 50%)');
|
||||
expect(await page.getByTestId('input-cmyk').inputValue()).toEqual('device-cmyk(0% 0% 100% 50%)');
|
||||
expect(await page.getByTestId('input-lch').inputValue()).toEqual('lch(52.15% 56.81 99.57)');
|
||||
});
|
||||
});
|
13
src/tools/color-converter/color-converter.models.test.ts
Normal file
13
src/tools/color-converter/color-converter.models.test.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { removeAlphaChannelWhenOpaque } from './color-converter.models';
|
||||
|
||||
describe('color-converter models', () => {
|
||||
describe('removeAlphaChannelWhenOpaque', () => {
|
||||
it('remove alpha channel of an hex color when it is opaque (alpha = 1)', () => {
|
||||
expect(removeAlphaChannelWhenOpaque('#000000ff')).toBe('#000000');
|
||||
expect(removeAlphaChannelWhenOpaque('#ffffffFF')).toBe('#ffffff');
|
||||
expect(removeAlphaChannelWhenOpaque('#000000FE')).toBe('#000000FE');
|
||||
expect(removeAlphaChannelWhenOpaque('#00000000')).toBe('#00000000');
|
||||
});
|
||||
});
|
||||
});
|
52
src/tools/color-converter/color-converter.models.ts
Normal file
52
src/tools/color-converter/color-converter.models.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { type Colord, colord } from 'colord';
|
||||
import { withDefaultOnError } from '@/utils/defaults';
|
||||
import { useValidation } from '@/composable/validation';
|
||||
|
||||
export { removeAlphaChannelWhenOpaque, buildColorFormat };
|
||||
|
||||
function removeAlphaChannelWhenOpaque(hexColor: string) {
|
||||
return hexColor.replace(/^(#(?:[0-9a-f]{3}){1,2})ff$/i, '$1');
|
||||
}
|
||||
|
||||
function buildColorFormat({
|
||||
label,
|
||||
parse = value => colord(value),
|
||||
format,
|
||||
placeholder,
|
||||
invalidMessage = `Invalid ${label.toLowerCase()} format.`,
|
||||
type = 'text',
|
||||
}: {
|
||||
label: string
|
||||
parse?: (value: string) => Colord
|
||||
format: (value: Colord) => string
|
||||
placeholder?: string
|
||||
invalidMessage?: string
|
||||
type?: 'text' | 'color-picker'
|
||||
}) {
|
||||
const value = ref('');
|
||||
|
||||
return {
|
||||
type,
|
||||
label,
|
||||
parse: (v: string) => withDefaultOnError(() => parse(v), undefined),
|
||||
format,
|
||||
placeholder,
|
||||
value,
|
||||
validation: useValidation({
|
||||
source: value,
|
||||
rules: [
|
||||
{
|
||||
message: invalidMessage,
|
||||
validator: v => withDefaultOnError(() => {
|
||||
if (v === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return parse(v).isValid();
|
||||
}, false),
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
};
|
||||
}
|
|
@ -1,87 +1,103 @@
|
|||
<script setup lang="ts">
|
||||
import type { Colord } from 'colord';
|
||||
import { colord, extend } from 'colord';
|
||||
|
||||
import _ from 'lodash';
|
||||
import cmykPlugin from 'colord/plugins/cmyk';
|
||||
import hwbPlugin from 'colord/plugins/hwb';
|
||||
import namesPlugin from 'colord/plugins/names';
|
||||
import lchPlugin from 'colord/plugins/lch';
|
||||
import InputCopyable from '../../components/InputCopyable.vue';
|
||||
import { buildColorFormat } from './color-converter.models';
|
||||
|
||||
extend([cmykPlugin, hwbPlugin, namesPlugin, lchPlugin]);
|
||||
|
||||
const name = ref('');
|
||||
const hex = ref('#1ea54cff');
|
||||
const rgb = ref('');
|
||||
const hsl = ref('');
|
||||
const hwb = ref('');
|
||||
const cmyk = ref('');
|
||||
const lch = ref('');
|
||||
const formats = {
|
||||
picker: buildColorFormat({
|
||||
label: 'color picker',
|
||||
format: (v: Colord) => v.toHex(),
|
||||
type: 'color-picker',
|
||||
}),
|
||||
hex: buildColorFormat({
|
||||
label: 'hex',
|
||||
format: (v: Colord) => v.toHex(),
|
||||
placeholder: 'e.g. #ff0000',
|
||||
}),
|
||||
rgb: buildColorFormat({
|
||||
label: 'rgb',
|
||||
format: (v: Colord) => v.toRgbString(),
|
||||
placeholder: 'e.g. rgb(255, 0, 0)',
|
||||
}),
|
||||
hsl: buildColorFormat({
|
||||
label: 'hsl',
|
||||
format: (v: Colord) => v.toHslString(),
|
||||
placeholder: 'e.g. hsl(0, 100%, 50%)',
|
||||
}),
|
||||
hwb: buildColorFormat({
|
||||
label: 'hwb',
|
||||
format: (v: Colord) => v.toHwbString(),
|
||||
placeholder: 'e.g. hwb(0, 0%, 0%)',
|
||||
}),
|
||||
lch: buildColorFormat({
|
||||
label: 'lch',
|
||||
format: (v: Colord) => v.toLchString(),
|
||||
placeholder: 'e.g. lch(53.24, 104.55, 40.85)',
|
||||
}),
|
||||
cmyk: buildColorFormat({
|
||||
label: 'cmyk',
|
||||
format: (v: Colord) => v.toCmykString(),
|
||||
placeholder: 'e.g. cmyk(0, 100%, 100%, 0)',
|
||||
}),
|
||||
name: buildColorFormat({
|
||||
label: 'name',
|
||||
format: (v: Colord) => v.toName({ closest: true }) ?? 'Unknown',
|
||||
placeholder: 'e.g. red',
|
||||
}),
|
||||
};
|
||||
|
||||
function onInputUpdated(value: string, omit: string) {
|
||||
try {
|
||||
const color = colord(value);
|
||||
updateColorValue(colord('#1ea54c'));
|
||||
|
||||
if (omit !== 'name') {
|
||||
name.value = color.toName({ closest: true }) ?? '';
|
||||
}
|
||||
if (omit !== 'hex') {
|
||||
hex.value = color.toHex();
|
||||
}
|
||||
if (omit !== 'rgb') {
|
||||
rgb.value = color.toRgbString();
|
||||
}
|
||||
if (omit !== 'hsl') {
|
||||
hsl.value = color.toHslString();
|
||||
}
|
||||
if (omit !== 'hwb') {
|
||||
hwb.value = color.toHwbString();
|
||||
}
|
||||
if (omit !== 'cmyk') {
|
||||
cmyk.value = color.toCmykString();
|
||||
}
|
||||
if (omit !== 'lch') {
|
||||
lch.value = color.toLchString();
|
||||
}
|
||||
function updateColorValue(value: Colord | undefined, omitLabel?: string) {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
catch {
|
||||
//
|
||||
|
||||
if (!value.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
_.forEach(formats, ({ value: valueRef, format }, key) => {
|
||||
if (key !== omitLabel) {
|
||||
valueRef.value = format(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onInputUpdated(hex.value, 'hex');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<c-card>
|
||||
<n-form label-width="100" label-placement="left">
|
||||
<n-form-item label="color picker:">
|
||||
<template v-for="({ label, parse, placeholder, validation, type }, key) in formats" :key="key">
|
||||
<input-copyable
|
||||
v-if="type === 'text'"
|
||||
v-model:value="formats[key].value.value"
|
||||
:test-id="`input-${key}`"
|
||||
:label="`${label}:`"
|
||||
label-position="left"
|
||||
label-width="100px"
|
||||
label-align="right"
|
||||
:placeholder="placeholder"
|
||||
:validation="validation"
|
||||
raw-text
|
||||
clearable
|
||||
mt-2
|
||||
@update:value="(v:string) => updateColorValue(parse(v), key)"
|
||||
/>
|
||||
|
||||
<n-form-item v-else-if="type === 'color-picker'" :label="`${label}:`" label-width="100" label-placement="left" :show-feedback="false">
|
||||
<n-color-picker
|
||||
v-model:value="hex"
|
||||
v-model:value="formats[key].value.value"
|
||||
placement="bottom-end"
|
||||
@update:value="(v: string) => onInputUpdated(v, 'hex')"
|
||||
@update:value="(v:string) => updateColorValue(parse(v), key)"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="color name:">
|
||||
<InputCopyable v-model:value="name" @update:value="(v: string) => onInputUpdated(v, 'name')" />
|
||||
</n-form-item>
|
||||
<n-form-item label="hex:">
|
||||
<InputCopyable v-model:value="hex" @update:value="(v: string) => onInputUpdated(v, 'hex')" />
|
||||
</n-form-item>
|
||||
<n-form-item label="rgb:">
|
||||
<InputCopyable v-model:value="rgb" @update:value="(v: string) => onInputUpdated(v, 'rgb')" />
|
||||
</n-form-item>
|
||||
<n-form-item label="hsl:">
|
||||
<InputCopyable v-model:value="hsl" @update:value="(v: string) => onInputUpdated(v, 'hsl')" />
|
||||
</n-form-item>
|
||||
<n-form-item label="hwb:">
|
||||
<InputCopyable v-model:value="hwb" @update:value="(v: string) => onInputUpdated(v, 'hwb')" />
|
||||
</n-form-item>
|
||||
<n-form-item label="lch:">
|
||||
<InputCopyable v-model:value="lch" @update:value="(v: string) => onInputUpdated(v, 'lch')" />
|
||||
</n-form-item>
|
||||
<n-form-item label="cmyk:">
|
||||
<InputCopyable v-model:value="cmyk" @update:value="(v: string) => onInputUpdated(v, 'cmyk')" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</template>
|
||||
</c-card>
|
||||
</template>
|
||||
|
|
|
@ -167,34 +167,8 @@ const cronValidationRules = [
|
|||
</div>
|
||||
</c-card>
|
||||
</div>
|
||||
<n-table v-else size="small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left" scope="col">
|
||||
Symbol
|
||||
</th>
|
||||
<th class="text-left" scope="col">
|
||||
Meaning
|
||||
</th>
|
||||
<th class="text-left" scope="col">
|
||||
Example
|
||||
</th>
|
||||
<th class="text-left" scope="col">
|
||||
Equivalent
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="{ symbol, meaning, example, equivalent } in helpers" :key="symbol">
|
||||
<td>{{ symbol }}</td>
|
||||
<td>{{ meaning }}</td>
|
||||
<td>
|
||||
<code>{{ example }}</code>
|
||||
</td>
|
||||
<td>{{ equivalent }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</n-table>
|
||||
|
||||
<c-table v-else :data="helpers" />
|
||||
</c-card>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -29,5 +29,6 @@ test.describe('Date time converter - json to yaml', () => {
|
|||
expect((await page.getByTestId('Timestamp').inputValue()).trim()).toEqual('1681333824000');
|
||||
expect((await page.getByTestId('UTC format').inputValue()).trim()).toEqual('Wed, 12 Apr 2023 21:10:24 GMT');
|
||||
expect((await page.getByTestId('Mongo ObjectID').inputValue()).trim()).toEqual('64371e400000000000000000');
|
||||
expect((await page.getByTestId('Excel date/time').inputValue()).trim()).toEqual('45028.88222222222');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
dateToExcelFormat,
|
||||
excelFormatToDate,
|
||||
isExcelFormat,
|
||||
isISO8601DateTimeString,
|
||||
isISO9075DateString,
|
||||
isMongoObjectId,
|
||||
|
@ -139,4 +142,39 @@ describe('date-time-converter models', () => {
|
|||
expect(isMongoObjectId('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExcelFormat', () => {
|
||||
test('an Excel format string is a floating number that can be negative', () => {
|
||||
expect(isExcelFormat('0')).toBe(true);
|
||||
expect(isExcelFormat('1')).toBe(true);
|
||||
expect(isExcelFormat('1.1')).toBe(true);
|
||||
expect(isExcelFormat('-1.1')).toBe(true);
|
||||
expect(isExcelFormat('-1')).toBe(true);
|
||||
|
||||
expect(isExcelFormat('')).toBe(false);
|
||||
expect(isExcelFormat('foo')).toBe(false);
|
||||
expect(isExcelFormat('1.1.1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dateToExcelFormat', () => {
|
||||
test('a date in Excel format is the number of days since 01/01/1900', () => {
|
||||
expect(dateToExcelFormat(new Date('2016-05-20T00:00:00.000Z'))).toBe('42510');
|
||||
expect(dateToExcelFormat(new Date('2016-05-20T12:00:00.000Z'))).toBe('42510.5');
|
||||
expect(dateToExcelFormat(new Date('2023-10-31T09:26:06.421Z'))).toBe('45230.39312987268');
|
||||
expect(dateToExcelFormat(new Date('1970-01-01T00:00:00.000Z'))).toBe('25569');
|
||||
expect(dateToExcelFormat(new Date('1800-01-01T00:00:00.000Z'))).toBe('-36522');
|
||||
});
|
||||
});
|
||||
|
||||
describe('excelFormatToDate', () => {
|
||||
test('a date in Excel format is the number of days since 01/01/1900', () => {
|
||||
expect(excelFormatToDate('0')).toEqual(new Date('1899-12-30T00:00:00.000Z'));
|
||||
expect(excelFormatToDate('1')).toEqual(new Date('1899-12-31T00:00:00.000Z'));
|
||||
expect(excelFormatToDate('2')).toEqual(new Date('1900-01-01T00:00:00.000Z'));
|
||||
expect(excelFormatToDate('4242.4242')).toEqual(new Date('1911-08-12T10:10:50.880Z'));
|
||||
expect(excelFormatToDate('42738.22626859954')).toEqual(new Date('2017-01-03T05:25:49.607Z'));
|
||||
expect(excelFormatToDate('-1000')).toEqual(new Date('1897-04-04T00:00:00.000Z'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,6 +9,9 @@ export {
|
|||
isTimestamp,
|
||||
isUTCDateString,
|
||||
isMongoObjectId,
|
||||
dateToExcelFormat,
|
||||
excelFormatToDate,
|
||||
isExcelFormat,
|
||||
};
|
||||
|
||||
const ISO8601_REGEX
|
||||
|
@ -21,6 +24,8 @@ const RFC3339_REGEX
|
|||
|
||||
const RFC7231_REGEX = /^[A-Za-z]{3},\s[0-9]{2}\s[A-Za-z]{3}\s[0-9]{4}\s[0-9]{2}:[0-9]{2}:[0-9]{2}\sGMT$/;
|
||||
|
||||
const EXCEL_FORMAT_REGEX = /^-?\d+(\.\d+)?$/;
|
||||
|
||||
function createRegexMatcher(regex: RegExp) {
|
||||
return (date?: string) => !_.isNil(date) && regex.test(date);
|
||||
}
|
||||
|
@ -33,6 +38,8 @@ const isUnixTimestamp = createRegexMatcher(/^[0-9]{1,10}$/);
|
|||
const isTimestamp = createRegexMatcher(/^[0-9]{1,13}$/);
|
||||
const isMongoObjectId = createRegexMatcher(/^[0-9a-fA-F]{24}$/);
|
||||
|
||||
const isExcelFormat = createRegexMatcher(EXCEL_FORMAT_REGEX);
|
||||
|
||||
function isUTCDateString(date?: string) {
|
||||
if (_.isNil(date)) {
|
||||
return false;
|
||||
|
@ -45,3 +52,11 @@ function isUTCDateString(date?: string) {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function dateToExcelFormat(date: Date) {
|
||||
return String(((date.getTime()) / (1000 * 60 * 60 * 24)) + 25569);
|
||||
}
|
||||
|
||||
function excelFormatToDate(excelFormat: string | number) {
|
||||
return new Date((Number(excelFormat) - 25569) * 86400 * 1000);
|
||||
}
|
||||
|
|
|
@ -14,6 +14,9 @@ import {
|
|||
} from 'date-fns';
|
||||
import type { DateFormat, ToDateMapper } from './date-time-converter.types';
|
||||
import {
|
||||
dateToExcelFormat,
|
||||
excelFormatToDate,
|
||||
isExcelFormat,
|
||||
isISO8601DateTimeString,
|
||||
isISO9075DateString,
|
||||
isMongoObjectId,
|
||||
|
@ -85,6 +88,12 @@ const formats: DateFormat[] = [
|
|||
toDate: objectId => new Date(Number.parseInt(objectId.substring(0, 8), 16) * 1000),
|
||||
formatMatcher: date => isMongoObjectId(date),
|
||||
},
|
||||
{
|
||||
name: 'Excel date/time',
|
||||
fromDate: date => dateToExcelFormat(date),
|
||||
toDate: excelFormatToDate,
|
||||
formatMatcher: isExcelFormat,
|
||||
},
|
||||
];
|
||||
|
||||
const formatIndex = ref(6);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { AES, RC4, Rabbit, TripleDES, enc } from 'crypto-js';
|
||||
import { computedCatch } from '@/composable/computed/catchedComputed';
|
||||
|
||||
const algos = { AES, TripleDES, Rabbit, RC4 };
|
||||
|
||||
|
@ -11,9 +12,10 @@ const cypherOutput = computed(() => algos[cypherAlgo.value].encrypt(cypherInput.
|
|||
const decryptInput = ref('U2FsdGVkX1/EC3+6P5dbbkZ3e1kQ5o2yzuU0NHTjmrKnLBEwreV489Kr0DIB+uBs');
|
||||
const decryptAlgo = ref<keyof typeof algos>('AES');
|
||||
const decryptSecret = ref('my secret key');
|
||||
const decryptOutput = computed(() =>
|
||||
algos[decryptAlgo.value].decrypt(decryptInput.value, decryptSecret.value).toString(enc.Utf8),
|
||||
);
|
||||
const [decryptOutput, decryptError] = computedCatch(() => algos[decryptAlgo.value].decrypt(decryptInput.value, decryptSecret.value).toString(enc.Utf8), {
|
||||
defaultValue: '',
|
||||
defaultErrorMessage: 'Unable to decrypt your text',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -63,7 +65,11 @@ const decryptOutput = computed(() =>
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<c-alert v-if="decryptError" type="error" mt-12 title="Error while decrypting">
|
||||
{{ decryptError }}
|
||||
</c-alert>
|
||||
<c-input-text
|
||||
v-else
|
||||
label="Your decrypted text:"
|
||||
:value="decryptOutput"
|
||||
placeholder="Your string hash"
|
||||
|
|
|
@ -26,8 +26,8 @@ const endAt = computed(() =>
|
|||
<template>
|
||||
<div>
|
||||
<div text-justify op-70>
|
||||
With a concrete example, if you wash 3 plates in 5 minutes and you have 500 plates to wash, it will take you 5
|
||||
hours and 10 minutes to wash them all.
|
||||
With a concrete example, if you wash 5 plates in 3 minutes and you have 500 plates to wash, it will take you 5
|
||||
hours to wash them all.
|
||||
</div>
|
||||
<n-divider />
|
||||
<div flex gap-2>
|
||||
|
|
|
@ -6,13 +6,9 @@ const { icon, title, action, isActive } = toRefs(props);
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<c-button circle variant="text" :type="isActive?.() ? 'primary' : 'default'" @click="action">
|
||||
<n-icon :component="icon" />
|
||||
</c-button>
|
||||
</template>
|
||||
|
||||
{{ title }}
|
||||
</n-tooltip>
|
||||
<c-tooltip :tooltip="title">
|
||||
<c-button circle variant="text" :type="isActive?.() ? 'primary' : 'default'" @click="action">
|
||||
<n-icon :component="icon" />
|
||||
</c-button>
|
||||
</c-tooltip>
|
||||
</template>
|
||||
|
|
|
@ -60,9 +60,11 @@ const ibanExamples = [
|
|||
<div>
|
||||
<c-input-text v-model:value="rawIban" placeholder="Enter an IBAN to check for validity..." test-id="iban-input" />
|
||||
|
||||
<c-key-value-list :items="ibanInfo" my-5 data-test-id="iban-info" />
|
||||
<c-card v-if="ibanInfo.length > 0" mt-5>
|
||||
<c-key-value-list :items="ibanInfo" data-test-id="iban-info" />
|
||||
</c-card>
|
||||
|
||||
<c-card title="Valid IBAN examples">
|
||||
<c-card title="Valid IBAN examples" mt-5>
|
||||
<div v-for="iban in ibanExamples" :key="iban">
|
||||
<c-text-copyable :value="iban" font-mono :displayed-value="friendlyFormatIBAN(iban)" />
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
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 pdfSignatureChecker } from './pdf-signature-checker';
|
||||
import { tool as numeronymGenerator } from './numeronym-generator';
|
||||
import { tool as macAddressGenerator } from './mac-address-generator';
|
||||
import { tool as textToBinary } from './text-to-binary';
|
||||
import { tool as ulidGenerator } from './ulid-generator';
|
||||
import { tool as ibanValidatorAndParser } from './iban-validator-and-parser';
|
||||
import { tool as stringObfuscator } from './string-obfuscator';
|
||||
|
@ -76,7 +80,7 @@ import { tool as htmlMdConverter } from './html-md-converter';
|
|||
export const toolsByCategory: ToolCategory[] = [
|
||||
{
|
||||
name: 'Crypto',
|
||||
components: [tokenGenerator, hashText, bcrypt, uuidGenerator, ulidGenerator, cypher, bip39, hmacGenerator, rsaKeyPairGenerator, passwordStrengthAnalyser],
|
||||
components: [tokenGenerator, hashText, bcrypt, uuidGenerator, ulidGenerator, cypher, bip39, hmacGenerator, rsaKeyPairGenerator, passwordStrengthAnalyser, pdfSignatureChecker],
|
||||
},
|
||||
{
|
||||
name: 'Converter',
|
||||
|
@ -89,6 +93,7 @@ export const toolsByCategory: ToolCategory[] = [
|
|||
colorConverter,
|
||||
caseConverter,
|
||||
textToNatoAlphabet,
|
||||
textToBinary,
|
||||
yamlToJson,
|
||||
yamlToToml,
|
||||
jsonToYaml,
|
||||
|
@ -140,7 +145,7 @@ export const toolsByCategory: ToolCategory[] = [
|
|||
},
|
||||
{
|
||||
name: 'Network',
|
||||
components: [ipv4SubnetCalculator, ipv4AddressConverter, ipv4RangeExpander, macAddressLookup, ipv6UlaGenerator],
|
||||
components: [ipv4SubnetCalculator, ipv4AddressConverter, ipv4RangeExpander, macAddressLookup, macAddressGenerator, ipv6UlaGenerator],
|
||||
},
|
||||
{
|
||||
name: 'Math',
|
||||
|
@ -152,7 +157,7 @@ export const toolsByCategory: ToolCategory[] = [
|
|||
},
|
||||
{
|
||||
name: 'Text',
|
||||
components: [loremIpsumGenerator, textStatistics, emojiPicker, stringObfuscator, textDiff],
|
||||
components: [loremIpsumGenerator, textStatistics, emojiPicker, stringObfuscator, textDiff, numeronymGenerator],
|
||||
},
|
||||
{
|
||||
name: 'Data',
|
||||
|
|
|
@ -19,7 +19,7 @@ function decodeJwt({ jwt }: { jwt: string }) {
|
|||
|
||||
function parseClaims({ claim, value }: { claim: string; value: unknown }) {
|
||||
const claimDescription = CLAIM_DESCRIPTIONS[claim];
|
||||
const formattedValue = _.isPlainObject(value) ? JSON.stringify(value, null, 3) : _.toString(value);
|
||||
const formattedValue = _.isPlainObject(value) || _.isArray(value) ? JSON.stringify(value, null, 3) : _.toString(value);
|
||||
const friendlyValue = getFriendlyValue({ claim, value });
|
||||
|
||||
return {
|
||||
|
|
12
src/tools/mac-address-generator/index.ts
Normal file
12
src/tools/mac-address-generator/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { Devices } from '@vicons/tabler';
|
||||
import { defineTool } from '../tool';
|
||||
|
||||
export const tool = defineTool({
|
||||
name: 'MAC address generator',
|
||||
path: '/mac-address-generator',
|
||||
description: 'Enter the quantity and prefix. MAC addresses will be generated in your chosen case (uppercase or lowercase)',
|
||||
keywords: ['mac', 'address', 'generator', 'random', 'prefix'],
|
||||
component: () => import('./mac-address-generator.vue'),
|
||||
icon: Devices,
|
||||
createdAt: new Date('2023-11-31'),
|
||||
});
|
|
@ -0,0 +1,11 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Tool - MAC address generator', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/mac-address-generator');
|
||||
});
|
||||
|
||||
test('Has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('MAC address generator - IT Tools');
|
||||
});
|
||||
});
|
103
src/tools/mac-address-generator/mac-address-generator.vue
Normal file
103
src/tools/mac-address-generator/mac-address-generator.vue
Normal file
|
@ -0,0 +1,103 @@
|
|||
<script setup lang="ts">
|
||||
import _ from 'lodash';
|
||||
import { generateRandomMacAddress } from './mac-adress-generator.models';
|
||||
import { computedRefreshable } from '@/composable/computedRefreshable';
|
||||
import { useCopy } from '@/composable/copy';
|
||||
import { usePartialMacAddressValidation } from '@/utils/macAddress';
|
||||
|
||||
const amount = useStorage('mac-address-generator-amount', 1);
|
||||
const macAddressPrefix = useStorage('mac-address-generator-prefix', '64:16:7F');
|
||||
|
||||
const prefixValidation = usePartialMacAddressValidation(macAddressPrefix);
|
||||
|
||||
const casesTransformers = [
|
||||
{ label: 'Uppercase', value: (value: string) => value.toUpperCase() },
|
||||
{ label: 'Lowercase', value: (value: string) => value.toLowerCase() },
|
||||
];
|
||||
const caseTransformer = ref(casesTransformers[0].value);
|
||||
|
||||
const separators = [
|
||||
{
|
||||
label: ':',
|
||||
value: ':',
|
||||
},
|
||||
{
|
||||
label: '-',
|
||||
value: '-',
|
||||
},
|
||||
{
|
||||
label: '.',
|
||||
value: '.',
|
||||
},
|
||||
{
|
||||
label: 'None',
|
||||
value: '',
|
||||
},
|
||||
];
|
||||
const separator = useStorage('mac-address-generator-separator', separators[0].value);
|
||||
|
||||
const [macAddresses, refreshMacAddresses] = computedRefreshable(() => {
|
||||
if (!prefixValidation.isValid) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const ids = _.times(amount.value, () => caseTransformer.value(generateRandomMacAddress({
|
||||
prefix: macAddressPrefix.value,
|
||||
separator: separator.value,
|
||||
})));
|
||||
return ids.join('\n');
|
||||
});
|
||||
|
||||
const { copy } = useCopy({ source: macAddresses, text: 'MAC addresses copied to the clipboard' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex flex-col justify-center gap-2>
|
||||
<div flex items-center>
|
||||
<label w-150px pr-12px text-right> Quantity:</label>
|
||||
<n-input-number v-model:value="amount" min="1" max="100" flex-1 />
|
||||
</div>
|
||||
|
||||
<c-input-text
|
||||
v-model:value="macAddressPrefix"
|
||||
label="MAC address prefix:"
|
||||
placeholder="Set a prefix, e.g. 64:16:7F"
|
||||
clearable
|
||||
label-position="left"
|
||||
spellcheck="false"
|
||||
:validation="prefixValidation"
|
||||
raw-text
|
||||
label-width="150px"
|
||||
label-align="right"
|
||||
/>
|
||||
|
||||
<c-buttons-select
|
||||
v-model:value="caseTransformer"
|
||||
:options="casesTransformers"
|
||||
label="Case:"
|
||||
label-width="150px"
|
||||
label-align="right"
|
||||
/>
|
||||
|
||||
<c-buttons-select
|
||||
v-model:value="separator"
|
||||
:options="separators"
|
||||
label="Separator:"
|
||||
label-width="150px"
|
||||
label-align="right"
|
||||
/>
|
||||
|
||||
<c-card mt-5 flex data-test-id="ulids">
|
||||
<pre m-0 m-x-auto>{{ macAddresses }}</pre>
|
||||
</c-card>
|
||||
|
||||
<div flex justify-center gap-2>
|
||||
<c-button data-test-id="refresh" @click="refreshMacAddresses()">
|
||||
Refresh
|
||||
</c-button>
|
||||
<c-button @click="copy()">
|
||||
Copy
|
||||
</c-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { generateRandomMacAddress, splitPrefix } from './mac-adress-generator.models';
|
||||
|
||||
describe('mac-adress-generator models', () => {
|
||||
describe('splitPrefix', () => {
|
||||
it('a mac address prefix is splitted around non hex characters', () => {
|
||||
expect(splitPrefix('')).toEqual([]);
|
||||
expect(splitPrefix('01')).toEqual(['01']);
|
||||
expect(splitPrefix('01:')).toEqual(['01']);
|
||||
expect(splitPrefix('01:23')).toEqual(['01', '23']);
|
||||
expect(splitPrefix('01-23')).toEqual(['01', '23']);
|
||||
});
|
||||
|
||||
it('when a prefix contains only hex characters, they are grouped by 2', () => {
|
||||
expect(splitPrefix('0123')).toEqual(['01', '23']);
|
||||
expect(splitPrefix('012345')).toEqual(['01', '23', '45']);
|
||||
expect(splitPrefix('0123456')).toEqual(['01', '23', '45', '06']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateRandomMacAddress', () => {
|
||||
const createRandomByteGenerator = () => {
|
||||
let i = 0;
|
||||
return () => (i++).toString(16).padStart(2, '0');
|
||||
};
|
||||
|
||||
it('generates a random mac address', () => {
|
||||
expect(generateRandomMacAddress({ getRandomByte: createRandomByteGenerator() })).toBe('00:01:02:03:04:05');
|
||||
});
|
||||
|
||||
it('generates a random mac address with a prefix', () => {
|
||||
expect(generateRandomMacAddress({ prefix: 'ff:ee:aa', getRandomByte: createRandomByteGenerator() })).toBe('ff:ee:aa:00:01:02');
|
||||
expect(generateRandomMacAddress({ prefix: 'ff:ee:a', getRandomByte: createRandomByteGenerator() })).toBe('ff:ee:0a:00:01:02');
|
||||
});
|
||||
|
||||
it('generates a random mac address with a prefix and a different separator', () => {
|
||||
expect(generateRandomMacAddress({ prefix: 'ff-ee-aa', separator: '-', getRandomByte: createRandomByteGenerator() })).toBe('ff-ee-aa-00-01-02');
|
||||
expect(generateRandomMacAddress({ prefix: 'ff:ee:aa', separator: '-', getRandomByte: createRandomByteGenerator() })).toBe('ff-ee-aa-00-01-02');
|
||||
expect(generateRandomMacAddress({ prefix: 'ff-ee:aa', separator: '-', getRandomByte: createRandomByteGenerator() })).toBe('ff-ee-aa-00-01-02');
|
||||
expect(generateRandomMacAddress({ prefix: 'ff ee:aa', separator: '-', getRandomByte: createRandomByteGenerator() })).toBe('ff-ee-aa-00-01-02');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,18 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
export { splitPrefix, generateRandomMacAddress };
|
||||
|
||||
function splitPrefix(prefix: string): string[] {
|
||||
const base = prefix.match(/[^0-9a-f]/i) === null ? prefix.match(/.{1,2}/g) ?? [] : prefix.split(/[^0-9a-f]/i);
|
||||
|
||||
return base.filter(Boolean).map(byte => byte.padStart(2, '0'));
|
||||
}
|
||||
|
||||
function generateRandomMacAddress({ prefix: rawPrefix = '', separator = ':', getRandomByte = () => _.random(0, 255).toString(16).padStart(2, '0') }: { prefix?: string; separator?: string; getRandomByte?: () => string } = {}) {
|
||||
const prefix = splitPrefix(rawPrefix);
|
||||
|
||||
const randomBytes = _.times(6 - prefix.length, getRandomByte);
|
||||
const bytes = [...prefix, ...randomBytes];
|
||||
|
||||
return bytes.join(separator);
|
||||
}
|
|
@ -1,12 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import db from 'oui/oui.json';
|
||||
import db from 'oui-data';
|
||||
import { macAddressValidationRules } from '@/utils/macAddress';
|
||||
import { useCopy } from '@/composable/copy';
|
||||
|
||||
const getVendorValue = (address: string) => address.trim().replace(/[.:-]/g, '').toUpperCase().substring(0, 6);
|
||||
|
||||
const macAddress = ref('20:37:06:12:34:56');
|
||||
const details = computed<string | undefined>(() => db[getVendorValue(macAddress.value)]);
|
||||
const details = computed<string | undefined>(() => (db as Record<string, string>)[getVendorValue(macAddress.value)]);
|
||||
|
||||
const { copy } = useCopy({ source: () => details.value ?? '', text: 'Vendor info copied to the clipboard' });
|
||||
</script>
|
||||
|
|
4
src/tools/mac-address-lookup/oui.d.ts
vendored
4
src/tools/mac-address-lookup/oui.d.ts
vendored
|
@ -1,4 +0,0 @@
|
|||
declare module 'oui/oui.json' {
|
||||
const db: Record<string, string>;
|
||||
export default db;
|
||||
}
|
|
@ -4,10 +4,13 @@ import { defineTool } from '../tool';
|
|||
export const tool = defineTool({
|
||||
name: 'Math evaluator',
|
||||
path: '/math-evaluator',
|
||||
description: 'Evaluate math expression, like a calculator on steroid (you can use function like sqrt, cos, sin, abs, ...)',
|
||||
description: 'A calculator for evaluating mathematical expressions. You can use functions like sqrt, cos, sin, abs, etc.',
|
||||
keywords: [
|
||||
'math',
|
||||
'evaluator',
|
||||
'calculator',
|
||||
'expression',
|
||||
'abs',
|
||||
'acos',
|
||||
'acosh',
|
||||
'acot',
|
||||
|
@ -31,6 +34,7 @@ export const tool = defineTool({
|
|||
'sech',
|
||||
'sin',
|
||||
'sinh',
|
||||
'sqrt',
|
||||
'tan',
|
||||
'tanh',
|
||||
],
|
||||
|
|
|
@ -16,6 +16,9 @@ const result = computed(() => withDefaultOnError(() => evaluate(expression.value
|
|||
multiline
|
||||
placeholder="Your math expression (ex: 2*sqrt(6) )..."
|
||||
raw-text
|
||||
monospace
|
||||
autofocus
|
||||
autosize
|
||||
/>
|
||||
|
||||
<c-card v-if="result !== ''" title="Result " mt-5>
|
||||
|
|
12
src/tools/numeronym-generator/index.ts
Normal file
12
src/tools/numeronym-generator/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { defineTool } from '../tool';
|
||||
import n7mIcon from './n7m-icon.svg?component';
|
||||
|
||||
export const tool = defineTool({
|
||||
name: 'Numeronym generator',
|
||||
path: '/numeronym-generator',
|
||||
description: 'A numeronym is a word where a number is used to form an abbreviation. For example, "i18n" is a numeronym of "internationalization" where 18 stands for the number of letters between the first i and the last n in the word.',
|
||||
keywords: ['numeronym', 'generator', 'abbreviation', 'i18n', 'a11y', 'l10n'],
|
||||
component: () => import('./numeronym-generator.vue'),
|
||||
icon: n7mIcon,
|
||||
createdAt: new Date('2023-11-05'),
|
||||
});
|
3
src/tools/numeronym-generator/n7m-icon.svg
Normal file
3
src/tools/numeronym-generator/n7m-icon.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" >
|
||||
<path id="n7m" fill="currentColor" aria-label="n7m" d="m0.7 35v-16.7q1.1-0.2 2.8-0.5 1.7-0.3 4-0.3 2.1 0 3.4 0.6 1.4 0.5 2.2 1.6 0.8 1 1.1 2.5 0.4 1.4 0.4 3.2v9.6h-3.1v-9q0-1.6-0.2-2.7-0.2-1.1-0.7-1.8-0.5-0.7-1.4-1-0.8-0.3-2-0.3-0.5 0-1 0-0.6 0-1 0.1-0.5 0-0.9 0.1-0.4 0.1-0.5 0.1v14.5zm18.8 0h-3.2q0.2-2.6 0.9-5.5 0.8-3 1.9-5.7 1.1-2.8 2.4-5.1 1.3-2.4 2.5-3.9h-11.1v-2.7h14.6v2.6q-1.1 1.2-2.4 3.4-1.4 2.2-2.6 5-1.1 2.7-2 5.8-0.8 3-1 6.1zm6.6 0v-16.7q1.1-0.2 2.8-0.5 1.8-0.3 4-0.3 1.7 0 2.8 0.4 1.1 0.5 1.9 1.3 0.2-0.1 0.7-0.4 0.5-0.3 1.2-0.6 0.8-0.3 1.7-0.5 0.8-0.2 1.9-0.2 1.9 0 3.2 0.6 1.3 0.5 2 1.6 0.7 1 0.9 2.5 0.3 1.4 0.3 3.2v9.6h-3.1v-9q0-1.5-0.2-2.6-0.1-1.1-0.5-1.8-0.4-0.7-1.1-1.1-0.7-0.3-1.9-0.3-1.5 0-2.5 0.4-1 0.4-1.4 0.7 0.3 0.9 0.4 1.9 0.1 1 0.1 2.2v9.6h-3v-9q0-1.5-0.2-2.6-0.2-1.1-0.6-1.8-0.4-0.7-1.1-1.1-0.7-0.3-1.8-0.3-0.5 0-1 0-0.5 0-1 0.1-0.5 0-0.9 0.1-0.4 0.1-0.5 0.1v14.5z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 982 B |
|
@ -0,0 +1,25 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Tool - Numeronym generator', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/numeronym-generator');
|
||||
});
|
||||
|
||||
test('Has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Numeronym generator - IT Tools');
|
||||
});
|
||||
|
||||
test('a numeronym is generated when a word is entered', async ({ page }) => {
|
||||
await page.getByTestId('word-input').fill('internationalization');
|
||||
const numeronym = await page.getByTestId('numeronym').inputValue();
|
||||
|
||||
expect(numeronym).toEqual('i18n');
|
||||
});
|
||||
|
||||
test('when a word has 3 letters or less, the numeronym is the word itself', async ({ page }) => {
|
||||
await page.getByTestId('word-input').fill('abc');
|
||||
const numeronym = await page.getByTestId('numeronym').inputValue();
|
||||
|
||||
expect(numeronym).toEqual('abc');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,15 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { generateNumeronym } from './numeronym-generator.service';
|
||||
|
||||
describe('numeronym-generator service', () => {
|
||||
describe('generateNumeronym', () => {
|
||||
it('a numeronym of a word is the first letter, the number of letters between the first and the last letter, and the last letter', () => {
|
||||
expect(generateNumeronym('internationalization')).toBe('i18n');
|
||||
expect(generateNumeronym('accessibility')).toBe('a11y');
|
||||
expect(generateNumeronym('localization')).toBe('l10n');
|
||||
});
|
||||
it('a numeronym of a word with 3 letters is the word itself', () => {
|
||||
expect(generateNumeronym('abc')).toBe('abc');
|
||||
});
|
||||
});
|
||||
});
|
11
src/tools/numeronym-generator/numeronym-generator.service.ts
Normal file
11
src/tools/numeronym-generator/numeronym-generator.service.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export { generateNumeronym };
|
||||
|
||||
function generateNumeronym(word: string): string {
|
||||
const wordLength = word.length;
|
||||
|
||||
if (wordLength <= 3) {
|
||||
return word;
|
||||
}
|
||||
|
||||
return `${word.at(0)}${wordLength - 2}${word.at(-1)}`;
|
||||
}
|
17
src/tools/numeronym-generator/numeronym-generator.vue
Normal file
17
src/tools/numeronym-generator/numeronym-generator.vue
Normal file
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import { generateNumeronym } from './numeronym-generator.service';
|
||||
|
||||
const word = ref('');
|
||||
|
||||
const numeronym = computed(() => generateNumeronym(word.value));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex flex-col items-center gap-4>
|
||||
<c-input-text v-model:value="word" placeholder="Enter a word, e.g. 'internationalization'" size="large" clearable test-id="word-input" />
|
||||
|
||||
<icon-mdi-arrow-down text-30px />
|
||||
|
||||
<input-copyable :value="numeronym" size="large" readonly placeholder="Your numeronym will be here, e.g. 'i18n'" test-id="numeronym" />
|
||||
</div>
|
||||
</template>
|
|
@ -61,19 +61,16 @@ const secretValidationRules = [
|
|||
:validation-rules="secretValidationRules"
|
||||
>
|
||||
<template #suffix>
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<c-button circle variant="text" size="small" @click="refreshSecret">
|
||||
<icon-mdi-refresh />
|
||||
</c-button>
|
||||
</template>
|
||||
Generate secret token
|
||||
</n-tooltip>
|
||||
<c-tooltip tooltip="Generate a new random secret">
|
||||
<c-button circle variant="text" size="small" @click="refreshSecret">
|
||||
<icon-mdi-refresh />
|
||||
</c-button>
|
||||
</c-tooltip>
|
||||
</template>
|
||||
</c-input-text>
|
||||
|
||||
<div>
|
||||
<TokenDisplay :tokens="tokens" style="margin-top: 2px" />
|
||||
<TokenDisplay :tokens="tokens" />
|
||||
|
||||
<n-progress :percentage="(100 * interval) / 30" :color="theme.primaryColor" :show-indicator="false" />
|
||||
<div style="text-align: center">
|
||||
|
|
|
@ -11,7 +11,7 @@ const { tokens } = toRefs(props);
|
|||
|
||||
<template>
|
||||
<div>
|
||||
<div class="labels" w-full flex items-center>
|
||||
<div mb-5px w-full flex items-center>
|
||||
<div flex-1 text-left>
|
||||
Previous
|
||||
</div>
|
||||
|
@ -22,60 +22,24 @@ const { tokens } = toRefs(props);
|
|||
Next
|
||||
</div>
|
||||
</div>
|
||||
<n-input-group>
|
||||
<n-tooltip trigger="hover" placement="bottom">
|
||||
<template #trigger>
|
||||
<c-button important:h-12 data-test-id="previous-otp" @click.prevent="copyPrevious(tokens.previous)">
|
||||
{{ tokens.previous }}
|
||||
</c-button>
|
||||
</template>
|
||||
<div>{{ previousCopied ? 'Copied !' : 'Copy previous OTP' }}</div>
|
||||
</n-tooltip>
|
||||
<n-tooltip trigger="hover" placement="bottom">
|
||||
<template #trigger>
|
||||
<c-button
|
||||
data-test-id="current-otp"
|
||||
class="current-otp"
|
||||
important:h-12
|
||||
@click.prevent="copyCurrent(tokens.current)"
|
||||
>
|
||||
{{ tokens.current }}
|
||||
</c-button>
|
||||
</template>
|
||||
<div>{{ currentCopied ? 'Copied !' : 'Copy current OTP' }}</div>
|
||||
</n-tooltip>
|
||||
<n-tooltip trigger="hover" placement="bottom">
|
||||
<template #trigger>
|
||||
<c-button important:h-12 data-test-id="next-otp" @click.prevent="copyNext(tokens.next)">
|
||||
{{
|
||||
tokens.next
|
||||
}}
|
||||
</c-button>
|
||||
</template>
|
||||
<div>{{ nextCopied ? 'Copied !' : 'Copy next OTP' }}</div>
|
||||
</n-tooltip>
|
||||
</n-input-group>
|
||||
<div flex items-center>
|
||||
<c-tooltip :tooltip="previousCopied ? 'Copied !' : 'Copy previous OTP'" position="bottom" flex-1>
|
||||
<c-button data-test-id="previous-otp" w-full important:h-12 important:rounded-r-none important:font-mono @click.prevent="copyPrevious(tokens.previous)">
|
||||
{{ tokens.previous }}
|
||||
</c-button>
|
||||
</c-tooltip>
|
||||
<c-tooltip :tooltip="currentCopied ? 'Copied !' : 'Copy current OTP'" position="bottom" flex-1 flex-basis-5xl>
|
||||
<c-button
|
||||
data-test-id="current-otp" w-full important:border-x="1px solid gray op-40" important:h-12 important:rounded-0 important:text-22px @click.prevent="copyCurrent(tokens.current)"
|
||||
>
|
||||
{{ tokens.current }}
|
||||
</c-button>
|
||||
</c-tooltip>
|
||||
<c-tooltip :tooltip="nextCopied ? 'Copied !' : 'Copy next OTP'" position="bottom" flex-1>
|
||||
<c-button data-test-id="next-otp" w-full important:h-12 important:rounded-l-none @click.prevent="copyNext(tokens.next)">
|
||||
{{ tokens.next }}
|
||||
</c-button>
|
||||
</c-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.current-otp {
|
||||
font-size: 22px;
|
||||
flex: 1 0 35% !important;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
.labels {
|
||||
div {
|
||||
padding: 0 2px 6px 2px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
}
|
||||
|
||||
.n-input-group > * {
|
||||
flex: 1 0 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
<script setup lang="ts">
|
||||
import type { SignatureInfo } from '../pdf-signature-checker.types';
|
||||
|
||||
const props = defineProps<{ signature: SignatureInfo }>();
|
||||
const { signature } = toRefs(props);
|
||||
|
||||
const tableHeaders = {
|
||||
validityPeriod: 'Validity period',
|
||||
issuedBy: 'Issued by',
|
||||
issuedTo: 'Issued to',
|
||||
pemCertificate: 'PEM certificate',
|
||||
};
|
||||
|
||||
const certs = computed(() => signature.value.meta.certs.map((certificate, index) => ({
|
||||
...certificate,
|
||||
validityPeriod: {
|
||||
notBefore: new Date(certificate.validityPeriod.notBefore).toLocaleString(),
|
||||
notAfter: new Date(certificate.validityPeriod.notAfter).toLocaleString(),
|
||||
},
|
||||
certificateName: `Certificate ${index + 1}`,
|
||||
})),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex flex-col gap-2>
|
||||
<c-table :data="certs" :headers="tableHeaders">
|
||||
<template #validityPeriod="{ value }">
|
||||
<c-key-value-list
|
||||
:items="[{
|
||||
label: 'Not before',
|
||||
value: value.notBefore,
|
||||
}, {
|
||||
label: 'Not after',
|
||||
value: value.notAfter,
|
||||
}]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #issuedBy="{ value }">
|
||||
<c-key-value-list
|
||||
:items="[{
|
||||
label: 'Common name',
|
||||
value: value.commonName,
|
||||
}, {
|
||||
label: 'Organization name',
|
||||
value: value.organizationName,
|
||||
}, {
|
||||
label: 'Country name',
|
||||
value: value.countryName,
|
||||
}, {
|
||||
label: 'Locality name',
|
||||
value: value.localityName,
|
||||
}, {
|
||||
label: 'Organizational unit name',
|
||||
value: value.organizationalUnitName,
|
||||
}, {
|
||||
label: 'State or province name',
|
||||
value: value.stateOrProvinceName,
|
||||
}]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #issuedTo="{ value }">
|
||||
<c-key-value-list
|
||||
:items="[{
|
||||
label: 'Common name',
|
||||
value: value.commonName,
|
||||
}, {
|
||||
label: 'Organization name',
|
||||
value: value.organizationName,
|
||||
}, {
|
||||
label: 'Country name',
|
||||
value: value.countryName,
|
||||
}, {
|
||||
label: 'Locality name',
|
||||
value: value.localityName,
|
||||
}, {
|
||||
label: 'Organizational unit name',
|
||||
value: value.organizationalUnitName,
|
||||
}, {
|
||||
label: 'State or province name',
|
||||
value: value.stateOrProvinceName,
|
||||
}]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #pemCertificate="{ value }">
|
||||
<c-modal-value :value="value" label="View PEM cert">
|
||||
<template #value>
|
||||
<div break-all text-xs>
|
||||
{{ value }}
|
||||
</div>
|
||||
</template>
|
||||
</c-modal-value>
|
||||
</template>
|
||||
</c-table>
|
||||
</div>
|
||||
</template>
|
12
src/tools/pdf-signature-checker/index.ts
Normal file
12
src/tools/pdf-signature-checker/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { defineTool } from '../tool';
|
||||
import FileCertIcon from '~icons/mdi/file-certificate-outline';
|
||||
|
||||
export const tool = defineTool({
|
||||
name: 'PDF signature checker',
|
||||
path: '/pdf-signature-checker',
|
||||
description: 'Verify the signatures of a PDF file. A signed PDF file contains one or more signatures that may be used to determine whether the contents of the file have been altered since the file was signed.',
|
||||
keywords: ['pdf', 'signature', 'checker', 'verify', 'validate', 'sign'],
|
||||
component: () => import('./pdf-signature-checker.vue'),
|
||||
icon: FileCertIcon,
|
||||
createdAt: new Date('2023-12-09'),
|
||||
});
|
|
@ -0,0 +1,11 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Tool - Pdf signature checker', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/pdf-signature-checker');
|
||||
});
|
||||
|
||||
test('Has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('PDF signature checker - IT Tools');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,39 @@
|
|||
export interface SignatureInfo {
|
||||
verified: boolean
|
||||
authenticity: boolean
|
||||
integrity: boolean
|
||||
expired: boolean
|
||||
meta: {
|
||||
certs: {
|
||||
clientCertificate?: boolean
|
||||
issuedBy: {
|
||||
commonName: string
|
||||
organizationalUnitName?: string
|
||||
organizationName: string
|
||||
countryName?: string
|
||||
localityName?: string
|
||||
stateOrProvinceName?: string
|
||||
}
|
||||
issuedTo: {
|
||||
commonName: string
|
||||
serialNumber?: string
|
||||
organizationalUnitName?: string
|
||||
organizationName: string
|
||||
countryName?: string
|
||||
localityName?: string
|
||||
stateOrProvinceName?: string
|
||||
}
|
||||
validityPeriod: {
|
||||
notBefore: string
|
||||
notAfter: string
|
||||
}
|
||||
pemCertificate: string
|
||||
}[]
|
||||
signatureMeta: {
|
||||
reason: string
|
||||
contactInfo: string | null
|
||||
location: string
|
||||
name: string | null
|
||||
}
|
||||
}
|
||||
}
|
59
src/tools/pdf-signature-checker/pdf-signature-checker.vue
Normal file
59
src/tools/pdf-signature-checker/pdf-signature-checker.vue
Normal file
|
@ -0,0 +1,59 @@
|
|||
<script setup lang="ts">
|
||||
import verifyPDF from 'pdf-signature-reader';
|
||||
import type { SignatureInfo } from './pdf-signature-checker.types';
|
||||
import { formatBytes } from '@/utils/convert';
|
||||
|
||||
const signatures = ref<SignatureInfo[]>([]);
|
||||
const status = ref<'idle' | 'parsed' | 'error' | 'loading'>('idle');
|
||||
const file = ref<File | null>(null);
|
||||
|
||||
async function onVerifyClicked(uploadedFile: File) {
|
||||
file.value = uploadedFile;
|
||||
const fileBuffer = await uploadedFile.arrayBuffer();
|
||||
|
||||
status.value = 'loading';
|
||||
try {
|
||||
const { signatures: parsedSignatures } = verifyPDF(fileBuffer);
|
||||
signatures.value = parsedSignatures;
|
||||
status.value = 'parsed';
|
||||
}
|
||||
catch (e) {
|
||||
signatures.value = [];
|
||||
status.value = 'error';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="flex: 0 0 100%">
|
||||
<div mx-auto max-w-600px>
|
||||
<c-file-upload title="Drag and drop a PDF file here, or click to select a file" accept=".pdf" @file-upload="onVerifyClicked" />
|
||||
|
||||
<c-card v-if="file" mt-4 flex gap-2>
|
||||
<div font-bold>
|
||||
{{ file.name }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ formatBytes(file.size) }}
|
||||
</div>
|
||||
</c-card>
|
||||
|
||||
<div v-if="status === 'error'">
|
||||
<c-alert mt-4>
|
||||
No signatures found in the provided file.
|
||||
</c-alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="status === 'parsed' && signatures.length" style="flex: 0 0 100%" mt-5 flex flex-col gap-4>
|
||||
<div v-for="(signature, index) of signatures" :key="index">
|
||||
<div mb-2 font-bold>
|
||||
Signature {{ index + 1 }} certificates :
|
||||
</div>
|
||||
|
||||
<pdf-signature-details :signature="signature" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
12
src/tools/text-to-binary/index.ts
Normal file
12
src/tools/text-to-binary/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { Binary } from '@vicons/tabler';
|
||||
import { defineTool } from '../tool';
|
||||
|
||||
export const tool = defineTool({
|
||||
name: 'Text to ASCII binary',
|
||||
path: '/text-to-binary',
|
||||
description: 'Convert text to its ASCII binary representation and vice versa.',
|
||||
keywords: ['text', 'to', 'binary', 'converter', 'encode', 'decode', 'ascii'],
|
||||
component: () => import('./text-to-binary.vue'),
|
||||
icon: Binary,
|
||||
createdAt: new Date('2023-10-15'),
|
||||
});
|
25
src/tools/text-to-binary/text-to-binary.e2e.spec.ts
Normal file
25
src/tools/text-to-binary/text-to-binary.e2e.spec.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('Tool - Text to ASCII binary', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/text-to-binary');
|
||||
});
|
||||
|
||||
test('Has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Text to ASCII binary - IT Tools');
|
||||
});
|
||||
|
||||
test('Text to binary conversion', async ({ page }) => {
|
||||
await page.getByTestId('text-to-binary-input').fill('it-tools');
|
||||
const binary = await page.getByTestId('text-to-binary-output').inputValue();
|
||||
|
||||
expect(binary).toEqual('01101001 01110100 00101101 01110100 01101111 01101111 01101100 01110011');
|
||||
});
|
||||
|
||||
test('Binary to text conversion', async ({ page }) => {
|
||||
await page.getByTestId('binary-to-text-input').fill('01101001 01110100 00101101 01110100 01101111 01101111 01101100 01110011');
|
||||
const text = await page.getByTestId('binary-to-text-output').inputValue();
|
||||
|
||||
expect(text).toEqual('it-tools');
|
||||
});
|
||||
});
|
32
src/tools/text-to-binary/text-to-binary.models.test.ts
Normal file
32
src/tools/text-to-binary/text-to-binary.models.test.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { convertAsciiBinaryToText, convertTextToAsciiBinary } from './text-to-binary.models';
|
||||
|
||||
describe('text-to-binary', () => {
|
||||
describe('convertTextToAsciiBinary', () => {
|
||||
it('a text string is converted to its ascii binary representation', () => {
|
||||
expect(convertTextToAsciiBinary('A')).toBe('01000001');
|
||||
expect(convertTextToAsciiBinary('hello')).toBe('01101000 01100101 01101100 01101100 01101111');
|
||||
expect(convertTextToAsciiBinary('')).toBe('');
|
||||
});
|
||||
it('the separator between octets can be changed', () => {
|
||||
expect(convertTextToAsciiBinary('hello', { separator: '' })).toBe('0110100001100101011011000110110001101111');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertAsciiBinaryToText', () => {
|
||||
it('an ascii binary string is converted to its text representation', () => {
|
||||
expect(convertAsciiBinaryToText('01101000 01100101 01101100 01101100 01101111')).toBe('hello');
|
||||
expect(convertAsciiBinaryToText('01000001')).toBe('A');
|
||||
expect(convertTextToAsciiBinary('')).toBe('');
|
||||
});
|
||||
|
||||
it('the given binary string is cleaned before conversion', () => {
|
||||
expect(convertAsciiBinaryToText(' 01000 001garbage')).toBe('A');
|
||||
});
|
||||
|
||||
it('throws an error if the given binary string as no complete octet', () => {
|
||||
expect(() => convertAsciiBinaryToText('010000011')).toThrow('Invalid binary string');
|
||||
expect(() => convertAsciiBinaryToText('1')).toThrow('Invalid binary string');
|
||||
});
|
||||
});
|
||||
});
|
22
src/tools/text-to-binary/text-to-binary.models.ts
Normal file
22
src/tools/text-to-binary/text-to-binary.models.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
export { convertTextToAsciiBinary, convertAsciiBinaryToText };
|
||||
|
||||
function convertTextToAsciiBinary(text: string, { separator = ' ' }: { separator?: string } = {}): string {
|
||||
return text
|
||||
.split('')
|
||||
.map(char => char.charCodeAt(0).toString(2).padStart(8, '0'))
|
||||
.join(separator);
|
||||
}
|
||||
|
||||
function convertAsciiBinaryToText(binary: string): string {
|
||||
const cleanBinary = binary.replace(/[^01]/g, '');
|
||||
|
||||
if (cleanBinary.length % 8) {
|
||||
throw new Error('Invalid binary string');
|
||||
}
|
||||
|
||||
return cleanBinary
|
||||
.split(/(\d{8})/)
|
||||
.filter(Boolean)
|
||||
.map(binary => String.fromCharCode(Number.parseInt(binary, 2)))
|
||||
.join('');
|
||||
}
|
42
src/tools/text-to-binary/text-to-binary.vue
Normal file
42
src/tools/text-to-binary/text-to-binary.vue
Normal file
|
@ -0,0 +1,42 @@
|
|||
<script setup lang="ts">
|
||||
import { convertAsciiBinaryToText, convertTextToAsciiBinary } from './text-to-binary.models';
|
||||
import { withDefaultOnError } from '@/utils/defaults';
|
||||
import { useCopy } from '@/composable/copy';
|
||||
import { isNotThrowing } from '@/utils/boolean';
|
||||
|
||||
const inputText = ref('');
|
||||
const binaryFromText = computed(() => convertTextToAsciiBinary(inputText.value));
|
||||
const { copy: copyBinary } = useCopy({ source: binaryFromText });
|
||||
|
||||
const inputBinary = ref('');
|
||||
const textFromBinary = computed(() => withDefaultOnError(() => convertAsciiBinaryToText(inputBinary.value), ''));
|
||||
const inputBinaryValidationRules = [
|
||||
{
|
||||
validator: (value: string) => isNotThrowing(() => convertAsciiBinaryToText(value)),
|
||||
message: 'Binary should be a valid ASCII binary string with multiples of 8 bits',
|
||||
},
|
||||
];
|
||||
const { copy: copyText } = useCopy({ source: textFromBinary });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<c-card title="Text to ASCII binary">
|
||||
<c-input-text v-model:value="inputText" multiline placeholder="e.g. 'Hello world'" label="Enter text to convert to binary" autosize autofocus raw-text test-id="text-to-binary-input" />
|
||||
<c-input-text v-model:value="binaryFromText" label="Binary from your text" multiline raw-text readonly mt-2 placeholder="The binary representation of your text will be here" test-id="text-to-binary-output" />
|
||||
<div mt-2 flex justify-center>
|
||||
<c-button :disabled="!binaryFromText" @click="copyBinary()">
|
||||
Copy binary to clipboard
|
||||
</c-button>
|
||||
</div>
|
||||
</c-card>
|
||||
|
||||
<c-card title="ASCII binary to text">
|
||||
<c-input-text v-model:value="inputBinary" multiline placeholder="e.g. '01001000 01100101 01101100 01101100 01101111'" label="Enter binary to convert to text" autosize raw-text :validation-rules="inputBinaryValidationRules" test-id="binary-to-text-input" />
|
||||
<c-input-text v-model:value="textFromBinary" label="Text from your binary" multiline raw-text readonly mt-2 placeholder="The text representation of your binary will be here" test-id="binary-to-text-output" />
|
||||
<div mt-2 flex justify-center>
|
||||
<c-button :disabled="!textFromBinary" @click="copyText()">
|
||||
Copy text to clipboard
|
||||
</c-button>
|
||||
</div>
|
||||
</c-card>
|
||||
</template>
|
|
@ -1,12 +1,12 @@
|
|||
import { ArrowsShuffle } from '@vicons/tabler';
|
||||
import { defineTool } from '../tool';
|
||||
import { translate } from '@/plugins/i18n.plugin';
|
||||
|
||||
export const tool = defineTool({
|
||||
name: 'Token generator',
|
||||
name: translate('tools.token-generator.title'),
|
||||
path: '/token-generator',
|
||||
description:
|
||||
'Generate random string with the chars you want: uppercase or lowercase letters, numbers and/or symbols.',
|
||||
keywords: ['token', 'random', 'string', 'alphanumeric', 'symbols', 'number', 'letters', 'lowercase', 'uppercase'],
|
||||
description: translate('tools.token-generator.description'),
|
||||
keywords: ['token', 'random', 'string', 'alphanumeric', 'symbols', 'number', 'letters', 'lowercase', 'uppercase', 'password'],
|
||||
component: () => import('./token-generator.tool.vue'),
|
||||
icon: ArrowsShuffle,
|
||||
});
|
||||
|
|
|
@ -6,4 +6,10 @@ tools:
|
|||
uppercase: Uppercase (ABC...)
|
||||
lowercase: Lowercase (abc...)
|
||||
numbers: Numbers (123...)
|
||||
symbols: Symbols (!-;...)
|
||||
symbols: Symbols (!-;...)
|
||||
length: Length
|
||||
tokenPlaceholder: 'The token...'
|
||||
copied: Token copied to the clipboard
|
||||
button:
|
||||
copy: Copy
|
||||
refresh: Refresh
|
|
@ -1,9 +1,16 @@
|
|||
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.
|
||||
|
||||
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 (!-;...)
|
||||
button:
|
||||
copy: Copier
|
||||
refresh: Rafraichir
|
||||
copied: Le token a été copié
|
||||
length: Longueur
|
||||
tokenPlaceholder: Le token...
|
||||
|
|
|
@ -21,7 +21,7 @@ const [token, refreshToken] = computedRefreshable(() =>
|
|||
}),
|
||||
);
|
||||
|
||||
const { copy } = useCopy({ source: token, text: 'Token copied to the clipboard' });
|
||||
const { copy } = useCopy({ source: token, text: t('tools.token-generator.copied') });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -51,14 +51,14 @@ const { copy } = useCopy({ source: token, text: 'Token copied to the clipboard'
|
|||
</div>
|
||||
</n-form>
|
||||
|
||||
<n-form-item :label="`Length (${length})`" label-placement="left">
|
||||
<n-form-item :label="`${t('tools.token-generator.length')} (${length})`" label-placement="left">
|
||||
<n-slider v-model:value="length" :step="1" :min="1" :max="512" />
|
||||
</n-form-item>
|
||||
|
||||
<c-input-text
|
||||
v-model:value="token"
|
||||
multiline
|
||||
placeholder="The token..."
|
||||
:placeholder="t('tools.token-generator.tokenPlaceholder')"
|
||||
readonly
|
||||
rows="3"
|
||||
autosize
|
||||
|
@ -67,10 +67,10 @@ const { copy } = useCopy({ source: token, text: 'Token copied to the clipboard'
|
|||
|
||||
<div mt-5 flex justify-center gap-3>
|
||||
<c-button @click="copy()">
|
||||
Copy
|
||||
{{ t('tools.token-generator.button.copy') }}
|
||||
</c-button>
|
||||
<c-button @click="refreshToken">
|
||||
Refresh
|
||||
{{ t('tools.token-generator.button.refresh') }}
|
||||
</c-button>
|
||||
</div>
|
||||
</c-card>
|
||||
|
|
|
@ -1,44 +1,57 @@
|
|||
import { type MaybeRef, get, useStorage } from '@vueuse/core';
|
||||
import { defineStore } from 'pinia';
|
||||
import type { Ref } from 'vue';
|
||||
import type { Tool, ToolWithCategory } from './tools.types';
|
||||
import _ from 'lodash';
|
||||
import type { Tool, ToolCategory, ToolWithCategory } from './tools.types';
|
||||
import { toolsWithCategory } from './index';
|
||||
|
||||
export const useToolStore = defineStore('tools', {
|
||||
state: () => ({
|
||||
favoriteToolsName: useStorage('favoriteToolsName', []) as Ref<string[]>,
|
||||
}),
|
||||
getters: {
|
||||
favoriteTools(state) {
|
||||
return state.favoriteToolsName
|
||||
.map(favoriteName => toolsWithCategory.find(({ name }) => name === favoriteName))
|
||||
.filter(Boolean) as ToolWithCategory[]; // cast because .filter(Boolean) does not remove undefined from type
|
||||
},
|
||||
export const useToolStore = defineStore('tools', () => {
|
||||
const favoriteToolsName = useStorage('favoriteToolsName', []) as Ref<string[]>;
|
||||
const { t } = useI18n();
|
||||
|
||||
notFavoriteTools(state): ToolWithCategory[] {
|
||||
return toolsWithCategory.filter(tool => !state.favoriteToolsName.includes(tool.name));
|
||||
},
|
||||
const tools = computed<ToolWithCategory[]>(() => toolsWithCategory.map((tool) => {
|
||||
const toolI18nKey = tool.path.replace(/\//g, '');
|
||||
|
||||
tools(): ToolWithCategory[] {
|
||||
return toolsWithCategory;
|
||||
},
|
||||
return ({
|
||||
...tool,
|
||||
name: t(`tools.${toolI18nKey}.title`, tool.name),
|
||||
description: t(`tools.${toolI18nKey}.description`, tool.description),
|
||||
category: t(`tools.categories.${tool.category.toLowerCase()}`, tool.category),
|
||||
});
|
||||
}));
|
||||
|
||||
newTools(): ToolWithCategory[] {
|
||||
return this.tools.filter(({ isNew }) => isNew);
|
||||
},
|
||||
},
|
||||
const toolsByCategory = computed<ToolCategory[]>(() => {
|
||||
return _.chain(tools.value)
|
||||
.groupBy('category')
|
||||
.map((components, name) => ({
|
||||
name,
|
||||
components,
|
||||
}))
|
||||
.value();
|
||||
});
|
||||
|
||||
const favoriteTools = computed(() => {
|
||||
return favoriteToolsName.value
|
||||
.map(favoriteName => tools.value.find(({ name }) => name === favoriteName))
|
||||
.filter(Boolean) as ToolWithCategory[]; // cast because .filter(Boolean) does not remove undefined from type
|
||||
});
|
||||
|
||||
return {
|
||||
tools,
|
||||
favoriteTools,
|
||||
toolsByCategory,
|
||||
newTools: computed(() => tools.value.filter(({ isNew }) => isNew)),
|
||||
|
||||
actions: {
|
||||
addToolToFavorites({ tool }: { tool: MaybeRef<Tool> }) {
|
||||
this.favoriteToolsName.push(get(tool).name);
|
||||
favoriteToolsName.value.push(get(tool).name);
|
||||
},
|
||||
|
||||
removeToolFromFavorites({ tool }: { tool: MaybeRef<Tool> }) {
|
||||
this.favoriteToolsName = this.favoriteToolsName.filter(name => get(tool).name !== name);
|
||||
favoriteToolsName.value = favoriteToolsName.value.filter(name => get(tool).name !== name);
|
||||
},
|
||||
|
||||
isToolFavorite({ tool }: { tool: MaybeRef<Tool> }) {
|
||||
return this.favoriteToolsName.includes(get(tool).name);
|
||||
return favoriteToolsName.value.includes(get(tool).name);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
@ -14,25 +14,18 @@ const { userAgentInfo, sections } = toRefs(props);
|
|||
<n-grid :x-gap="12" :y-gap="8" cols="1 s:2" responsive="screen">
|
||||
<n-gi v-for="{ heading, icon, content } in sections" :key="heading">
|
||||
<c-card h-full>
|
||||
<n-page-header>
|
||||
<template #title>
|
||||
{{ heading }}
|
||||
</template>
|
||||
<template v-if="icon" #avatar>
|
||||
<n-icon size="30" :component="icon" :depth="3" />
|
||||
</template>
|
||||
</n-page-header>
|
||||
<div flex items-center gap-3>
|
||||
<n-icon size="30" :component="icon" :depth="3" />
|
||||
<span text-lg>{{ heading }}</span>
|
||||
</div>
|
||||
|
||||
<div mt-5 flex gap-2>
|
||||
<span v-for="{ label, getValue } in content" :key="label">
|
||||
<n-tooltip v-if="getValue(userAgentInfo)" trigger="hover">
|
||||
<template #trigger>
|
||||
<n-tag type="success" size="large" round :bordered="false">
|
||||
{{ getValue(userAgentInfo) }}
|
||||
</n-tag>
|
||||
</template>
|
||||
{{ label }}
|
||||
</n-tooltip>
|
||||
<c-tooltip v-if="getValue(userAgentInfo)" :tooltip="label">
|
||||
<n-tag type="success" size="large" round :bordered="false">
|
||||
{{ getValue(userAgentInfo) }}
|
||||
</n-tag>
|
||||
</c-tooltip>
|
||||
</span>
|
||||
</div>
|
||||
<div flex flex-col>
|
||||
|
|
|
@ -2,11 +2,11 @@ import { Fingerprint } from '@vicons/tabler';
|
|||
import { defineTool } from '../tool';
|
||||
|
||||
export const tool = defineTool({
|
||||
name: 'UUIDs v4 generator',
|
||||
name: 'UUIDs generator',
|
||||
path: '/uuid-generator',
|
||||
description:
|
||||
'A Universally Unique Identifier (UUID) is a 128-bit number used to identify information in computer systems. The number of possible UUIDs is 16^32, which is 2^128 or about 3.4x10^38 (which is a lot!).',
|
||||
keywords: ['uuid', 'v4', 'random', 'id', 'alphanumeric', 'identity', 'token', 'string', 'identifier', 'unique'],
|
||||
keywords: ['uuid', 'v4', 'random', 'id', 'alphanumeric', 'identity', 'token', 'string', 'identifier', 'unique', 'v1', 'v3', 'v5', 'nil'],
|
||||
component: () => import('./uuid-generator.vue'),
|
||||
icon: Fingerprint,
|
||||
});
|
||||
|
|
|
@ -1,22 +1,94 @@
|
|||
<script setup lang="ts">
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
import { v1 as generateUuidV1, v3 as generateUuidV3, v4 as generateUuidV4, v5 as generateUuidV5, NIL as nilUuid } from 'uuid';
|
||||
import { useCopy } from '@/composable/copy';
|
||||
import { computedRefreshable } from '@/composable/computedRefreshable';
|
||||
import { withDefaultOnError } from '@/utils/defaults';
|
||||
|
||||
const versions = ['NIL', 'v1', 'v3', 'v4', 'v5'] as const;
|
||||
|
||||
const version = useStorage<typeof versions[number]>('uuid-generator:version', 'v4');
|
||||
const count = useStorage('uuid-generator:quantity', 1);
|
||||
const v35Args = ref({ namespace: '6ba7b811-9dad-11d1-80b4-00c04fd430c8', name: '' });
|
||||
|
||||
const [uuids, refreshUUIDs] = computedRefreshable(() =>
|
||||
Array.from({ length: count.value }, () => generateUUID()).join('\n'),
|
||||
);
|
||||
const validUuidRules = [
|
||||
{
|
||||
message: 'Invalid UUID',
|
||||
validator: (value: string) => {
|
||||
if (value === nilUuid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(value.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/));
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const generators = {
|
||||
NIL: () => nilUuid,
|
||||
v1: (index: number) => generateUuidV1({
|
||||
clockseq: index,
|
||||
msecs: Date.now(),
|
||||
nsecs: Math.floor(Math.random() * 10000),
|
||||
node: Array.from({ length: 6 }, () => Math.floor(Math.random() * 256)),
|
||||
}),
|
||||
v3: () => generateUuidV3(v35Args.value.name, v35Args.value.namespace),
|
||||
v4: () => generateUuidV4(),
|
||||
v5: () => generateUuidV5(v35Args.value.name, v35Args.value.namespace),
|
||||
};
|
||||
|
||||
const [uuids, refreshUUIDs] = computedRefreshable(() => withDefaultOnError(() =>
|
||||
Array.from({ length: count.value }, (_ignored, index) => {
|
||||
const generator = generators[version.value] ?? generators.NIL;
|
||||
return generator(index);
|
||||
}).join('\n'), ''));
|
||||
|
||||
const { copy } = useCopy({ source: uuids, text: 'UUIDs copied to the clipboard' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div flex items-center justify-center gap-3>
|
||||
Quantity :
|
||||
<n-input-number v-model:value="count" :min="1" :max="50" placeholder="UUID quantity" />
|
||||
<c-buttons-select v-model:value="version" :options="versions" label="UUID version" label-width="100px" mb-2 />
|
||||
|
||||
<div mb-2 flex items-center>
|
||||
<span w-100px>Quantity </span>
|
||||
<n-input-number v-model:value="count" flex-1 :min="1" :max="50" placeholder="UUID quantity" />
|
||||
</div>
|
||||
|
||||
<div v-if="version === 'v3' || version === 'v5'">
|
||||
<div>
|
||||
<c-buttons-select
|
||||
v-model:value="v35Args.namespace"
|
||||
:options="{
|
||||
DNS: '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
|
||||
URL: '6ba7b811-9dad-11d1-80b4-00c04fd430c8',
|
||||
OID: '6ba7b812-9dad-11d1-80b4-00c04fd430c8',
|
||||
X500: '6ba7b814-9dad-11d1-80b4-00c04fd430c8',
|
||||
}"
|
||||
label="Namespace"
|
||||
label-width="100px"
|
||||
mb-2
|
||||
/>
|
||||
</div>
|
||||
<div flex-1>
|
||||
<c-input-text
|
||||
v-model:value="v35Args.namespace"
|
||||
placeholder="Namespace"
|
||||
label-width="100px"
|
||||
label-position="left"
|
||||
label=" "
|
||||
:validation-rules="validUuidRules"
|
||||
mb-2
|
||||
/>
|
||||
</div>
|
||||
|
||||
<c-input-text
|
||||
v-model:value="v35Args.name"
|
||||
placeholder="Name"
|
||||
label="Name"
|
||||
label-width="100px"
|
||||
label-position="left"
|
||||
mb-2
|
||||
/>
|
||||
</div>
|
||||
|
||||
<c-input-text
|
||||
|
|
|
@ -1,11 +1,19 @@
|
|||
<script lang="ts" setup>
|
||||
const variants = ['warning'] as const;
|
||||
const variants = ['warning', 'error'] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2>Basic</h2>
|
||||
<c-alert v-for="variant in variants" :key="variant" :type="variant" mb-4>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Magni reprehenderit itaque enim? Suscipit magni optio velit
|
||||
quia, eveniet repellat pariatur quaerat laudantium dignissimos natus, beatae deleniti adipisci, atque necessitatibus
|
||||
odio!
|
||||
</c-alert>
|
||||
|
||||
<h2>With title</h2>
|
||||
<c-alert v-for="variant in variants" :key="variant" :type="variant" title="This is the title" mb-4>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Magni reprehenderit itaque enim? Suscipit magni optio velit
|
||||
quia, eveniet repellat pariatur quaerat laudantium dignissimos natus, beatae deleniti adipisci, atque necessitatibus
|
||||
odio!
|
||||
</c-alert>
|
||||
</template>
|
||||
|
|
|
@ -3,6 +3,7 @@ import { defineThemes } from '../theme/theme.models';
|
|||
import { appThemes } from '../theme/themes';
|
||||
|
||||
import WarningIcon from '~icons/mdi/alert-circle-outline';
|
||||
import ErrorIcon from '~icons/mdi/close-circle-outline';
|
||||
|
||||
export const { useTheme } = defineThemes({
|
||||
dark: {
|
||||
|
@ -12,6 +13,12 @@ export const { useTheme } = defineThemes({
|
|||
textColor: appThemes.dark.warning.color,
|
||||
icon: WarningIcon,
|
||||
},
|
||||
error: {
|
||||
backgroundColor: appThemes.dark.error.colorFaded,
|
||||
borderColor: appThemes.dark.error.color,
|
||||
textColor: appThemes.dark.error.color,
|
||||
icon: ErrorIcon,
|
||||
},
|
||||
},
|
||||
light: {
|
||||
warning: {
|
||||
|
@ -20,5 +27,11 @@ export const { useTheme } = defineThemes({
|
|||
textColor: darken(appThemes.light.warning.color, 40),
|
||||
icon: WarningIcon,
|
||||
},
|
||||
error: {
|
||||
backgroundColor: appThemes.light.error.colorFaded,
|
||||
borderColor: appThemes.light.error.color,
|
||||
textColor: darken(appThemes.light.error.color, 40),
|
||||
icon: ErrorIcon,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<script lang="ts" setup>
|
||||
import { useTheme } from './c-alert.theme';
|
||||
|
||||
const props = withDefaults(defineProps<{ type?: 'warning' }>(), { type: 'warning' });
|
||||
const { type } = toRefs(props);
|
||||
const props = withDefaults(defineProps<{ type?: 'warning'; title?: string }>(), { type: 'warning', title: undefined });
|
||||
const { type, title } = toRefs(props);
|
||||
|
||||
const theme = useTheme();
|
||||
const variantTheme = computed(() => theme.value[type.value]);
|
||||
|
@ -17,6 +17,9 @@ const variantTheme = computed(() => theme.value[type.value]);
|
|||
</div>
|
||||
|
||||
<div class="c-alert--content">
|
||||
<div v-if="title" class="c-alert--title" text-15px fw-600>
|
||||
{{ title }}
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -4,11 +4,18 @@ const optionsA = [
|
|||
{ label: 'Option B', value: 'b', tooltip: 'This is a tooltip' },
|
||||
{ label: 'Option C', value: 'c' },
|
||||
];
|
||||
|
||||
const optionB = {
|
||||
'Option A': 'a',
|
||||
'Option B': 'b',
|
||||
'Option C': 'c',
|
||||
};
|
||||
|
||||
const valueA = ref('a');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<c-buttons-select v-model:value="valueA" :options="optionsA" label="Label: " />
|
||||
<c-buttons-select v-model:value="valueA" :options="optionsA" label="Label: " label-position="left" mt-2 />
|
||||
<c-buttons-select v-model:value="valueA" :options="optionsA" label="Label: " label-position="left" mt-2 />
|
||||
<c-buttons-select v-model:value="valueA" :options="optionB" label="Options object: " />
|
||||
</template>
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
<script setup lang="ts" generic="T extends unknown">
|
||||
import _ from 'lodash';
|
||||
import type { CLabelProps } from '../c-label/c-label.types';
|
||||
import type { CButtonSelectOption } from './c-buttons-select.types';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
options?: CButtonSelectOption<T>[] | string[]
|
||||
options?: CButtonSelectOption<T>[] | string[] | Record<string, T>
|
||||
value?: T
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
} & CLabelProps >(),
|
||||
|
@ -20,14 +21,18 @@ const emits = defineEmits(['update:value']);
|
|||
|
||||
const { options: rawOptions, size } = toRefs(props);
|
||||
|
||||
const options = computed(() => {
|
||||
return rawOptions.value.map((option: string | CButtonSelectOption<T>) => {
|
||||
if (typeof option === 'string') {
|
||||
return { label: option, value: option };
|
||||
}
|
||||
const options = computed<CButtonSelectOption<T>[]>(() => {
|
||||
if (_.isArray(rawOptions.value)) {
|
||||
return rawOptions.value.map((option: string | CButtonSelectOption<T>) => {
|
||||
if (typeof option === 'string') {
|
||||
return { label: option, value: option };
|
||||
}
|
||||
|
||||
return option;
|
||||
});
|
||||
return option;
|
||||
}) as CButtonSelectOption<T>[];
|
||||
}
|
||||
|
||||
return _.map(rawOptions.value, (value, label) => ({ label, value })) as CButtonSelectOption<T>[];
|
||||
});
|
||||
|
||||
const value = useVModel(props, 'value', emits);
|
||||
|
|
5
src/ui/c-collapse/c-collapse.demo.vue
Normal file
5
src/ui/c-collapse/c-collapse.demo.vue
Normal file
|
@ -0,0 +1,5 @@
|
|||
<template>
|
||||
<c-collapse title="Collapse title">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquet iaculis class cubilia metus per nullam gravida ad venenatis. Id elementum elementum enim orci elementum justo facilisi habitant consequat. Justo eget ligula purus laoreet penatibus eros quisque fusce sociis. In eget amet sagittis dignissim eleifend proin lacinia potenti tellus. Interdum vulputate condimentum molestie pulvinar praesent accumsan quisque venenatis imperdiet.
|
||||
</c-collapse>
|
||||
</template>
|
25
src/ui/c-collapse/c-collapse.vue
Normal file
25
src/ui/c-collapse/c-collapse.vue
Normal file
|
@ -0,0 +1,25 @@
|
|||
<script lang="ts" setup>
|
||||
const props = withDefaults(defineProps<{ title?: string }>(), { title: '' });
|
||||
const { title } = toRefs(props);
|
||||
|
||||
const isCollapsed = ref(true);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div flex cursor-pointer items-center @click="isCollapsed = !isCollapsed">
|
||||
<icon-mdi-triangle-down :class="{ 'transform-rotate--90': isCollapsed }" op-50 transition />
|
||||
|
||||
<slot name="title">
|
||||
<span class="ml-2" font-bold>{{ title }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="!isCollapsed"
|
||||
mt-2
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
3
src/ui/c-file-upload/c-file-upload.demo.vue
Normal file
3
src/ui/c-file-upload/c-file-upload.demo.vue
Normal file
|
@ -0,0 +1,3 @@
|
|||
<template>
|
||||
<c-file-upload />
|
||||
</template>
|
95
src/ui/c-file-upload/c-file-upload.vue
Normal file
95
src/ui/c-file-upload/c-file-upload.vue
Normal file
|
@ -0,0 +1,95 @@
|
|||
<script lang="ts" setup>
|
||||
import _ from 'lodash';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
multiple?: boolean
|
||||
accept?: string
|
||||
title?: string
|
||||
}>(), {
|
||||
multiple: false,
|
||||
accept: undefined,
|
||||
title: 'Drag and drop files here, or click to select files',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'filesUpload', files: File[]): void
|
||||
(event: 'fileUpload', file: File): void
|
||||
}>();
|
||||
|
||||
const { multiple } = toRefs(props);
|
||||
|
||||
const isOverDropZone = ref(false);
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function handleFileInput(event: Event) {
|
||||
const files = (event.target as HTMLInputElement).files;
|
||||
|
||||
handleUpload(files);
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
const files = event.dataTransfer?.files;
|
||||
|
||||
handleUpload(files);
|
||||
}
|
||||
|
||||
function handleUpload(files: FileList | null | undefined) {
|
||||
if (_.isNil(files) || _.isEmpty(files)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (multiple.value) {
|
||||
emit('filesUpload', Array.from(files));
|
||||
return;
|
||||
}
|
||||
|
||||
emit('fileUpload', files[0]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col cursor-pointer items-center justify-center border-2px border-gray-300 border-opacity-50 rounded-lg border-dashed p-8 transition-colors"
|
||||
:class="{
|
||||
'border-primary border-opacity-100': isOverDropZone,
|
||||
}"
|
||||
@click="triggerFileInput"
|
||||
@drop.prevent="handleDrop"
|
||||
@dragover.prevent
|
||||
@dragenter="isOverDropZone = true"
|
||||
@dragleave="isOverDropZone = false"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
:multiple="multiple"
|
||||
:accept="accept"
|
||||
@change="handleFileInput"
|
||||
>
|
||||
<slot>
|
||||
<span op-70>
|
||||
{{ title }}
|
||||
</span>
|
||||
|
||||
<!-- separator -->
|
||||
<div my-4 w-full flex items-center justify-center op-70>
|
||||
<div class="h-1px max-w-100px flex-1 bg-gray-300 op-50" />
|
||||
<div class="mx-2 text-gray-400">
|
||||
or
|
||||
</div>
|
||||
<div class="h-1px max-w-100px flex-1 bg-gray-300 op-50" />
|
||||
</div>
|
||||
|
||||
<c-button>
|
||||
Browse files
|
||||
</c-button>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
|
@ -9,13 +9,13 @@ const formattedItems = computed(() => items.value.filter(item => !_.isNil(item.v
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div my-5>
|
||||
<div v-for="item in formattedItems" :key="item.label" flex gap-2 py-1 class="c-key-value-list__item">
|
||||
<div flex-basis-180px text-right font-bold class="c-key-value-list__key">
|
||||
<div flex flex-col gap-2>
|
||||
<div v-for="item in formattedItems" :key="item.label" class="c-key-value-list__item">
|
||||
<div class="c-key-value-list__key" text-13px lh-normal>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
|
||||
<c-key-value-list-item :item="item" class="c-key-value-list__value" />
|
||||
<c-key-value-list-item :item="item" class="c-key-value-list__value" font-bold lh-normal />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
18
src/ui/c-markdown/c-markdown.demo.vue
Normal file
18
src/ui/c-markdown/c-markdown.demo.vue
Normal file
|
@ -0,0 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
const md = `
|
||||
# IT Tools
|
||||
|
||||
## About
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed euismod, nisl quis
|
||||
mollis blandit, nunc nisl aliquam nunc, vitae aliquam nisl nunc vitae nisl.
|
||||
|
||||
- Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
- Sed euismod, nisl quis mollis blandit, nunc nisl aliquam nunc, vitae aliquam nisl nunc vitae nisl.
|
||||
|
||||
[it-tools](https://it-tools.tech)
|
||||
`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<c-markdown :markdown="md" />
|
||||
</template>
|
21
src/ui/c-markdown/c-markdown.vue
Normal file
21
src/ui/c-markdown/c-markdown.vue
Normal file
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import { marked } from 'marked';
|
||||
import DomPurify from 'dompurify';
|
||||
|
||||
const props = withDefaults(defineProps<{ markdown?: string }>(), { markdown: '' });
|
||||
const { markdown } = toRefs(props);
|
||||
|
||||
marked.use({
|
||||
renderer: {
|
||||
link(href, title, text) {
|
||||
return `<a class="text-primary transition decoration-none hover:underline" href="${href}" target="_blank" rel="noopener">${text}</a>`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const html = computed(() => DomPurify.sanitize(marked(markdown.value), { ADD_ATTR: ['target'] }));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-html="html" />
|
||||
</template>
|
21
src/ui/c-modal-value/c-modal-value.demo.vue
Normal file
21
src/ui/c-modal-value/c-modal-value.demo.vue
Normal file
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<div flex gap-2>
|
||||
<c-modal-value value="lorem ipsum" label="test" />
|
||||
<c-modal-value>
|
||||
<template #label="{ toggleModal }">
|
||||
<c-button class="text-left" size="small" @click="toggleModal">
|
||||
Bonjour
|
||||
</c-button>
|
||||
</template>
|
||||
|
||||
<template #value>
|
||||
<pre>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit.
|
||||
Molestias, quisquam vitae saepe dolores quas debitis ab r
|
||||
ecusandae suscipit ex dignissimos minus quam repellat sunt.
|
||||
Molestiae culpa blanditiis totam sapiente dignissimos.
|
||||
</pre>
|
||||
</template>
|
||||
</c-modal-value>
|
||||
</div>
|
||||
</template>
|
31
src/ui/c-modal-value/c-modal-value.vue
Normal file
31
src/ui/c-modal-value/c-modal-value.vue
Normal file
|
@ -0,0 +1,31 @@
|
|||
<script lang="ts" setup>
|
||||
import { useCopy } from '@/composable/copy';
|
||||
|
||||
const props = withDefaults(defineProps<{ value: string; label?: string; copyable?: boolean }>(), { label: undefined, copyable: true });
|
||||
const { value, label } = toRefs(props);
|
||||
|
||||
const { copy, isJustCopied } = useCopy({ source: value });
|
||||
|
||||
const isModalOpen = ref(false);
|
||||
const toggleModal = useToggle(isModalOpen);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot name="label" :value="value" :toggle-modal="toggleModal" :is-modal-open="isModalOpen">
|
||||
<c-button class="text-left" @click="isModalOpen = true">
|
||||
{{ label }}
|
||||
</c-button>
|
||||
</slot>
|
||||
|
||||
<c-modal v-model:open="isModalOpen">
|
||||
<slot name="value" :value="value" :toggle-modal="toggleModal" :is-modal-open="isModalOpen">
|
||||
{{ value }}
|
||||
</slot>
|
||||
|
||||
<div mt-4 flex justify-center>
|
||||
<c-button class="w-full" @click="copy">
|
||||
{{ isJustCopied ? 'Copied!' : 'Copy' }}
|
||||
</c-button>
|
||||
</div>
|
||||
</c-modal>
|
||||
</template>
|
|
@ -33,4 +33,19 @@ const value = ref('');
|
|||
<c-select label="Label" label-position="left" label-align="left" mb-2 label-width="200px" />
|
||||
<c-select label="Label" label-position="left" label-align="center" mb-2 label-width="200px" />
|
||||
<c-select label="Label" label-position="left" label-align="right" mb-2 label-width="200px" />
|
||||
|
||||
<h2>Custom displayed value</h2>
|
||||
<c-select v-model:value="value" :options="optionsA" mb-2>
|
||||
<template #displayed-value>
|
||||
<span class="font-bold lh-normal">Hello</span>
|
||||
</template>
|
||||
</c-select>
|
||||
|
||||
<c-select v-model:value="value" :options="optionsA">
|
||||
<template #displayed-value>
|
||||
<span lh-normal>
|
||||
<icon-mdi-translate />
|
||||
</span>
|
||||
</template>
|
||||
</c-select>
|
||||
</template>
|
||||
|
|
|
@ -150,13 +150,15 @@ function onSearchInput() {
|
|||
@keydown="handleKeydown"
|
||||
>
|
||||
<div flex-1 truncate>
|
||||
<input v-if="searchable && isOpen" ref="searchInputRef" v-model="searchQuery" type="text" placeholder="Search..." class="search-input" w-full lh-normal color-current @input="onSearchInput">
|
||||
<span v-else-if="selectedOption" lh-normal>
|
||||
{{ selectedOption.label }}
|
||||
</span>
|
||||
<span v-else class="placeholder" lh-normal>
|
||||
{{ placeholder ?? 'Select an option' }}
|
||||
</span>
|
||||
<slot name="displayed-value">
|
||||
<input v-if="searchable && isOpen" ref="searchInputRef" v-model="searchQuery" type="text" placeholder="Search..." class="search-input" w-full lh-normal color-current @input="onSearchInput">
|
||||
<span v-else-if="selectedOption" lh-normal>
|
||||
{{ selectedOption.label }}
|
||||
</span>
|
||||
<span v-else class="placeholder" lh-normal>
|
||||
{{ placeholder ?? 'Select an option' }}
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<icon-mdi-chevron-down class="chevron" />
|
||||
|
|
20
src/ui/c-table/c-table.demo.vue
Normal file
20
src/ui/c-table/c-table.demo.vue
Normal file
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts" setup>
|
||||
const data = ref([
|
||||
{ name: 'John', age: 20 },
|
||||
{ name: 'Jane', age: 24 },
|
||||
{ name: 'Joe', age: 30 },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<c-table :data="data" mb-2 />
|
||||
<c-table :data="data" hide-headers mb-2 />
|
||||
<c-table :data="data" :headers="['age', 'name']" mb-2 />
|
||||
<c-table :data="data" :headers="['age', { key: 'name', label: 'Full name' }]" mb-2 />
|
||||
<c-table :data="data" :headers="{ name: 'full name' }" mb-2 />
|
||||
<c-table :data="data" :headers="['age', 'name']">
|
||||
<template #age="{ value }">
|
||||
{{ value }}yo
|
||||
</template>
|
||||
</c-table>
|
||||
</template>
|
4
src/ui/c-table/c-table.types.ts
Normal file
4
src/ui/c-table/c-table.types.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export type HeaderConfiguration = (string | {
|
||||
key: string
|
||||
label?: string
|
||||
})[] | Record<string, string>;
|
65
src/ui/c-table/c-table.vue
Normal file
65
src/ui/c-table/c-table.vue
Normal file
|
@ -0,0 +1,65 @@
|
|||
<script lang="ts" setup>
|
||||
import _ from 'lodash';
|
||||
import type { HeaderConfiguration } from './c-table.types';
|
||||
|
||||
const props = withDefaults(defineProps<{ data?: Record<string, unknown>[]; headers?: HeaderConfiguration ; hideHeaders?: boolean; description?: string }>(), { data: () => [], headers: undefined, hideHeaders: false, description: 'Data table' });
|
||||
const { data, headers: rawHeaders, hideHeaders } = toRefs(props);
|
||||
|
||||
const headers = computed(() => {
|
||||
if (rawHeaders.value) {
|
||||
if (Array.isArray(rawHeaders.value)) {
|
||||
return rawHeaders.value.map((value) => {
|
||||
if (typeof value === 'string') {
|
||||
return { key: value, label: value };
|
||||
}
|
||||
|
||||
const { key, label } = value;
|
||||
|
||||
return {
|
||||
key,
|
||||
label: label ?? key,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return _.map(rawHeaders.value, (value, key) => ({
|
||||
key, label: value,
|
||||
}));
|
||||
}
|
||||
|
||||
return _.chain(data.value)
|
||||
.map(row => Object.keys(row))
|
||||
.flatten()
|
||||
.uniq()
|
||||
.map(key => ({ key, label: key }))
|
||||
.value();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative overflow-x-auto rounded">
|
||||
<table class="w-full border-collapse text-left text-sm text-gray-500 dark:text-gray-400" role="table" :aria-label="description">
|
||||
<thead v-if="!hideHeaders" class="bg-#ffffff uppercase text-gray-700 dark:bg-#333333 dark:text-gray-400" border-b="1px solid dark:transparent #efeff5">
|
||||
<tr>
|
||||
<th v-for="header in headers" :key="header.key" scope="col" class="px-6 py-3 text-xs">
|
||||
{{ header.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, i) in data" :key="i" border-b="1px solid dark:#282828 #efeff5" class="bg-white dark:bg-#232323"
|
||||
:class="{
|
||||
'important:border-b-none': i === data.length - 1,
|
||||
}"
|
||||
>
|
||||
<td v-for="header in headers" :key="header.key" class="px-6 py-4">
|
||||
<slot :name="header.key" :row="row" :headers="headers" :value="row[header.key]">
|
||||
{{ row[header.key] }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
|
@ -1,3 +1,7 @@
|
|||
<script lang="ts" setup>
|
||||
const positions = ['top', 'bottom', 'left', 'right'] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<c-tooltip>
|
||||
|
@ -14,4 +18,18 @@
|
|||
Hover me
|
||||
</c-tooltip>
|
||||
</div>
|
||||
|
||||
<div mt-5>
|
||||
<h2>Tooltip positions</h2>
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div v-for="position in positions" :key="position">
|
||||
<c-tooltip :position="position" :tooltip="`Tooltip ${position}`">
|
||||
<c-button>
|
||||
{{ position }}
|
||||
</c-button>
|
||||
</c-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,23 +1,30 @@
|
|||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{ tooltip?: string }>(), { tooltip: '' });
|
||||
const { tooltip } = toRefs(props);
|
||||
const props = withDefaults(defineProps<{ tooltip?: string; position?: 'top' | 'bottom' | 'left' | 'right' }>(), {
|
||||
tooltip: undefined,
|
||||
position: 'top',
|
||||
});
|
||||
const { tooltip, position } = toRefs(props);
|
||||
|
||||
const targetRef = ref();
|
||||
const isTargetHovered = useElementHover(targetRef);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative" inline-block>
|
||||
<div relative inline-block>
|
||||
<div ref="targetRef">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="tooltip || $slots.tooltip"
|
||||
class="absolute bottom-100% left-50% z-10 mb-5px whitespace-nowrap rounded bg-black px-12px py-6px text-sm text-white shadow-lg transition transition transition-duration-0.2s -translate-x-1/2"
|
||||
class="absolute z-10 whitespace-nowrap rounded bg-black px-12px py-6px text-sm text-white shadow-lg transition transition transition-duration-0.2s"
|
||||
:class="{
|
||||
'op-0 scale-0': isTargetHovered === false,
|
||||
'op-100 scale-100': isTargetHovered,
|
||||
'bottom-100% left-50% -translate-x-1/2 mb-5px': position === 'top',
|
||||
'top-100% left-50% -translate-x-1/2 mt-5px': position === 'bottom',
|
||||
'right-100% top-50% -translate-y-1/2 mr-5px': position === 'left',
|
||||
'left-100% top-50% -translate-y-1/2 ml-5px': position === 'right',
|
||||
}"
|
||||
>
|
||||
<slot
|
||||
|
|
|
@ -4,10 +4,8 @@ import { demoRoutes } from './demo.routes';
|
|||
|
||||
<template>
|
||||
<div grid grid-cols-5 gap-2>
|
||||
<c-card v-for="{ name } of demoRoutes" :key="name" :title="String(name)">
|
||||
<c-button :to="{ name }">
|
||||
{{ name }}
|
||||
</c-button>
|
||||
</c-card>
|
||||
<c-button v-for="{ name } of demoRoutes" :key="name" :to="{ name }">
|
||||
{{ name }}
|
||||
</c-button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -12,7 +12,7 @@ const componentName = computed(() => _.startCase(String(route.name).replace(/^c-
|
|||
<h1>c-lib components</h1>
|
||||
|
||||
<div flex>
|
||||
<div w-30 b-r b-gray b-op-10 b-r-solid pr-4>
|
||||
<div w-200px b-r b-gray b-op-10 b-r-solid pr-4>
|
||||
<c-button
|
||||
v-for="{ name } of demoRoutes"
|
||||
:key="name"
|
||||
|
@ -20,6 +20,7 @@ const componentName = computed(() => _.startCase(String(route.name).replace(/^c-
|
|||
:to="{ name }"
|
||||
w-full
|
||||
important:justify-start
|
||||
important:text-left
|
||||
:type="route.name === name ? 'primary' : 'default'"
|
||||
>
|
||||
{{ name }}
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import type { RouteRecordRaw } from 'vue-router';
|
||||
import DemoHome from './demo-home.page.vue';
|
||||
|
||||
const demoPages = import.meta.glob('../*/*.demo.vue');
|
||||
const demoPages = import.meta.glob('../*/*.demo.vue', { eager: true });
|
||||
|
||||
export const demoRoutes = Object.keys(demoPages).map((path) => {
|
||||
const [, , fileName] = path.split('/');
|
||||
const name = fileName.split('.').shift();
|
||||
export const demoRoutes = Object.keys(demoPages).map((demoComponentPath) => {
|
||||
const [, , fileName] = demoComponentPath.split('/');
|
||||
const demoComponentName = fileName.split('.').shift();
|
||||
|
||||
return {
|
||||
path: name,
|
||||
name,
|
||||
component: () => import(/* @vite-ignore */ path),
|
||||
path: demoComponentName,
|
||||
name: demoComponentName,
|
||||
component: () => import(/* @vite-ignore */ demoComponentPath),
|
||||
} as RouteRecordRaw;
|
||||
});
|
||||
|
||||
|
|
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