feat(i18n): language selector (#710)

This commit is contained in:
Corentin THOMASSET 2023-11-01 15:38:19 +01:00 committed by GitHub
parent 58de8970f5
commit e86fd96ae3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 182 additions and 46 deletions

2
components.d.ts vendored
View file

@ -98,6 +98,7 @@ declare module '@vue/runtime-core' {
IconMdiRecord: typeof import('~icons/mdi/record')['default'] IconMdiRecord: typeof import('~icons/mdi/record')['default']
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default'] IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
IconMdiSearch: typeof import('~icons/mdi/search')['default'] IconMdiSearch: typeof import('~icons/mdi/search')['default']
IconMdiTranslate: typeof import('~icons/mdi/translate')['default']
IconMdiVideo: typeof import('~icons/mdi/video')['default'] IconMdiVideo: typeof import('~icons/mdi/video')['default']
InputCopyable: typeof import('./src/components/InputCopyable.vue')['default'] InputCopyable: typeof import('./src/components/InputCopyable.vue')['default']
IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default'] IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default']
@ -114,6 +115,7 @@ declare module '@vue/runtime-core' {
JwtParser: typeof import('./src/tools/jwt-parser/jwt-parser.vue')['default'] JwtParser: typeof import('./src/tools/jwt-parser/jwt-parser.vue')['default']
KeycodeInfo: typeof import('./src/tools/keycode-info/keycode-info.vue')['default'] KeycodeInfo: typeof import('./src/tools/keycode-info/keycode-info.vue')['default']
ListConverter: typeof import('./src/tools/list-converter/list-converter.vue')['default'] ListConverter: typeof import('./src/tools/list-converter/list-converter.vue')['default']
LocaleSelector: typeof import('./src/modules/i18n/components/locale-selector.vue')['default']
LoremIpsumGenerator: typeof import('./src/tools/lorem-ipsum-generator/lorem-ipsum-generator.vue')['default'] LoremIpsumGenerator: typeof import('./src/tools/lorem-ipsum-generator/lorem-ipsum-generator.vue')['default']
MacAddressGenerator: typeof import('./src/tools/mac-address-generator/mac-address-generator.vue')['default'] MacAddressGenerator: typeof import('./src/tools/mac-address-generator/mac-address-generator.vue')['default']
MacAddressLookup: typeof import('./src/tools/mac-address-lookup/mac-address-lookup.vue')['default'] MacAddressLookup: typeof import('./src/tools/mac-address-lookup/mac-address-lookup.vue')['default']

View file

@ -28,7 +28,7 @@ home:
about: about:
h1: 'About IT-Tools' h1: 'About IT-Tools'
h1p1: 'This wonderful website, made with ❤ by' h1p1: 'This wonderful website, made with ❤ by'
h1p2: ', aggregates useful tools for developer and people working in IT. If you find it useful, please feel free to share it to people you think may find it useful too and don''''t forget to bookmark it in your shortcut bar!' h1p2: ", aggregates useful tools for developer and people working in IT. If you find it useful, please feel free to share it to people you think may find it useful too and don''t forget to bookmark it in your shortcut bar!"
h1p3: 'IT Tools is open-source (under the MIT license) and free, and will always be, but it costs me money to host and renew the domain name. If you want to support my work, and encourage me to add more tools, please consider supporting by' h1p3: 'IT Tools is open-source (under the MIT license) and free, and will always be, but it costs me money to host and renew the domain name. If you want to support my work, and encourage me to add more tools, please consider supporting by'
h1p4: 'sponsoring me' h1p4: 'sponsoring me'
h2: Technologies h2: Technologies
@ -38,7 +38,7 @@ about:
h3p1: 'If you need a tool that is currently not present here, and you think can be useful, you are welcome to submit a feature request in the' h3p1: 'If you need a tool that is currently not present here, and you think can be useful, you are welcome to submit a feature request in the'
h3p2: 'issues section' h3p2: 'issues section'
h3p3: 'in the GitHub repository.' h3p3: 'in the GitHub repository.'
h3p4: 'And if you found a bug, or something doesn''''t work as expected, please file a bug report in the' h3p4: "And if you found a bug, or something doesn''t work as expected, please file a bug report in the"
h3p5: 'issues section' h3p5: 'issues section'
h3p6: 'in the GitHub repository.' h3p6: 'in the GitHub repository.'
404: 404:
@ -48,4 +48,18 @@ about:
backHome: 'Back home' backHome: 'Back home'
toolCard: toolCard:
new: New new: New
search:
label: Search
tools:
categories:
favorite-tools: 'Your favorite tools'
crypto: Crypto
converter: Converter
web: Web
images and videos: 'Images & Videos'
development: Development
network: Network
math: Math
measurement: Measurement
text: Text
data: Data

View file

@ -1,3 +1,49 @@
home: home:
categories: categories:
newestTools: "Nouveaux outils" newestTools: 'Les nouveaux outils'
favoriteTools: 'Vos outils favoris'
allTools: 'Tous les outils'
subtitle: 'Outils pour les développeurs'
toggleMenu: 'Menu'
home: Accueil
uiLib: 'UI Lib'
buyMeACoffee: 'Soutenez IT-Tools'
follow:
title: 'Vous aimez it-tools ?'
p1: 'Soutenez-nous avec une star sur'
githubRepository: "le dépôt GitHub d'IT-Tools"
p2: 'ou suivez-nous sur'
twitterAccount: "le compte Twitter d'IT-Tools"
thankYou: 'Merci !'
nav:
github: 'Dépôt GitHub'
githubRepository: "Dépôt GitHub d'IT-Tools"
twitter: 'Compte Twitter'
twitterAccount: "Compte Twitter d'IT-Tools"
about: "À propos d'IT-Tools"
aboutLabel: 'À propos'
darkMode: 'Mode sombre'
lightMode: 'Mode clair'
mode: 'Basculer le mode sombre/clair'
404:
notFound: '404 Not Found'
sorry: "Désolé, cette page n'existe pas"
maybe: 'Peut-être que le cache fait des siennes, essayez de forcer le rafraîchissement ?'
backHome: "Retour à l'accueil"
toolCard:
new: Nouveau
search:
label: Rechercher
tools:
categories:
favorite-tools: 'Vos outils favoris'
crypto: Cryptographie
converter: Convertisseur
web: Web
images and videos: 'Images & Vidéos'
development: Développement
network: Réseau
math: Math
measurement: Mesure
text: Texte
data: Données

View file

@ -11,6 +11,13 @@ const styleStore = useStyleStore();
const theme = computed(() => (styleStore.isDarkTheme ? darkTheme : null)); const theme = computed(() => (styleStore.isDarkTheme ? darkTheme : null));
const themeOverrides = computed(() => (styleStore.isDarkTheme ? darkThemeOverrides : lightThemeOverrides)); const themeOverrides = computed(() => (styleStore.isDarkTheme ? darkThemeOverrides : lightThemeOverrides));
const { locale } = useI18n();
syncRef(
locale,
useStorage('locale', locale),
);
</script> </script>
<template> <template>

View file

@ -36,7 +36,7 @@ const menuOptions = computed(() =>
tools: components.map(tool => ({ tools: components.map(tool => ({
label: makeLabel(tool), label: makeLabel(tool),
icon: makeIcon(tool), icon: makeIcon(tool),
key: tool.name, key: tool.path,
})), })),
})), })),
); );
@ -62,7 +62,7 @@ const themeVars = useThemeVars();
<n-menu <n-menu
class="menu" class="menu"
:value="route.name as string" :value="route.path"
:collapsed-width="64" :collapsed-width="64"
:collapsed-icon-size="22" :collapsed-icon-size="22"
:options="tools" :options="tools"

