feat: localizations

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-07 18:16:54 -04:00
parent f4fb8277d2
commit 0099a63fad
11 changed files with 353 additions and 94 deletions
+41 -39
View File
@@ -2,8 +2,10 @@
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@KTXC/stores/userStore'
import { userService } from '@KTXC/services/user/userService'
import { useL10n } from '@KTXC'
const userStore = useUserStore()
const { t } = useL10n('user_profile')
const refInputEl = ref<HTMLElement>()
const isSaving = ref(false)
@@ -134,27 +136,27 @@ const resetAvatar = () => {
}
}
const countries = [
'United States',
'United Kingdom',
'Canada',
'Australia',
'Germany',
'France',
'India',
'China',
'Japan',
'Brazil',
'Mexico',
'South Africa',
]
const countries = computed(() => [
{ value: 'United States', title: t('account.countries.unitedStates', 'United States') },
{ value: 'United Kingdom', title: t('account.countries.unitedKingdom', 'United Kingdom') },
{ value: 'Canada', title: t('account.countries.canada', 'Canada') },
{ value: 'Australia', title: t('account.countries.australia', 'Australia') },
{ value: 'Germany', title: t('account.countries.germany', 'Germany') },
{ value: 'France', title: t('account.countries.france', 'France') },
{ value: 'India', title: t('account.countries.india', 'India') },
{ value: 'China', title: t('account.countries.china', 'China') },
{ value: 'Japan', title: t('account.countries.japan', 'Japan') },
{ value: 'Brazil', title: t('account.countries.brazil', 'Brazil') },
{ value: 'Mexico', title: t('account.countries.mexico', 'Mexico') },
{ value: 'South Africa', title: t('account.countries.southAfrica', 'South Africa') },
])
</script>
<template>
<VRow>
<VCol cols="12">
<VCard title="Account Details">
<VCard :title="t('account.title', 'Account Details')">
<VCardText class="d-flex">
<!-- 👉 Avatar -->
<VAvatar
@@ -172,7 +174,7 @@ const countries = [
@click="refInputEl?.click()"
>
<VIcon icon="mdi-cloud-upload" class="d-sm-none" />
<span class="d-none d-sm-block">Upload new photo</span>
<span class="d-none d-sm-block">{{ t('account.uploadPhoto', 'Upload new photo') }}</span>
</VBtn>
<input
@@ -190,13 +192,13 @@ const countries = [
variant="outlined"
@click="resetAvatar"
>
<span class="d-none d-sm-block">Reset</span>
<span class="d-none d-sm-block">{{ t('actions.reset', 'Reset') }}</span>
<VIcon icon="mdi-refresh" class="d-sm-none" />
</VBtn>
</div>
<p class="text-body-1 mb-0">
Allowed JPG, GIF or PNG. Max size of 800K
{{ t('account.photoRequirements', 'Allowed JPG, GIF or PNG. Max size of 800K') }}
</p>
</form>
</VCardText>
@@ -214,10 +216,10 @@ const countries = [
>
<VTextField
v-model="accountDataLocal.name_given"
placeholder="John"
label="First Name"
:placeholder="t('account.firstNamePlaceholder', 'John')"
:label="t('account.firstName', 'First Name')"
:readonly="!isFieldEditable('name_given')"
:hint="!isFieldEditable('name_given') ? 'Managed by authentication provider' : ''"
:hint="!isFieldEditable('name_given') ? t('account.managedFieldHint', 'Managed by authentication provider') : ''"
persistent-hint
/>
</VCol>
@@ -229,10 +231,10 @@ const countries = [
>
<VTextField
v-model="accountDataLocal.name_family"
placeholder="Doe"
label="Last Name"
:placeholder="t('account.lastNamePlaceholder', 'Doe')"
:label="t('account.lastName', 'Last Name')"
:readonly="!isFieldEditable('name_family')"
:hint="!isFieldEditable('name_family') ? 'Managed by authentication provider' : ''"
:hint="!isFieldEditable('name_family') ? t('account.managedFieldHint', 'Managed by authentication provider') : ''"
persistent-hint
/>
</VCol>
@@ -244,11 +246,11 @@ const countries = [
>
<VTextField
v-model="accountDataLocal.email"
label="E-mail"
:label="t('account.email', 'E-mail')"
placeholder="john.doe@example.com"
type="email"
:readonly="!isFieldEditable('email')"
:hint="!isFieldEditable('email') ? 'Managed by authentication provider' : ''"
:hint="!isFieldEditable('email') ? t('account.managedFieldHint', 'Managed by authentication provider') : ''"
persistent-hint
/>
</VCol>
@@ -260,10 +262,10 @@ const countries = [
>
<VTextField
v-model="accountDataLocal.phone"
label="Phone Number"
:label="t('account.phoneNumber', 'Phone Number')"
placeholder="+1 (555) 123-4567"
:readonly="!isFieldEditable('phone')"
:hint="!isFieldEditable('phone') ? 'Managed by authentication provider' : ''"
:hint="!isFieldEditable('phone') ? t('account.managedFieldHint', 'Managed by authentication provider') : ''"
persistent-hint
/>
</VCol>
@@ -275,8 +277,8 @@ const countries = [
>
<VTextField
v-model="accountDataLocal.address"
label="Address"
placeholder="123 Main St"
:label="t('account.address', 'Address')"
:placeholder="t('account.addressPlaceholder', '123 Main St')"
/>
</VCol>
@@ -287,8 +289,8 @@ const countries = [
>
<VTextField
v-model="accountDataLocal.city"
label="City"
placeholder="New York"
:label="t('account.city', 'City')"
:placeholder="t('account.cityPlaceholder', 'New York')"
/>
</VCol>
@@ -299,8 +301,8 @@ const countries = [
>
<VTextField
v-model="accountDataLocal.state"
label="State/Province"
placeholder="State"
:label="t('account.stateOrProvince', 'State/Province')"
:placeholder="t('account.statePlaceholder', 'State')"
/>
</VCol>
@@ -311,7 +313,7 @@ const countries = [
>
<VTextField
v-model="accountDataLocal.zip"
label="Zip Code"
:label="t('account.zipCode', 'Zip Code')"
placeholder="10001"
/>
</VCol>
@@ -323,9 +325,9 @@ const countries = [
>
<VSelect
v-model="accountDataLocal.country"
label="Country"
:label="t('account.country', 'Country')"
:items="countries"
placeholder="Select Country"
:placeholder="t('account.selectCountry', 'Select Country')"
/>
</VCol>
@@ -339,7 +341,7 @@ const countries = [
:loading="isSaving"
color="primary"
>
Save changes
{{ t('actions.saveChanges', 'Save changes') }}
</VBtn>
<VBtn
@@ -348,7 +350,7 @@ const countries = [
type="reset"
@click.prevent="resetForm"
>
Reset
{{ t('actions.reset', 'Reset') }}
</VBtn>
</VCol>
</VRow>
+36 -43
View File
@@ -1,69 +1,62 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useL10n } from '@KTXC'
const recentDevices = ref(
[
{
type: 'New for you',
email: true,
browser: true,
app: true,
},
{
type: 'Account activity',
email: true,
browser: true,
app: true,
},
{
type: 'A new browser used to sign in',
email: true,
browser: true,
app: false,
},
{
type: 'A new device is linked',
email: true,
browser: false,
app: false,
},
],
)
const { t } = useL10n('user_profile')
const selectedNotification = ref('Only when I\'m online')
const recentDevices = ref([
{ id: 'newForYou', email: true, browser: true, app: true },
{ id: 'accountActivity', email: true, browser: true, app: true },
{ id: 'newBrowserSignIn', email: true, browser: true, app: false },
{ id: 'newDeviceLinked', email: true, browser: false, app: false },
])
const notificationTypeLabels = computed<Record<string, string>>(() => ({
newForYou: t('notifications.types.newForYou', 'New for you'),
accountActivity: t('notifications.types.accountActivity', 'Account activity'),
newBrowserSignIn: t('notifications.types.newBrowserSignIn', 'A new browser used to sign in'),
newDeviceLinked: t('notifications.types.newDeviceLinked', 'A new device is linked'),
}))
const notificationTimingOptions = computed(() => [
{ value: 'online', title: t('notifications.timing.online', 'Only when I\'m online') },
{ value: 'anytime', title: t('notifications.timing.anytime', 'Anytime') },
])
const selectedNotification = ref('online')
</script>
<template>
<VCard title="Notifications">
<VCard :title="t('notifications.title', 'Notifications')">
<VCardText>
We need permission from your browser to show notifications.
<a href="javascript:void(0)">Request Permission</a>
{{ t('notifications.permissionDescription', 'We need permission from your browser to show notifications.') }}
<a href="javascript:void(0)">{{ t('notifications.requestPermission', 'Request Permission') }}</a>
</VCardText>
<VTable class="text-no-wrap">
<thead>
<tr>
<th scope="col">
Type
{{ t('notifications.columns.type', 'Type') }}
</th>
<th scope="col">
EMAIL
{{ t('notifications.columns.email', 'EMAIL') }}
</th>
<th scope="col">
BROWSER
{{ t('notifications.columns.browser', 'BROWSER') }}
</th>
<th scope="col">
App
{{ t('notifications.columns.app', 'App') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="device in recentDevices"
:key="device.type"
:key="device.id"
>
<td>
{{ device.type }}
{{ notificationTypeLabels[device.id] }}
</td>
<td>
<VCheckbox v-model="device.email" />
@@ -82,7 +75,7 @@ const selectedNotification = ref('Only when I\'m online')
<VCardText>
<VForm @submit.prevent="() => {}">
<p class="text-base font-weight-medium">
When should we send you notifications?
{{ t('notifications.timingQuestion', 'When should we send you notifications?') }}
</p>
<VRow>
@@ -93,21 +86,21 @@ const selectedNotification = ref('Only when I\'m online')
<VSelect
v-model="selectedNotification"
mandatory
:items="['Only when I\'m online', 'Anytime']"
:items="notificationTimingOptions"
/>
</VCol>
</VRow>
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn type="submit">
Save Changes
{{ t('actions.saveChanges', 'Save changes') }}
</VBtn>
<VBtn
color="secondary"
variant="outlined"
type="reset"
>
Reset
{{ t('actions.reset', 'Reset') }}
</VBtn>
</div>
</VForm>
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useL10nStore, useL10n } from '@KTXC'
const l10nStore = useL10nStore()
const { t } = useL10n('user_profile')
// Offer each available locale under its own native name
const languageOptions = computed(() =>
l10nStore.available.map((locale: string) => {
let title = locale
try {
title = new Intl.DisplayNames([locale], { type: 'language' }).of(locale) ?? locale
} catch {
// unknown tag: fall back to the raw locale code
}
return { value: locale, title }
})
)
const selectLanguage = (locale: string | null) => {
if (locale && locale !== l10nStore.locale) {
l10nStore.setLocale(locale)
}
}
</script>
<template>
<VRow>
<VCol cols="12" md="6">
<VCard :title="t('preferences.languageTitle', 'Language')">
<VCardText>
<VSelect
:model-value="l10nStore.locale"
:items="languageOptions"
:label="t('preferences.languageLabel', 'Display language')"
:hint="t('preferences.languageHint', 'Applies immediately and is saved to your account')"
persistent-hint
@update:model-value="selectLanguage"
/>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>
+6 -2
View File
@@ -1,8 +1,10 @@
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { useIntegrationStore } from '@KTXC/stores/integrationStore'
import { useL10n } from '@KTXC'
const integrationStore = useIntegrationStore()
const { t } = useL10n('user_profile')
// Get all registered security panel components from modules
const securityPanels = computed(() => {
@@ -59,9 +61,11 @@ onMounted(async () => {
class="mb-4"
color="grey"
/>
<p class="text-h6 mb-2">No Security Settings Available</p>
<p class="text-h6 mb-2">
{{ t('security.emptyTitle', 'No Security Settings Available') }}
</p>
<p class="text-body-2 text-medium-emphasis">
No security modules are currently enabled or installed.
{{ t('security.emptyDescription', 'No security modules are currently enabled or installed.') }}
</p>
</VCardText>
</VCard>
+5 -1
View File
@@ -5,17 +5,19 @@ const integrations: ModuleIntegrations = {
{
id: 'user_profile',
label: 'Profile & Settings',
l10n: 'nav.profileSettings',
path: '/me',
icon: 'mdi-account-outline',
priority: 90,
},
],
// User personal settings
user_settings_menu: [
{
id: 'profile',
label: 'Profile',
l10n: 'nav.profile',
path: '/me/profile',
icon: 'mdi-account-circle',
priority: 10,
@@ -23,6 +25,7 @@ const integrations: ModuleIntegrations = {
{
id: 'preferences',
label: 'Preferences',
l10n: 'nav.preferences',
path: '/me/preferences',
icon: 'mdi-tune',
priority: 20,
@@ -30,6 +33,7 @@ const integrations: ModuleIntegrations = {
{
id: 'security',
label: 'Security',
l10n: 'nav.security',
path: '/me/security',
icon: 'mdi-shield-lock',
priority: 30,
+3
View File
@@ -1,4 +1,7 @@
import routes from '@/routes'
import integrations from '@/integrations'
// Module ships translation catalogs at static/l10n/{locale}.json
export const l10n = true
export { routes, integrations }
+15 -6
View File
@@ -1,20 +1,24 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import ProfileAccount from '@/components/ProfileAccount.vue'
import ProfileNotifications from '@/components/ProfileNotifications.vue'
import ProfilePreferences from '@/components/ProfilePreferences.vue'
import ProfileSecurity from '@/components/ProfileSecurity.vue'
import { useL10n } from '@KTXC'
const route = useRoute()
const { t } = useL10n('user_profile')
const activeTab = ref(route.params.tab || 'account')
// tabs
const tabs = [
{ title: 'Account', icon: 'mdi-account-outline', tab: 'account' },
{ title: 'Security', icon: 'mdi-lock-outline', tab: 'security' },
{ title: 'Notifications', icon: 'mdi-bell-outline', tab: 'notifications' },
]
const tabs = computed(() => [
{ title: t('tabs.account', 'Account'), icon: 'mdi-account-outline', tab: 'account' },
{ title: t('tabs.security', 'Security'), icon: 'mdi-lock-outline', tab: 'security' },
{ title: t('tabs.preferences', 'Preferences'), icon: 'mdi-tune', tab: 'preferences' },
{ title: t('tabs.notifications', 'Notifications'), icon: 'mdi-bell-outline', tab: 'notifications' },
])
</script>
<template>
@@ -61,6 +65,11 @@ const tabs = [
<VWindowItem value="notifications">
<ProfileNotifications />
</VWindowItem>
<!-- Preferences -->
<VWindowItem value="preferences">
<ProfilePreferences />
</VWindowItem>
</VWindow>
</PerfectScrollbar>
</template>