From 428c300e9f42f8b9232f780d387292c1a94fcd23 Mon Sep 17 00:00:00 2001 From: Rémy Raes Date: Wed, 29 Mar 2023 00:45:16 +0200 Subject: feat: i18n (#182) * build: add vue-i18n dependency * feat: add i18n plugin to vue project * feat: use translations in play button * feat: translate play view * feat: translate menu items * feat: translate local mods view * feat: translate online mods view * feat: translate mods menu * feat: translate thunderstore mod card component * fix: remove useless "this" keyword * feat: translate settings view * fix: remove leftover test invocation * feat: add language selector component * feat: using language selector updates interface's language * feat: save language in persistent store on selector change * feat: initialize lang on app launch * refactor: move i18n code into App.mounted callback * feat: update interface language on app launch * feat: adjust language selection on language selector load * fix: this.$root can't be null * feat: translate store notifications * fix: add missing parameter to english translation * feat: translate "by" author keyword * feat: translate repair window * feat: translate repair window title * docs: add some documentation regarding localization * docs: explain how to add a new language * feat: translate Northstar release canal selector elements * docs: describe how to inject variable into translations * feat: translate "info" word * feat: translate popconfirm buttons * fix: remove "this" keyword * fix: save store when updating interface language --- src-vue/src/App.vue | 25 ++-- src-vue/src/components/LanguageSelector.vue | 47 +++++++ src-vue/src/components/ModsMenu.vue | 14 +-- src-vue/src/components/PlayButton.vue | 16 +-- src-vue/src/components/ThunderstoreModCard.vue | 34 +++--- src-vue/src/i18n/lang/en.ts | 156 ++++++++++++++++++++++++ src-vue/src/i18n/lang/fr.ts | 156 ++++++++++++++++++++++++ src-vue/src/main.ts | 12 ++ src-vue/src/plugins/store.ts | 26 ++-- src-vue/src/utils/SortOptions.d.ts | 12 +- src-vue/src/views/PlayView.vue | 14 +-- src-vue/src/views/RepairView.vue | 27 ++-- src-vue/src/views/SettingsView.vue | 47 ++++--- src-vue/src/views/mods/LocalModsView.vue | 18 +-- src-vue/src/views/mods/ThunderstoreModsView.vue | 4 +- 15 files changed, 510 insertions(+), 98 deletions(-) create mode 100644 src-vue/src/components/LanguageSelector.vue create mode 100644 src-vue/src/i18n/lang/en.ts create mode 100644 src-vue/src/i18n/lang/fr.ts (limited to 'src-vue/src') diff --git a/src-vue/src/App.vue b/src-vue/src/App.vue index f740bd2f..86a1bb37 100644 --- a/src-vue/src/App.vue +++ b/src-vue/src/App.vue @@ -6,7 +6,8 @@ import ModsView from './views/ModsView.vue'; import SettingsView from './views/SettingsView.vue'; import { appWindow } from '@tauri-apps/api/window'; import { store } from './plugins/store'; -import { invoke, window as tauriWindow } from "@tauri-apps/api"; +import { Store } from 'tauri-plugin-store-api'; +import { invoke } from "@tauri-apps/api"; export default { components: { @@ -19,8 +20,18 @@ export default { data() { return {} }, - mounted: () => { + mounted: async function() { store.commit('initialize'); + + // Initialize interface language + const persistentStore = new Store('flight-core-settings.json'); + let lang: string | null = await persistentStore.get('lang'); + if (lang === null) { + lang = navigator.language.substring(0, 2); + persistentStore.set('lang', lang); + await persistentStore.save(); + } + this.$root!.$i18n.locale = lang; }, methods: { async toggleMaximize() { @@ -56,11 +67,11 @@ export default { id="fc__menu_items" data-tauri-drag-region > - Play - Changelog - Mods - Settings - Dev + {{ $t('menu.play') }} + {{ $t('menu.changelog') }} + {{ $t('menu.mods') }} + {{ $t('menu.settings') }} + {{ $t('menu.dev') }} diff --git a/src-vue/src/components/LanguageSelector.vue b/src-vue/src/components/LanguageSelector.vue new file mode 100644 index 00000000..c09f6c05 --- /dev/null +++ b/src-vue/src/components/LanguageSelector.vue @@ -0,0 +1,47 @@ + + + diff --git a/src-vue/src/components/ModsMenu.vue b/src-vue/src/components/ModsMenu.vue index 9b62fcfa..656c05a6 100644 --- a/src-vue/src/components/ModsMenu.vue +++ b/src-vue/src/components/ModsMenu.vue @@ -7,20 +7,20 @@
Mods
- Local + {{ $t('mods.menu.local') }} - Online + {{ $t('mods.menu.online') }} -
Filter
- +
{{ $t('mods.menu.filter') }}
+ ({ value: key, - label: Object.values(SortOptions)[Object.keys(SortOptions).indexOf(key)] + label: this.$t('mods.menu.sort.' + Object.values(SortOptions)[Object.keys(SortOptions).indexOf(key)]) })); } } diff --git a/src-vue/src/components/PlayButton.vue b/src-vue/src/components/PlayButton.vue index 687f12a4..3efcc9f5 100644 --- a/src-vue/src/components/PlayButton.vue +++ b/src-vue/src/components/PlayButton.vue @@ -18,22 +18,22 @@ export default defineComponent({ }, playButtonLabel(): string { if (this.$store.state.northstar_is_running) { - return "Game is running"; + return this.$t("play.button.northstar_is_running"); } switch(this.$store.state.northstar_state) { case NorthstarState.GAME_NOT_FOUND: - return "Select Titanfall2 game folder"; + return this.$t("play.button.select_game_dir"); case NorthstarState.INSTALL: - return "Install"; + return this.$t("play.button.install"); case NorthstarState.INSTALLING: - return "Installing..." + return this.$t("play.button.installing"); case NorthstarState.MUST_UPDATE: - return "Update"; + return this.$t("play.button.update"); case NorthstarState.UPDATING: - return "Updating..."; + return this.$t("play.button.updating"); case NorthstarState.READY_TO_PLAY: - return "Launch game"; + return this.$t("play.button.ready_to_play"); default: return ""; @@ -57,7 +57,7 @@ export default defineComponent({ options: [ { value: ReleaseCanal.RELEASE_CANDIDATE, - label: 'Northstar release candidate', + label: this.$t('channels.names.NorthstarReleaseCandidate'), }, ] }, diff --git a/src-vue/src/components/ThunderstoreModCard.vue b/src-vue/src/components/ThunderstoreModCard.vue index c9f6768c..b81ed03c 100644 --- a/src-vue/src/components/ThunderstoreModCard.vue +++ b/src-vue/src/components/ThunderstoreModCard.vue @@ -21,7 +21,7 @@
{{ mod.name }}
-
by {{ mod.owner }}
+
{{ $t('mods.card.by') }} {{ mod.owner }}
{{ latestVersion.description }}
@@ -33,7 +33,7 @@ :loading="isBeingInstalled || isBeingUpdated" @click.stop="installMod(mod)" > - {{ modButtonText }} + {{ $t(modButtonText) }} @@ -51,10 +51,10 @@ @@ -129,15 +129,15 @@ export default defineComponent({ modButtonText(): string { switch (this.modStatus) { case ThunderstoreModStatus.BEING_INSTALLED: - return "Installing..."; + return "mods.card.button.being_installed"; case ThunderstoreModStatus.BEING_UPDATED: - return "Updating..."; + return "mods.card.button.being_updated"; case ThunderstoreModStatus.INSTALLED: - return "Installed"; + return "mods.card.button.installed"; case ThunderstoreModStatus.NOT_INSTALLED: - return "Install"; + return "mods.card.button.install"; case ThunderstoreModStatus.OUTDATED: - return "Update"; + return "mods.card.button.outdated"; } }, @@ -200,11 +200,11 @@ export default defineComponent({ // Show pop-up to confirm delete ElMessageBox.confirm( - 'Delete Thunderstore mod?', - 'Warning', + this.$t('mods.card.remove_dialog_text'), + this.$t('mods.card.remove_dialog_title'), { - confirmButtonText: 'OK', - cancelButtonText: 'Cancel', + confirmButtonText: this.$t('generic.yes'), + cancelButtonText: this.$t('generic.cancel'), type: 'warning', } ) @@ -217,7 +217,7 @@ export default defineComponent({ await invoke("delete_thunderstore_mod", { gameInstall: game_install, thunderstoreModString: this.latestVersion.full_name }) .then((message) => { ElNotification({ - title: `Removed ${mod.name}`, + title: this.$t('mods.card.remove_success', {modName: mod.name}), message: message as string, type: 'success', position: 'bottom-right' @@ -225,7 +225,7 @@ export default defineComponent({ }) .catch((error) => { ElNotification({ - title: 'Error', + title: this.$t('generic.error'), message: error, type: 'error', position: 'bottom-right' @@ -255,7 +255,7 @@ export default defineComponent({ await invoke("install_mod_caller", { gameInstall: game_install, thunderstoreModString: this.latestVersion.full_name }).then((message) => { ElNotification({ - title: `Installed ${mod.name}`, + title: this.$t('mods.card.install_success', {modName: mod.name}), message: message as string, type: 'success', position: 'bottom-right' @@ -263,7 +263,7 @@ export default defineComponent({ }) .catch((error) => { ElNotification({ - title: 'Error', + title: this.$t('generic.error'), message: error, type: 'error', position: 'bottom-right' diff --git a/src-vue/src/i18n/lang/en.ts b/src-vue/src/i18n/lang/en.ts new file mode 100644 index 00000000..14d744f9 --- /dev/null +++ b/src-vue/src/i18n/lang/en.ts @@ -0,0 +1,156 @@ +export default { + menu: { + play: 'Play', + changelog: 'Changelog', + mods: 'Mods', + settings: 'Settings', + dev: 'Dev' + }, + + generic: { + yes: 'Yes', + no: 'No', + error: 'Error', + cancel: 'Cancel', + informationShort: 'Info' + }, + + play: { + button: { + northstar_is_running: 'Game is running', + select_game_dir: 'Select Titanfall2 game folder', + install: 'Install', + installing: 'Installing...', + update: 'Update', + updating: 'Updating...', + ready_to_play: 'Launch game' + }, + + unknown_version: "Unknown version", + see_patch_notes: "see patch notes", + players: "players", + servers: "servers", + unable_to_load_playercount: "Unable to load playercount", + northstar_running: "Northstar is running:", + origin_running: "Origin is running:" + }, + + mods: { + local: { + no_mods: "No mods were found.", + delete_confirm: "Are you sure to delete this mod?", + delete: "Delete", + part_of_ts_mod: "This Northstar mod is part of a Thunderstore mod", + success_deleting: "Success deleting {modName}" + }, + + online: { + no_match: "No matching mod has been found.", + try_another_search: "Try another search!" + }, + + menu: { + local: 'Local', + online: 'Online', + filter: 'Filter', + search: 'Search', + sort_mods: 'Sort mods', + select_categories: 'Select categories', + + sort: { + name_asc: 'By name (A to Z)', + name_desc: 'By name (Z to A)', + date_asc: 'By date (from oldest)', + date_desc: 'By date (from newest)', + most_downloaded: "Most downloaded", + top_rated: "Top rated" + } + }, + + card: { + button: { + being_installed: "Installing...", + being_updated: "Updating...", + installed: "Installed", + install: "Install", + outdated: "Update" + }, + + by: "by", + more_info: "More info", + remove: "Remove mod", + remove_dialog_title: "Warning", + remove_dialog_text: "Delete Thunderstore mod?", + remove_success: "Removed {modName}", + install_success: "Installed {modName}" + } + }, + + settings: { + manage_install: "Manage installation", + choose_folder: "Choose installation folder", + nb_ts_mods_per_page: "Number of Thunderstore mods per page", + nb_ts_mods_per_page_desc1: "This has an impact on display performances when browsing Thunderstore mods.", + nb_ts_mods_per_page_desc2: "Set this value to 0 to disable pagination.", + nb_ts_mods_reset: "Reset to default", + language: 'Language', + language_select: "Select your favorite language", + about: "About:", + flightcore_version: "FlightCore version:", + testing: "Testing:", + enable_test_channels: "Enable testing release channels", + dev_mode_enabled_title: "Watch out!", + dev_mod_enabled_text: "Developer mode enabled.", + + repair: { + title: "Repair", + open_window: "Open repair window", + + window: { + title: "FlightCore repair window", + warning: "This window contains various functionality to repair common issues with Northstar and FlightCore.", + disable_all_but_core: "Disable all but core mods", + force_reinstall_ns: "Force reinstall Northstar", + force_delete_temp_dl: "Force delete temp download folder", + delete_persistent_store: "Delete FlightCore persistent store" + } + } + }, + + notification: { + game_folder: { + new: { + title: "New game folder", + text: "Game folder was successfully updated." + }, + + wrong: { + title: "Wrong folder", + text: "Selected folder is not a valid Titanfall2 install." + }, + + not_found: { + title: "Titanfall2 not found!", + text: "Please manually select install location" + } + }, + + flightcore_outdated: { + title: "FlightCore outdated!", + text: "Please update FlightCore.\nRunning outdated version {oldVersion}.\nNewest is {newVersion}!" + } + }, + + channels: { + release: { + switch: { + text: 'Switched release channel to "{canal}".' + } + }, + + names: { + Northstar: 'Northstar', + NorthstarReleaseCandidate: 'Northstar release candidate' + } + } +}; diff --git a/src-vue/src/i18n/lang/fr.ts b/src-vue/src/i18n/lang/fr.ts new file mode 100644 index 00000000..f1eba0da --- /dev/null +++ b/src-vue/src/i18n/lang/fr.ts @@ -0,0 +1,156 @@ +export default { + menu: { + play: 'Jouer', + changelog: 'Notes', + mods: 'Mods', + settings: 'Paramètres', + dev: 'Dev' + }, + + generic: { + yes: 'Oui', + no: 'Non', + error: 'Erreur', + cancel: 'Annuler', + informationShort: 'Info' + }, + + play: { + button: { + northstar_is_running: "En cours d'utilisation", + select_game_dir: 'Sélectionner le dossier du jeu', + install: 'Installer', + installing: 'Installation...', + update: 'Mettre à jour', + updating: 'Mise à jour...', + ready_to_play: 'Jouer' + }, + + unknown_version: "Version inconnue", + see_patch_notes: "voir les notes de version", + players: "joueurs", + servers: "serveurs", + unable_to_load_playercount: "Impossible de charger les statistiques", + northstar_running: "Northstar est en cours d'exécution :", + origin_running: "Origin est en cours d'exécution :" + }, + + mods: { + local: { + no_mods: "Aucun mod trouvé.", + delete_confirm: "Êtes-vous certain de vouloir supprimer ce mod ?", + delete: "Supprimer", + part_of_ts_mod: "Ce mod Northstar fait partie d'un mod Thunderstore", + success_deleting: "Succès de la suppression de {modName}" + }, + + online: { + no_match: "Aucun mod correspondant n'a été trouvé.", + try_another_search: "Essayez une autre recherche !" + }, + + menu: { + local: 'Local', + online: 'En ligne', + filter: 'Filtrer', + search: 'Chercher', + sort_mods: 'Trier les mods', + select_categories: 'Choisir les catégories', + + sort: { + name_asc: 'Par nom (de A à Z)', + name_desc: 'Par nom (de Z à A)', + date_asc: 'Par date (du plus vieux)', + date_desc: 'Par date (du plus récent)', + most_downloaded: "Plus téléchargés", + top_rated: "Mieux notés" + } + }, + + card: { + button: { + being_installed: "Installation...", + being_updated: "Mise à jour...", + installed: "Installé", + install: "Installer", + outdated: "Mettre à jour" + }, + + by: "par", + more_info: "Plus d'informations", + remove: "Supprimer le mod", + remove_dialog_title: "Attention !", + remove_dialog_text: "Voulez-vous vraiment supprimer ce mod Thunderstore ?", + remove_success: "{modName} supprimé", + install_success: "{modName} installé" + } + }, + + settings: { + manage_install: "Gérer l'installation", + choose_folder: "Choisir le dossier d'installation du jeu", + nb_ts_mods_per_page: "Nombre de mods Thunderstore par page", + nb_ts_mods_per_page_desc1: "Ce paramètre a un impact sur les performances d'affichage des mods Thunderstore.", + nb_ts_mods_per_page_desc2: "Réglez-le sur 0 pour désactiver la pagination.", + nb_ts_mods_reset: "Valeur par défaut", + language: 'Langue', + language_select: "Sélectionnez votre langue", + about: "À propos:", + flightcore_version: "Version de FlightCore :", + testing: "Tests :", + enable_test_channels: "Activer le test de versions de pré-production", + dev_mode_enabled_title: "Attention !", + dev_mod_enabled_text: "Mode développeur activé.", + + repair: { + title: "Dépannage", + open_window: "Ouvrir la fenêtre de dépannage", + + window: { + title: "Fenêtre de dépannage FlightCore", + warning: "Cette fenêtre contient plusieurs fonctionnalité de résolution de problèmes courants avec Northstar et FlightCore.", + disable_all_but_core: "Désactiver tous les mods (sauf ceux de Northstar)", + force_reinstall_ns: "Forcer la réinstallation de Northstar", + force_delete_temp_dl: "Supprimer le dossier de téléchargement temporaire", + delete_persistent_store: "Supprimer l'espace de stockage local de FlightCore" + } + } + }, + + notification: { + game_folder: { + new: { + title: "Nouveau dossier", + text: "Le dossier du jeu a bien été mis à jour." + }, + + wrong: { + title: "Mauvais dossier", + text: "Le dossier sélectionné ne contient pas d'installation de Titanfall2." + }, + + not_found: { + title: "Titanfall2 non trouvé", + text: "Veuillez sélectionner manuellement le dossier du jeu." + } + }, + + flightcore_outdated: { + title: "Mise à jour disponible !", + text: "Veuillez mettre à jour FlightCore.\nVersion actuelle : {oldVersion}.\nNouvelle version : {newVersion}." + } + }, + + channels: { + release: { + switch: { + text: 'Le canal de téléchargement a été réglé sur "{canal}".' + } + }, + + names: { + Northstar: 'Northstar', + NorthstarReleaseCandidate: 'Version de pré-release' + } + } +}; diff --git a/src-vue/src/main.ts b/src-vue/src/main.ts index 57d41865..7ee700da 100644 --- a/src-vue/src/main.ts +++ b/src-vue/src/main.ts @@ -1,4 +1,5 @@ import { createApp } from 'vue' +import { createI18n } from "vue-i18n"; import App from './App.vue' import ElementPlus from "element-plus"; import * as ElementPlusIconsVue from '@element-plus/icons-vue' @@ -10,10 +11,21 @@ import SettingsView from "./views/SettingsView.vue"; import DeveloperView from "./views/DeveloperView.vue"; import RepairView from "./views/RepairView.vue"; import {createRouter, createWebHashHistory} from "vue-router"; +import en from "./i18n/lang/en"; +import fr from "./i18n/lang/fr"; const app = createApp(App); +// internationalization +export const i18n = createI18n({ + locale: 'en', + fallbackLocale: 'en', + messages: { + en, fr + } +}); +app.use(i18n); // styles import 'element-plus/theme-chalk/index.css'; diff --git a/src-vue/src/plugins/store.ts b/src-vue/src/plugins/store.ts index caa46bee..08f9b85f 100644 --- a/src-vue/src/plugins/store.ts +++ b/src-vue/src/plugins/store.ts @@ -16,8 +16,8 @@ import { ReleaseInfo } from "../../../src-tauri/bindings/ReleaseInfo"; import { ThunderstoreMod } from "../../../src-tauri/bindings/ThunderstoreMod"; import { NorthstarMod } from "../../../src-tauri/bindings/NorthstarMod"; import { searchModule } from './modules/search'; +import { i18n } from '../main'; import { pullRequestModule } from './modules/pull_requests'; -import { PullsApiResponseElement } from "../../../src-tauri/bindings/PullsApiResponseElement"; const persistentStore = new Store('flight-core-settings.json'); @@ -123,8 +123,8 @@ export const store = createStore({ if (is_valid_titanfall2_install) { state.game_path = selected; ElNotification({ - title: 'New game folder', - message: "Game folder was successfully updated.", + title: i18n.global.tc('notification.game_folder.new.title'), + message: i18n.global.tc('notification.game_folder.new.text'), type: 'success', position: 'bottom-right' }); @@ -151,8 +151,8 @@ export const store = createStore({ else { // Not valid Titanfall2 install ElNotification({ - title: 'Wrong folder', - message: "Selected folder is not a valid Titanfall2 install.", + title: i18n.global.tc('notification.game_folder.wrong.title'), + message: i18n.global.tc('notification.game_folder.wrong.text'), type: 'error', position: 'bottom-right' }); @@ -224,7 +224,7 @@ export const store = createStore({ .catch((error) => { console.error(error); ElNotification({ - title: 'Error', + title: i18n.global.tc('generic.error'), message: error, type: 'error', position: 'bottom-right' @@ -293,7 +293,7 @@ export const store = createStore({ .catch((error) => { console.error(error); ElNotification({ - title: 'Error', + title: i18n.global.tc('generic.error'), message: error, type: 'error', position: 'bottom-right' @@ -315,8 +315,8 @@ export const store = createStore({ // Display notification to highlight change ElNotification({ - title: `${state.northstar_release_canal}`, - message: `Switched release channel to: "${state.northstar_release_canal}"`, + title: i18n.global.tc(`channels.names.${state.northstar_release_canal}`), + message: i18n.global.tc('channels.release.switch.text', {canal: state.northstar_release_canal}), type: 'success', position: 'bottom-right' }); @@ -394,8 +394,8 @@ async function _initializeApp(state: any) { // Gamepath not found or other error console.error(err); notification_handle = ElNotification({ - title: 'Titanfall2 not found!', - message: "Please manually select install location", + title: i18n.global.tc('notification.game_folder.not_found.title'), + message: i18n.global.tc('notification.game_folder.not_found.text'), type: 'error', position: 'bottom-right', duration: 0 // Duration `0` means the notification will not auto-vanish @@ -437,8 +437,8 @@ async function _checkForFlightCoreUpdates(state: FlightCoreStore) { if (flightcore_is_outdated) { let newest_flightcore_version = await invoke("get_newest_flightcore_version") as FlightCoreVersion; ElNotification({ - title: 'FlightCore outdated!', - message: `Please update FlightCore.\nRunning outdated version ${state.flightcore_version}.\nNewest is ${newest_flightcore_version.tag_name}!`, + title: i18n.global.tc('notification.flightcore_outdated.title'), + message: i18n.global.tc('notification.flightcore_outdated.text', {oldVersion: state.flightcore_version, newVersion: newest_flightcore_version.tag_name}), type: 'warning', position: 'bottom-right', duration: 0 // Duration `0` means the notification will not auto-vanish diff --git a/src-vue/src/utils/SortOptions.d.ts b/src-vue/src/utils/SortOptions.d.ts index 6bdd0a4a..b6f180d2 100644 --- a/src-vue/src/utils/SortOptions.d.ts +++ b/src-vue/src/utils/SortOptions.d.ts @@ -1,8 +1,8 @@ export enum SortOptions { - NAME_ASC = 'By name (A to Z)', - NAME_DESC = 'By name (Z to A)', - DATE_ASC = 'By date (from oldest)', - DATE_DESC = 'By date (from newest)', - MOST_DOWNLOADED = "Most downloaded", - TOP_RATED = "Top rated" + NAME_ASC = 'name_asc', + NAME_DESC = 'name_desc', + DATE_ASC = 'date_asc', + DATE_DESC = 'date_desc', + MOST_DOWNLOADED = 'most_downloaded', + TOP_RATED = 'top_rated' } diff --git a/src-vue/src/views/PlayView.vue b/src-vue/src/views/PlayView.vue index beca6724..57d02904 100644 --- a/src-vue/src/views/PlayView.vue +++ b/src-vue/src/views/PlayView.vue @@ -34,27 +34,27 @@ export default defineComponent({
Northstar
- {{ northstarVersion === '' ? 'Unknown version' : `v${northstarVersion}` }} + {{ northstarVersion === '' ? $t('play.unknown_version') : `v${northstarVersion}` }}
- {{ playerCount }} players, - {{ serverCount }} servers + {{ playerCount }} {{ $t('play.players') }}, + {{ serverCount }} {{ $t('play.servers') }}
- Unable to load playercount + {{ $t('play.unable_to_load_playercount') }}
-
Northstar is running:
+
{{ $t('play.northstar_running') }}
{{ northstarIsRunning }}
-
Origin is running:
+
{{ $t('play.origin_running') }}
{{ $store.state.origin_is_running }}
diff --git a/src-vue/src/views/RepairView.vue b/src-vue/src/views/RepairView.vue index e7cd479a..12224524 100644 --- a/src-vue/src/views/RepairView.vue +++ b/src-vue/src/views/RepairView.vue @@ -1,30 +1,30 @@