View file

@ -4,10 +4,10 @@ import { NIcon, useThemeVars } from 'naive-ui';
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import { Heart, Home2, Menu2 } from '@vicons/tabler'; import { Heart, Home2, Menu2 } from '@vicons/tabler';
import { storeToRefs } from 'pinia';
import HeroGradient from '../assets/hero-gradient.svg?component'; import HeroGradient from '../assets/hero-gradient.svg?component';
import MenuLayout from '../components/MenuLayout.vue'; import MenuLayout from '../components/MenuLayout.vue';
import NavbarButtons from '../components/NavbarButtons.vue'; import NavbarButtons from '../components/NavbarButtons.vue';
import { toolsByCategory } from '@/tools';
import { useStyleStore } from '@/stores/style.store'; import { useStyleStore } from '@/stores/style.store';
import { config } from '@/config'; import { config } from '@/config';
import type { ToolCategory } from '@/tools/tools.types'; 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 commitSha = config.app.lastCommitSha.slice(0, 7);
const { tracker } = useTracker(); const { tracker } = useTracker();
const { t } = useI18n();
const toolStore = useToolStore(); const toolStore = useToolStore();
const { favoriteTools, toolsByCategory } = storeToRefs(toolStore);
const tools = computed<ToolCategory[]>(() => [ const tools = computed<ToolCategory[]>(() => [
...(toolStore.favoriteTools.length > 0 ? [{ name: 'Your favorite tools', components: toolStore.favoriteTools }] : []), ...(favoriteTools.value.length > 0 ? [{ name: t('tools.categories.favorite-tools'), components: favoriteTools.value }] : []),
...toolsByCategory, ...toolsByCategory.value,
]); ]);
</script> </script>
@ -47,8 +49,12 @@ const tools = computed<ToolCategory[]>(() => [
</RouterLink> </RouterLink>
<div class="sider-content"> <div class="sider-content">
<div v-if="styleStore.isSmallScreen" flex justify-center> <div v-if="styleStore.isSmallScreen" flex flex-col items-center>
<NavbarButtons /> <locale-selector w="90%" />
<div flex justify-center>
<NavbarButtons />
</div>
</div> </div>
<CollapsibleToolMenu :tools-by-category="tools" /> <CollapsibleToolMenu :tools-by-category="tools" />
@ -108,6 +114,8 @@ const tools = computed<ToolCategory[]>(() => [
<command-palette /> <command-palette />
<locale-selector v-if="!styleStore.isSmallScreen" />
<div> <div>
<NavbarButtons v-if="!styleStore.isSmallScreen" /> <NavbarButtons v-if="!styleStore.isSmallScreen" />
</div> </div>

View file

@ -116,7 +116,7 @@ function activateOption(option: PaletteOption) {
<span flex items-center gap-3 op-40> <span flex items-center gap-3 op-40>
<icon-mdi-search /> <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> <span hidden flex-1 border border-current border-op-40 rounded border-solid px-5px py-3px sm:inline>
{{ isMac ? 'Cmd' : 'Ctrl' }}&nbsp;+&nbsp;K {{ isMac ? 'Cmd' : 'Ctrl' }}&nbsp;+&nbsp;K

View file

@ -0,0 +1,28 @@
<script setup lang="ts">
const { availableLocales, locale } = useI18n();
const localesLong: Record<string, string> = {
en: 'English',
es: 'Español',
fr: 'Français',
pt: 'Português',
ru: 'Русский',
zh: '中文',
};
const localeOptions = computed(() =>
availableLocales.map(locale => ({
label: localesLong[locale] ?? locale,
value: locale,
})),
);
</script>
<template>
<c-select
v-model:value="locale"
:options="localeOptions"
placeholder="Select a language"
w-100px
/>
</template>

View file

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

View file

@ -1,44 +1,57 @@
import { type MaybeRef, get, useStorage } from '@vueuse/core'; import { type MaybeRef, get, useStorage } from '@vueuse/core';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import type { Ref } from 'vue'; 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'; import { toolsWithCategory } from './index';
export const useToolStore = defineStore('tools', { export const useToolStore = defineStore('tools', () => {
state: () => ({ const favoriteToolsName = useStorage('favoriteToolsName', []) as Ref<string[]>;
favoriteToolsName: useStorage('favoriteToolsName', []) as Ref<string[]>, const { t } = useI18n();
}),
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
},
notFavoriteTools(state): ToolWithCategory[] { const tools = computed<ToolWithCategory[]>(() => toolsWithCategory.map((tool) => {
return toolsWithCategory.filter(tool => !state.favoriteToolsName.includes(tool.name)); const toolI18nKey = tool.path.replace(/\//g, '');
},
tools(): ToolWithCategory[] { return ({
return toolsWithCategory; ...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[] { const toolsByCategory = computed<ToolCategory[]>(() => {
return this.tools.filter(({ isNew }) => isNew); 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> }) { addToolToFavorites({ tool }: { tool: MaybeRef<Tool> }) {
this.favoriteToolsName.push(get(tool).name); favoriteToolsName.value.push(get(tool).name);
}, },
removeToolFromFavorites({ tool }: { tool: MaybeRef<Tool> }) { 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> }) { isToolFavorite({ tool }: { tool: MaybeRef<Tool> }) {
return this.favoriteToolsName.includes(get(tool).name); return favoriteToolsName.value.includes(get(tool).name);
}, },
}, };
}); });

View file

@ -33,4 +33,19 @@ const value = ref('');
<c-select label="Label" label-position="left" label-align="left" mb-2 label-width="200px" /> <c-select label="Label" label-position="left" label-align="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="center" mb-2 label-width="200px" />
<c-select label="Label" label-position="left" label-align="right" 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> </template>

View file

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