Ampliar i18n (EN/ES) en labels del dashboard y mensajes de update (#112)

* Expand EN/ES translations for panels and update status texts

* Translate remaining dashboard labels and localize country names

* Translate Players top action buttons and search placeholder
This commit is contained in:
Pandipipas
2026-02-19 23:43:02 +01:00
committed by GitHub
parent fe110e7c66
commit 46772d542f
10 changed files with 430 additions and 113 deletions
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, watch } from 'vue'; import { onMounted, ref, watch } from 'vue';
import { t } from '../i18n';
import { useScoreboardStore } from '../stores/scoreboard'; import { useScoreboardStore } from '../stores/scoreboard';
const scoreboardStore = useScoreboardStore(); const scoreboardStore = useScoreboardStore();
@@ -103,20 +104,20 @@ onMounted(() => {
<template> <template>
<div class="bracket-panel"> <div class="bracket-panel">
<div class="text-h4"> <div class="text-h4">
Bracket {{ t('bracketTitle') }}
</div> </div>
<div class="column q-gutter-md"> <div class="column q-gutter-md">
<QSelect <QSelect
v-model="stage" v-model="stage"
label="Stage" :label="t('bracketStage')"
:options="stageOptions" :options="stageOptions"
dense dense
class="bracket-panel__field" class="bracket-panel__field"
/> />
<QSelect <QSelect
v-model="bracketSide" v-model="bracketSide"
label="Bracket side" :label="t('bracketSide')"
:options="bracketSideOptions" :options="bracketSideOptions"
dense dense
emit-value emit-value
@@ -126,7 +127,7 @@ onMounted(() => {
<div class="bracket-panel-custom"> <div class="bracket-panel-custom">
<QInput <QInput
v-model="customText" v-model="customText"
label="Custom progress" :label="t('bracketCustomProgress')"
dense dense
class="bracket-panel-custom-input bracket-panel__field" class="bracket-panel-custom-input bracket-panel__field"
/> />
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '../i18n';
import { useCommentaryStore } from '../stores/commentary'; import { useCommentaryStore } from '../stores/commentary';
const commentaryStore = useCommentaryStore(); const commentaryStore = useCommentaryStore();
@@ -8,7 +9,7 @@ const commentaryStore = useCommentaryStore();
<div class="commentary-panel"> <div class="commentary-panel">
<div class="row items-center q-mb-md"> <div class="row items-center q-mb-md">
<div class="text-h4"> <div class="text-h4">
Commentary {{ t('commentaryTitle') }}
</div> </div>
</div> </div>
@@ -16,7 +17,7 @@ const commentaryStore = useCommentaryStore();
<div class="commentary-panel__commentator"> <div class="commentary-panel__commentator">
<QInput <QInput
v-model="commentaryStore.leftCommentator" v-model="commentaryStore.leftCommentator"
label="Commentator #1" :label="t('commentaryCommentator1')"
dense dense
class="commentary-panel__field" class="commentary-panel__field"
> >
@@ -27,7 +28,7 @@ const commentaryStore = useCommentaryStore();
<QInput <QInput
v-model="commentaryStore.leftCommentatorTwitter" v-model="commentaryStore.leftCommentatorTwitter"
label="@Twitter / Text" :label="t('commentaryTwitterText')"
dense dense
class="commentary-panel__field" class="commentary-panel__field"
/> />
@@ -45,7 +46,7 @@ const commentaryStore = useCommentaryStore();
<div class="commentary-panel__commentator"> <div class="commentary-panel__commentator">
<QInput <QInput
v-model="commentaryStore.rightCommentator" v-model="commentaryStore.rightCommentator"
label="Commentator #2" :label="t('commentaryCommentator2')"
dense dense
class="commentary-panel__field" class="commentary-panel__field"
> >
@@ -56,7 +57,7 @@ const commentaryStore = useCommentaryStore();
<QInput <QInput
v-model="commentaryStore.rightCommentatorTwitter" v-model="commentaryStore.rightCommentatorTwitter"
label="@Twitter / Text" :label="t('commentaryTwitterText')"
dense dense
class="commentary-panel__field" class="commentary-panel__field"
/> />
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, watchEffect, type Ref } from 'vue'; import { computed, ref, watch, watchEffect, type Ref } from 'vue';
import { countryOptions, getCountryLabel } from '../../../shared/countries'; import { getCountryLabel, getCountryOptions } from '../../../shared/countries';
import { getCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters'; import { getCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters';
import type { Schemas } from '../../../types'; import type { Schemas } from '../../../types';
import { usePlayersStore } from '../stores/players'; import { usePlayersStore } from '../stores/players';
import { useScoreboardStore } from '../stores/scoreboard'; import { useScoreboardStore } from '../stores/scoreboard';
import { locale, t } from '../i18n';
const playersStore = usePlayersStore(); const playersStore = usePlayersStore();
const scoreboardStore = useScoreboardStore(); const scoreboardStore = useScoreboardStore();
@@ -21,8 +22,9 @@ const rightFocused = ref(false);
const leftCountryInput = ref(''); const leftCountryInput = ref('');
const rightCountryInput = ref(''); const rightCountryInput = ref('');
const leftCountryOptions = ref(countryOptions); const countryOptions = computed(() => getCountryOptions(locale.value));
const rightCountryOptions = ref(countryOptions); const leftCountryOptions = ref(countryOptions.value);
const rightCountryOptions = ref(countryOptions.value);
const leftCharacterInput = ref(''); const leftCharacterInput = ref('');
const rightCharacterInput = ref(''); const rightCharacterInput = ref('');
@@ -81,10 +83,10 @@ const filterCountries = (
update(() => { update(() => {
const needle = value.toLowerCase().trim(); const needle = value.toLowerCase().trim();
if (!needle) { if (!needle) {
target.value = countryOptions; target.value = countryOptions.value;
return; return;
} }
target.value = countryOptions.filter((country) => country.label.toLowerCase().includes(needle)); target.value = countryOptions.value.filter((country) => country.label.toLowerCase().includes(needle));
}); });
}; };
@@ -133,7 +135,7 @@ const onGameFilter = (value: string, update: (callback: () => void) => void) =>
}; };
const playerOptions = computed(() => { const playerOptions = computed(() => {
const base = [{ label: '(Sin asignar)', value: '' }]; const base = [{ label: t('scoreboardUnassigned'), value: '' }];
const entries = Object.entries(playersStore.players) as [string, Schemas.Players[string]][]; const entries = Object.entries(playersStore.players) as [string, Schemas.Players[string]][];
const options = entries.map(([id, player]) => ({ const options = entries.map(([id, player]) => ({
value: id, value: id,
@@ -608,7 +610,7 @@ watch(
watch( watch(
() => scoreboardStore.scoreboard.leftCountryOverride, () => scoreboardStore.scoreboard.leftCountryOverride,
(value) => { (value) => {
leftCountryInput.value = getCountryLabel(value); leftCountryInput.value = getCountryLabel(value, locale.value);
}, },
{ immediate: true }, { immediate: true },
); );
@@ -616,7 +618,7 @@ watch(
watch( watch(
() => scoreboardStore.scoreboard.rightCountryOverride, () => scoreboardStore.scoreboard.rightCountryOverride,
(value) => { (value) => {
rightCountryInput.value = getCountryLabel(value); rightCountryInput.value = getCountryLabel(value, locale.value);
}, },
{ immediate: true }, { immediate: true },
); );
@@ -695,6 +697,11 @@ watch(
{ immediate: true }, { immediate: true },
); );
watch(countryOptions, (value) => {
leftCountryOptions.value = value;
rightCountryOptions.value = value;
});
watch( watch(
() => scoreboardStore.scoreboard.leftCharacter, () => scoreboardStore.scoreboard.leftCharacter,
(value) => { (value) => {
@@ -745,14 +752,14 @@ watch(
<img <img
v-if="leftPanelImage" v-if="leftPanelImage"
:src="leftPanelImage" :src="leftPanelImage"
:alt="`${leftDisplayName || 'Left'} preview`" :alt="`${leftDisplayName || t('scoreboardLeft')} ${t('scoreboardPreview')}`"
class="scoreboard-preview__image" class="scoreboard-preview__image"
> >
<div <div
v-else v-else
class="scoreboard-preview__empty" class="scoreboard-preview__empty"
> >
Left image {{ t('scoreboardLeftImage') }}
</div> </div>
</div> </div>
<QSelect <QSelect
@@ -763,7 +770,7 @@ watch(
option-label="label" option-label="label"
emit-value emit-value
map-options map-options
label="Character" :label="t('scoreboardLabelCharacter')"
dense dense
use-input use-input
input-debounce="0" input-debounce="0"
@@ -780,7 +787,7 @@ watch(
v-model="scoreboardStore.scoreboard.leftPlayerId" v-model="scoreboardStore.scoreboard.leftPlayerId"
v-model:input-value="leftInput" v-model:input-value="leftInput"
:options="leftPlayerOptions" :options="leftPlayerOptions"
label="Player" :label="t('scoreboardLabelPlayer')"
dense dense
emit-value emit-value
map-options map-options
@@ -809,7 +816,7 @@ watch(
</QSelect> </QSelect>
<QInput <QInput
v-model="scoreboardStore.scoreboard.leftTeamOverride" v-model="scoreboardStore.scoreboard.leftTeamOverride"
label="Team" :label="t('scoreboardLabelTeam')"
dense dense
class="scoreboard-preview__field" class="scoreboard-preview__field"
> >
@@ -838,7 +845,7 @@ watch(
hide-selected hide-selected
fill-input fill-input
clearable clearable
label="Country" :label="t('scoreboardLabelCountry')"
dense dense
class="scoreboard-preview__field" class="scoreboard-preview__field"
@filter="onLeftCountryFilter" @filter="onLeftCountryFilter"
@@ -864,7 +871,7 @@ watch(
v-model="scoreboardStore.scoreboard.game" v-model="scoreboardStore.scoreboard.game"
v-model:input-value="gameInput" v-model:input-value="gameInput"
:options="fightingGameOptions" :options="fightingGameOptions"
label="Game" :label="t('scoreboardLabelGame')"
dense dense
emit-value emit-value
map-options map-options
@@ -945,7 +952,7 @@ watch(
v-model="scoreboardStore.scoreboard.rightPlayerId" v-model="scoreboardStore.scoreboard.rightPlayerId"
v-model:input-value="rightInput" v-model:input-value="rightInput"
:options="rightPlayerOptions" :options="rightPlayerOptions"
label="Player" :label="t('scoreboardLabelPlayer')"
dense dense
emit-value emit-value
map-options map-options
@@ -974,7 +981,7 @@ watch(
</QSelect> </QSelect>
<QInput <QInput
v-model="scoreboardStore.scoreboard.rightTeamOverride" v-model="scoreboardStore.scoreboard.rightTeamOverride"
label="Team" :label="t('scoreboardLabelTeam')"
dense dense
class="scoreboard-preview__field" class="scoreboard-preview__field"
> >
@@ -1003,7 +1010,7 @@ watch(
hide-selected hide-selected
fill-input fill-input
clearable clearable
label="Country" :label="t('scoreboardLabelCountry')"
dense dense
class="scoreboard-preview__field" class="scoreboard-preview__field"
@filter="onRightCountryFilter" @filter="onRightCountryFilter"
@@ -1026,14 +1033,14 @@ watch(
<img <img
v-if="rightPanelImage" v-if="rightPanelImage"
:src="rightPanelImage" :src="rightPanelImage"
:alt="`${rightDisplayName || 'Right'} preview`" :alt="`${rightDisplayName || t('scoreboardRight')} ${t('scoreboardPreview')}`"
class="scoreboard-preview__image" class="scoreboard-preview__image"
> >
<div <div
v-else v-else
class="scoreboard-preview__empty" class="scoreboard-preview__empty"
> >
Right image {{ t('scoreboardRightImage') }}
</div> </div>
</div> </div>
<QSelect <QSelect
@@ -1044,7 +1051,7 @@ watch(
option-label="label" option-label="label"
emit-value emit-value
map-options map-options
label="Character" :label="t('scoreboardLabelCharacter')"
dense dense
use-input use-input
input-debounce="0" input-debounce="0"
+241
View File
@@ -0,0 +1,241 @@
import { ref } from 'vue';
export type Locale = 'en' | 'es';
type Translations = {
menuDashboard: string;
menuPlayers: string;
menuGraphics: string;
menuSettings: string;
menuAbout: string;
settingsTitle: string;
settingsDescription: string;
settingsLanguageLabel: string;
settingsLanguageHint: string;
languageEnglish: string;
languageSpanish: string;
scoreboardUnassigned: string;
scoreboardLeft: string;
scoreboardRight: string;
scoreboardPreview: string;
scoreboardLeftImage: string;
scoreboardRightImage: string;
scoreboardLabelCharacter: string;
scoreboardLabelPlayer: string;
scoreboardLabelTeam: string;
scoreboardLabelCountry: string;
scoreboardLabelGame: string;
aboutTitle: string;
aboutVersion: string;
aboutDescription: string;
aboutFrameworkNodeCG: string;
aboutCollaboratorsTitle: string;
aboutUpdateSystemTitle: string;
aboutUpdateSystemDescription: string;
aboutCheckUpdates: string;
aboutLatestRelease: string;
aboutPublished: string;
aboutUpdateAvailable: string;
aboutUpToDate: string;
aboutViewRelease: string;
aboutElectronNote: string;
aboutUnknownReleaseError: string;
aboutGitHubStatusError: string;
graphicsTitle: string;
graphicsDescription: string;
graphicsNoConfigured: string;
graphicsCopyUrl: string;
graphicsDragObs: string;
graphicsOverlayPreview: string;
graphicsPreviewTitle: string;
commentaryTitle: string;
commentaryCommentator1: string;
commentaryCommentator2: string;
commentaryTwitterText: string;
bracketTitle: string;
bracketStage: string;
bracketSide: string;
bracketCustomProgress: string;
playersLabelTeam: string;
playersLabelCountry: string;
playersLabelActions: string;
playersStartggHelp: string;
playersConnectStartgg: string;
playersConnected: string;
playersUsePersonalApi: string;
playersTournament: string;
playersImportPlayers: string;
playersChallongeHelp: string;
playersConnectChallonge: string;
playersNewPlayer: string;
playersSearchPlaceholder: string;
playersImport: string;
playersExport: string;
};
const STORAGE_KEY = 'scoreko-dev.language';
const messages: Record<Locale, Translations> = {
en: {
menuDashboard: 'Dashboard',
menuPlayers: 'Players',
menuGraphics: 'Graphics',
menuSettings: 'Settings',
menuAbout: 'About',
settingsTitle: 'Settings',
settingsDescription: 'Dashboard and bundle configuration.',
settingsLanguageLabel: 'Language',
settingsLanguageHint: 'Choose the dashboard language.',
languageEnglish: 'English',
languageSpanish: 'Spanish',
scoreboardUnassigned: '(Unassigned)',
scoreboardLeft: 'Left',
scoreboardRight: 'Right',
scoreboardPreview: 'preview',
scoreboardLeftImage: 'Left image',
scoreboardRightImage: 'Right image',
scoreboardLabelCharacter: 'Character',
scoreboardLabelPlayer: 'Player',
scoreboardLabelTeam: 'Team',
scoreboardLabelCountry: 'Country',
scoreboardLabelGame: 'Game',
aboutTitle: 'About',
aboutVersion: 'Version',
aboutDescription: 'Dashboard for producing fighting game overlays using NodeCG, Vue, and Quasar.',
aboutFrameworkNodeCG: 'Framework NodeCG',
aboutCollaboratorsTitle: 'Collaborators and acknowledgments',
aboutUpdateSystemTitle: 'Update system (GitHub Releases)',
aboutUpdateSystemDescription: 'This check fetches the latest release from the repository and compares it with the current version.',
aboutCheckUpdates: 'Check for updates',
aboutLatestRelease: 'Latest release',
aboutPublished: 'Published',
aboutUpdateAvailable: 'A newer version is available.',
aboutUpToDate: 'Your version is up to date with the latest release.',
aboutViewRelease: 'View release',
aboutElectronNote: 'Note for Electron: this panel only implements detection and notification. For real automatic desktop updates, you need to integrate autoUpdater into Electron\'s main process and publish signed artifacts per platform.',
aboutUnknownReleaseError: 'Unknown error while checking releases.',
aboutGitHubStatusError: 'GitHub responded with status',
graphicsTitle: 'Graphics',
graphicsDescription: 'Bundle graphics controls and status.',
graphicsNoConfigured: 'There are no graphics configured in this bundle.',
graphicsCopyUrl: 'Copy URL',
graphicsDragObs: 'Drag into OBS',
graphicsOverlayPreview: 'Overlay preview (real)',
graphicsPreviewTitle: 'Graphic preview',
commentaryTitle: 'Commentary',
commentaryCommentator1: 'Commentator #1',
commentaryCommentator2: 'Commentator #2',
commentaryTwitterText: '@Twitter / Text',
bracketTitle: 'Bracket',
bracketStage: 'Stage',
bracketSide: 'Bracket side',
bracketCustomProgress: 'Custom progress',
playersLabelTeam: 'Team',
playersLabelCountry: 'Country',
playersLabelActions: 'Actions',
playersStartggHelp: 'Connect via OAuth (recommended) or paste your personal token to load tournaments you created or administrate. If you see "Client authentication failed", verify your config uses the Client ID/Secret from a start.gg OAuth App.',
playersConnectStartgg: 'Connect with start.gg',
playersConnected: 'Connected',
playersUsePersonalApi: 'Use personal API',
playersTournament: 'Tournament',
playersImportPlayers: 'Import players',
playersChallongeHelp: 'Connect with OAuth or paste your personal token to load your Challonge tournaments and import participants.',
playersConnectChallonge: 'Connect with Challonge',
playersNewPlayer: 'New player',
playersSearchPlaceholder: 'Search...',
playersImport: 'Import',
playersExport: 'Export',
},
es: {
menuDashboard: 'Panel',
menuPlayers: 'Jugadores',
menuGraphics: 'Gráficos',
menuSettings: 'Configuración',
menuAbout: 'Acerca de',
settingsTitle: 'Configuración',
settingsDescription: 'Configuración del dashboard y del bundle.',
settingsLanguageLabel: 'Idioma',
settingsLanguageHint: 'Selecciona el idioma del dashboard.',
languageEnglish: 'Inglés',
languageSpanish: 'Castellano',
scoreboardUnassigned: '(Sin asignar)',
scoreboardLeft: 'Izquierda',
scoreboardRight: 'Derecha',
scoreboardPreview: 'vista previa',
scoreboardLeftImage: 'Imagen izquierda',
scoreboardRightImage: 'Imagen derecha',
scoreboardLabelCharacter: 'Personaje',
scoreboardLabelPlayer: 'Jugador',
scoreboardLabelTeam: 'Equipo',
scoreboardLabelCountry: 'País',
scoreboardLabelGame: 'Juego',
aboutTitle: 'Acerca de',
aboutVersion: 'Versión',
aboutDescription: 'Dashboard para producir overlays de juegos de lucha usando NodeCG, Vue y Quasar.',
aboutFrameworkNodeCG: 'Framework NodeCG',
aboutCollaboratorsTitle: 'Colaboradores y agradecimientos',
aboutUpdateSystemTitle: 'Sistema de actualizaciones (GitHub Releases)',
aboutUpdateSystemDescription: 'Esta comprobación obtiene la última release del repositorio y la compara con la versión actual.',
aboutCheckUpdates: 'Buscar actualizaciones',
aboutLatestRelease: 'Última release',
aboutPublished: 'Publicado',
aboutUpdateAvailable: 'Hay una versión más nueva disponible.',
aboutUpToDate: 'Tu versión está actualizada con la última release.',
aboutViewRelease: 'Ver release',
aboutElectronNote: 'Nota para Electron: este panel solo implementa detección y notificación. Para actualizaciones automáticas reales de escritorio, debes integrar autoUpdater en el proceso principal de Electron y publicar artefactos firmados por plataforma.',
aboutUnknownReleaseError: 'Error desconocido al consultar releases.',
aboutGitHubStatusError: 'GitHub respondió con estado',
graphicsTitle: 'Gráficos',
graphicsDescription: 'Controles y estado de los gráficos del bundle.',
graphicsNoConfigured: 'No hay gráficos configurados en este bundle.',
graphicsCopyUrl: 'Copiar URL',
graphicsDragObs: 'Arrastrar a OBS',
graphicsOverlayPreview: 'Vista previa del overlay (real)',
graphicsPreviewTitle: 'Vista previa del gráfico',
commentaryTitle: 'Comentario',
commentaryCommentator1: 'Comentarista #1',
commentaryCommentator2: 'Comentarista #2',
commentaryTwitterText: '@Twitter / Texto',
bracketTitle: 'Bracket',
bracketStage: 'Etapa',
bracketSide: 'Lado del bracket',
bracketCustomProgress: 'Progreso personalizado',
playersLabelTeam: 'Equipo',
playersLabelCountry: 'País',
playersLabelActions: 'Acciones',
playersStartggHelp: 'Conéctate por OAuth (recomendado) o pega tu token personal para cargar torneos que creaste o administras. Si ves "Client authentication failed", revisa que tu configuración use el Client ID/Secret de una app OAuth de start.gg.',
playersConnectStartgg: 'Conectar con start.gg',
playersConnected: 'Conectado',
playersUsePersonalApi: 'Usar API personal',
playersTournament: 'Torneo',
playersImportPlayers: 'Importar jugadores',
playersChallongeHelp: 'Conéctate con OAuth o pega tu token personal para cargar tus torneos de Challonge e importar participantes.',
playersConnectChallonge: 'Conectar con Challonge',
playersNewPlayer: 'Nuevo jugador',
playersSearchPlaceholder: 'Buscar...',
playersImport: 'Importar',
playersExport: 'Exportar',
},
};
const normalizeLocale = (value: unknown): Locale => (value === 'es' ? 'es' : 'en');
const getStoredLocale = (): Locale => {
if (typeof window === 'undefined') {
return 'en';
}
return normalizeLocale(localStorage.getItem(STORAGE_KEY));
};
export const locale = ref<Locale>(getStoredLocale());
export const setLocale = (value: Locale) => {
locale.value = normalizeLocale(value);
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, locale.value);
}
};
export const t = (key: keyof Translations): string => messages[locale.value][key];
+10 -7
View File
@@ -1,11 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
const menuItems = [ import { computed } from 'vue';
{ label: 'Dashboard', to: '/', icon: 'dashboard' }, import { t } from './i18n';
{ label: 'Players', to: '/players', icon: 'groups' },
{ label: 'Graphics', to: '/graphics', icon: 'collections' }, const menuItems = computed(() => [
{ label: 'Settings', to: '/settings', icon: 'settings' }, { label: t('menuDashboard'), to: '/', icon: 'dashboard' },
{ label: 'About', to: '/about', icon: 'info' }, { label: t('menuPlayers'), to: '/players', icon: 'groups' },
]; { label: t('menuGraphics'), to: '/graphics', icon: 'collections' },
{ label: t('menuSettings'), to: '/settings', icon: 'settings' },
{ label: t('menuAbout'), to: '/about', icon: 'info' },
]);
const logoUrl = new URL('./image.png', import.meta.url).href; const logoUrl = new URL('./image.png', import.meta.url).href;
</script> </script>
+17 -18
View File
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { t } from '../i18n';
defineOptions({ name: 'AboutView' }); defineOptions({ name: 'AboutView' });
useHead({ title: 'About' }); useHead(() => ({ title: t('aboutTitle') }));
type ReleaseResponse = { type ReleaseResponse = {
html_url: string; html_url: string;
@@ -101,13 +102,13 @@ async function checkForUpdates() {
); );
if (!response.ok) { if (!response.ok) {
throw new Error(`GitHub responded with status ${response.status}.`); throw new Error(`${t('aboutGitHubStatusError')} ${response.status}.`);
} }
latestRelease.value = await response.json() as ReleaseResponse; latestRelease.value = await response.json() as ReleaseResponse;
} catch (error) { } catch (error) {
latestRelease.value = null; latestRelease.value = null;
updateError.value = error instanceof Error ? error.message : 'Unknown error while checking releases.'; updateError.value = error instanceof Error ? error.message : t('aboutUnknownReleaseError');
} finally { } finally {
checkingUpdates.value = false; checkingUpdates.value = false;
} }
@@ -121,7 +122,7 @@ onMounted(() => {
<template> <template>
<QPage class="q-pa-lg"> <QPage class="q-pa-lg">
<div class="text-h4 q-mb-md"> <div class="text-h4 q-mb-md">
About {{ t('aboutTitle') }}
</div> </div>
<div class="row q-col-gutter-lg"> <div class="row q-col-gutter-lg">
@@ -145,7 +146,7 @@ onMounted(() => {
{{ appName }} {{ appName }}
</div> </div>
<div class="text-caption text-grey-7"> <div class="text-caption text-grey-7">
Version {{ currentVersion }} {{ t('aboutVersion') }} {{ currentVersion }}
</div> </div>
</div> </div>
</QCardSection> </QCardSection>
@@ -154,7 +155,7 @@ onMounted(() => {
<QCardSection> <QCardSection>
<p class="q-mb-sm"> <p class="q-mb-sm">
Dashboard for producing fighting game overlays using NodeCG, Vue, and Quasar. {{ t('aboutDescription') }}
</p> </p>
<div class="column q-gutter-sm"> <div class="column q-gutter-sm">
<QBtn <QBtn
@@ -162,7 +163,7 @@ onMounted(() => {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
icon="open_in_new" icon="open_in_new"
label="Framework NodeCG" :label="t('aboutFrameworkNodeCG')"
color="primary" color="primary"
flat flat
no-caps no-caps
@@ -175,7 +176,7 @@ onMounted(() => {
<QCardSection> <QCardSection>
<div class="text-subtitle2 q-mb-sm"> <div class="text-subtitle2 q-mb-sm">
Collaborators and acknowledgments {{ t('aboutCollaboratorsTitle') }}
</div> </div>
<QList dense> <QList dense>
<QItem <QItem
@@ -205,10 +206,10 @@ onMounted(() => {
> >
<QCardSection> <QCardSection>
<div class="text-h6"> <div class="text-h6">
Update system (GitHub Releases) {{ t('aboutUpdateSystemTitle') }}
</div> </div>
<div class="text-body2 text-grey-7 q-mt-xs"> <div class="text-body2 text-grey-7 q-mt-xs">
This check fetches the latest release from the repository and compares it with the current version. {{ t('aboutUpdateSystemDescription') }}
</div> </div>
</QCardSection> </QCardSection>
@@ -216,7 +217,7 @@ onMounted(() => {
<QCardSection class="q-gutter-md"> <QCardSection class="q-gutter-md">
<QBtn <QBtn
label="Check for updates" :label="t('aboutCheckUpdates')"
color="primary" color="primary"
icon="sync" icon="sync"
:loading="checkingUpdates" :loading="checkingUpdates"
@@ -236,19 +237,19 @@ onMounted(() => {
/> />
</template> </template>
<div class="text-subtitle2"> <div class="text-subtitle2">
Latest release: {{ releaseLabel }} {{ t('aboutLatestRelease') }}: {{ releaseLabel }}
</div> </div>
<div class="text-caption text-grey-7"> <div class="text-caption text-grey-7">
Published: {{ new Date(latestRelease.published_at).toLocaleString() }} {{ t('aboutPublished') }}: {{ new Date(latestRelease.published_at).toLocaleString() }}
</div> </div>
<div class="q-mt-sm"> <div class="q-mt-sm">
{{ hasUpdate ? 'A newer version is available.' : 'Your version is up to date with the latest release.' }} {{ hasUpdate ? t('aboutUpdateAvailable') : t('aboutUpToDate') }}
</div> </div>
<template #action> <template #action>
<QBtn <QBtn
flat flat
color="primary" color="primary"
label="View release" :label="t('aboutViewRelease')"
:href="releaseUrl" :href="releaseUrl"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@@ -269,9 +270,7 @@ onMounted(() => {
rounded rounded
class="bg-blue-1 text-blue-10" class="bg-blue-1 text-blue-10"
> >
Note for Electron: this panel only implements <strong>detection and notification</strong>. For real automatic {{ t('aboutElectronNote') }}
desktop updates, you need to integrate `autoUpdater` into Electron's main process
and publish signed artifacts per platform.
</QBanner> </QBanner>
</QCardSection> </QCardSection>
</QCard> </QCard>
+9 -8
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { computed } from 'vue'; import { computed } from 'vue';
import { t } from '../i18n';
defineOptions({ name: 'GraphicsView' }); defineOptions({ name: 'GraphicsView' });
@@ -14,7 +15,7 @@ type GraphicConfig = {
type PreviewKind = 'scoreboard' | 'commentary' | null; type PreviewKind = 'scoreboard' | 'commentary' | null;
useHead({ title: 'Graphics' }); useHead(() => ({ title: t('graphicsTitle') }));
const graphics = computed<GraphicConfig[]>( const graphics = computed<GraphicConfig[]>(
() => bundlePackage.nodecg?.graphics ?? [], () => bundlePackage.nodecg?.graphics ?? [],
@@ -84,17 +85,17 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
<template> <template>
<QPage class="q-pa-lg"> <QPage class="q-pa-lg">
<div class="text-h4 q-mb-md"> <div class="text-h4 q-mb-md">
Graphics {{ t('graphicsTitle') }}
</div> </div>
<div class="text-body1 q-mb-lg"> <div class="text-body1 q-mb-lg">
Bundle graphics controls and status. {{ t('graphicsDescription') }}
</div> </div>
<div <div
v-if="graphics.length === 0" v-if="graphics.length === 0"
class="text-body2 text-grey-5" class="text-body2 text-grey-5"
> >
There are no graphics configured in this bundle. {{ t('graphicsNoConfigured') }}
</div> </div>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
@@ -131,13 +132,13 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
<QBtn <QBtn
color="primary" color="primary"
icon="content_copy" icon="content_copy"
label="Copy URL" :label="t('graphicsCopyUrl')"
@click="copyUrl(graphic)" @click="copyUrl(graphic)"
/> />
<QBtn <QBtn
color="secondary" color="secondary"
icon="open_with" icon="open_with"
label="Drag into OBS" :label="t('graphicsDragObs')"
draggable="true" draggable="true"
@dragstart="onDragStart($event, graphic)" @dragstart="onDragStart($event, graphic)"
/> />
@@ -148,14 +149,14 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
class="graphics-preview q-mt-md" class="graphics-preview q-mt-md"
> >
<div class="graphics-preview__label text-caption text-grey-5 q-mb-sm"> <div class="graphics-preview__label text-caption text-grey-5 q-mb-sm">
Overlay preview (real) {{ t('graphicsOverlayPreview') }}
</div> </div>
<div class="graphics-preview__frame-wrap"> <div class="graphics-preview__frame-wrap">
<iframe <iframe
class="graphics-preview__frame" class="graphics-preview__frame"
:src="buildGraphicUrl(graphic)" :src="buildGraphicUrl(graphic)"
title="Graphic preview" :title="t('graphicsPreviewTitle')"
loading="lazy" loading="lazy"
/> />
</div> </div>
+42 -36
View File
@@ -5,11 +5,12 @@ defineOptions({ name: 'PlayersView' });
import type { QTableColumn } from 'quasar'; import type { QTableColumn } from 'quasar';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { countryOptions, getCountryLabel } from '../../../shared/countries'; import { getCountryLabel, getCountryOptions } from '../../../shared/countries';
import type { Schemas } from '../../../types'; import type { Schemas } from '../../../types';
import { locale, t } from '../i18n';
import { usePlayersStore } from '../stores/players'; import { usePlayersStore } from '../stores/players';
useHead({ title: 'Players' }); useHead(() => ({ title: t('menuPlayers') }));
type PlayersMap = Schemas.Players; type PlayersMap = Schemas.Players;
type Player = PlayersMap[string]; type Player = PlayersMap[string];
@@ -74,39 +75,44 @@ const emptyPlayer: Player = {
const form = reactive<Player>({ ...emptyPlayer }); const form = reactive<Player>({ ...emptyPlayer });
const columns: QTableColumn<PlayerRow>[] = [ const columns = computed<QTableColumn<PlayerRow>[]>(() => [
{ name: 'gamertag', label: 'Gamertag', field: 'gamertag', sortable: true, align: 'left' }, { name: 'gamertag', label: 'Gamertag', field: 'gamertag', sortable: true, align: 'left' },
{ name: 'team', label: 'Team', field: 'team', sortable: true, align: 'left' }, { name: 'team', label: t('playersLabelTeam'), field: 'team', sortable: true, align: 'left' },
{ {
name: 'country', name: 'country',
label: 'Country', label: t('playersLabelCountry'),
field: (row) => getCountryLabel(row.country), field: (row) => getCountryLabel(row.country, locale.value),
sortable: true, sortable: true,
align: 'left', align: 'left',
}, },
{ name: 'twitter', label: 'Twitter', field: 'twitter', sortable: true, align: 'left' }, { name: 'twitter', label: 'Twitter', field: 'twitter', sortable: true, align: 'left' },
{ name: 'actions', label: 'Actions', field: (row) => row.id, sortable: false, align: 'right' }, { name: 'actions', label: t('playersLabelActions'), field: (row) => row.id, sortable: false, align: 'right' },
]; ]);
const filteredCountryOptions = ref(countryOptions); const countryOptions = computed(() => getCountryOptions(locale.value));
const filteredCountryOptions = ref(countryOptions.value);
const countryInput = ref(''); const countryInput = ref('');
const filterCountries = (value: string, update: (callback: () => void) => void) => { const filterCountries = (value: string, update: (callback: () => void) => void) => {
update(() => { update(() => {
const needle = value.toLowerCase().trim(); const needle = value.toLowerCase().trim();
if (!needle) { if (!needle) {
filteredCountryOptions.value = countryOptions; filteredCountryOptions.value = countryOptions.value;
return; return;
} }
filteredCountryOptions.value = countryOptions.filter((country) => filteredCountryOptions.value = countryOptions.value.filter((country) =>
country.label.toLowerCase().includes(needle), country.label.toLowerCase().includes(needle),
); );
}); });
}; };
watch(countryOptions, (value) => {
filteredCountryOptions.value = value;
});
watch( watch(
() => form.country, () => form.country,
(value) => { (value) => {
countryInput.value = getCountryLabel(value); countryInput.value = getCountryLabel(value, locale.value);
}, },
{ immediate: true }, { immediate: true },
); );
@@ -517,7 +523,7 @@ const selectedChallongeTournamentOption = computed(() =>
const canImportSelectedChallongeTournament = computed(() => Boolean(selectedChallongeTournamentOption.value)); const canImportSelectedChallongeTournament = computed(() => Boolean(selectedChallongeTournamentOption.value));
const hasChallongeTokenConfigured = computed(() => Boolean(challongeToken.value.trim())); const hasChallongeTokenConfigured = computed(() => Boolean(challongeToken.value.trim()));
const challongeConnectionLabel = computed(() => (hasValidatedChallongeToken.value ? 'Connected' : 'Token set')); const challongeConnectionLabel = computed(() => (hasValidatedChallongeToken.value ? t('playersConnected') : 'Token set'));
const filterChallongeTournaments = (value: string, update: (callback: () => void) => void) => { const filterChallongeTournaments = (value: string, update: (callback: () => void) => void) => {
update(() => { update(() => {
@@ -805,13 +811,13 @@ onBeforeUnmount(() => {
<QPage class="q-pa-lg players-page"> <QPage class="q-pa-lg players-page">
<div class="row items-center q-mb-md"> <div class="row items-center q-mb-md">
<div class="text-h4"> <div class="text-h4">
Players {{ t('menuPlayers') }}
</div> </div>
<QSpace /> <QSpace />
<QBtn <QBtn
color="primary" color="primary"
icon="add" icon="add"
label="New player" :label="t('playersNewPlayer')"
class="q-ml-sm" class="q-ml-sm"
@click="openCreateDialog" @click="openCreateDialog"
/> />
@@ -823,7 +829,7 @@ onBeforeUnmount(() => {
<QInput <QInput
v-model="filter" v-model="filter"
dense dense
placeholder="Search..." :placeholder="t('playersSearchPlaceholder')"
class="players-search players-underlined-field" class="players-search players-underlined-field"
clearable clearable
> >
@@ -835,14 +841,14 @@ onBeforeUnmount(() => {
color="secondary" color="secondary"
outline outline
icon="file_upload" icon="file_upload"
label="Import" :label="t('playersImport')"
@click="triggerImport" @click="triggerImport"
/> />
<QBtn <QBtn
color="secondary" color="secondary"
outline outline
icon="file_download" icon="file_download"
label="Export" :label="t('playersExport')"
@click="exportPlayers" @click="exportPlayers"
/> />
<input <input
@@ -900,10 +906,10 @@ onBeforeUnmount(() => {
> >
<path d="M6 0A5.999 5.999 0 00.002 6v5.252a.75.75 0 00.75.748H5.25a.748.748 0 00.75-.747V6.749C6 6.334 6.336 6 6.748 6h16.497a.748.748 0 00.749-.748V.749A.743.743 0 0023.247 0zm12.75 12a.748.748 0 00-.75.75v4.5a.748.748 0 01-.747.748H.753a.754.754 0 00-.75.751v4.5a.75.75 0 00.75.751H18a5.999 5.999 0 005.999-6v-5.25a.75.75 0 00-.75-.75z" /> <path d="M6 0A5.999 5.999 0 00.002 6v5.252a.75.75 0 00.75.748H5.25a.748.748 0 00.75-.747V6.749C6 6.334 6.336 6 6.748 6h16.497a.748.748 0 00.749-.748V.749A.743.743 0 0023.247 0zm12.75 12a.748.748 0 00-.75.75v4.5a.748.748 0 01-.747.748H.753a.754.754 0 00-.75.751v4.5a.75.75 0 00.75.751H18a5.999 5.999 0 005.999-6v-5.25a.75.75 0 00-.75-.75z" />
</svg> </svg>
<span>StartGG</span> <span>start.gg</span>
</div> </div>
<div class="text-caption q-mb-md"> <div class="text-caption q-mb-md">
Connect via OAuth (recommended) or paste your personal token to load tournaments you created or administrate. If you see "Client authentication failed", verify your config uses the Client ID/Secret from a start.gg OAuth App. {{ t('playersStartggHelp') }}
</div> </div>
<div class="row q-col-gutter-sm items-center"> <div class="row q-col-gutter-sm items-center">
<div class="col-auto"> <div class="col-auto">
@@ -911,7 +917,7 @@ onBeforeUnmount(() => {
v-if="!hasStartGGTokenConfigured" v-if="!hasStartGGTokenConfigured"
color="primary" color="primary"
icon="login" icon="login"
label="Connect with start.gg" :label="t('playersConnectStartgg')"
:loading="oauthLoading" :loading="oauthLoading"
@click="connectWithStartGGOAuth" @click="connectWithStartGGOAuth"
/> />
@@ -920,7 +926,7 @@ onBeforeUnmount(() => {
outline outline
color="positive" color="positive"
icon="check_circle" icon="check_circle"
label="Connected" :label="t('playersConnected')"
class="startgg-connected-btn" class="startgg-connected-btn"
@click="openManualTokenDialog" @click="openManualTokenDialog"
/> />
@@ -930,7 +936,7 @@ onBeforeUnmount(() => {
outline outline
color="white" color="white"
icon="vpn_key" icon="vpn_key"
label="Use personal API" :label="t('playersUsePersonalApi')"
@click="openManualTokenDialog" @click="openManualTokenDialog"
/> />
</div> </div>
@@ -967,7 +973,7 @@ onBeforeUnmount(() => {
input-debounce="0" input-debounce="0"
clearable clearable
dense dense
label="Tournament" :label="t('playersTournament')"
class="players-underlined-field" class="players-underlined-field"
@filter="filterTournaments" @filter="filterTournaments"
> >
@@ -992,10 +998,10 @@ onBeforeUnmount(() => {
unelevated unelevated
round round
icon="person_add" icon="person_add"
aria-label="Import players" :aria-label="t('playersImportPlayers')"
@click="openSelectedTournamentImportDialog" @click="openSelectedTournamentImportDialog"
> >
<QTooltip>Import players</QTooltip> <QTooltip>{{ t('playersImportPlayers') }}</QTooltip>
</QBtn> </QBtn>
</div> </div>
</div> </div>
@@ -1015,7 +1021,7 @@ onBeforeUnmount(() => {
<span>Challonge</span> <span>Challonge</span>
</div> </div>
<div class="text-caption q-mb-md"> <div class="text-caption q-mb-md">
Connect with OAuth or paste your personal token to load your Challonge tournaments and import participants. {{ t('playersChallongeHelp') }}
</div> </div>
<div class="row q-col-gutter-sm items-center"> <div class="row q-col-gutter-sm items-center">
<div class="col-auto"> <div class="col-auto">
@@ -1023,7 +1029,7 @@ onBeforeUnmount(() => {
v-if="!hasChallongeTokenConfigured" v-if="!hasChallongeTokenConfigured"
color="primary" color="primary"
icon="login" icon="login"
label="Connect with Challonge" :label="t('playersConnectChallonge')"
:loading="challongeOauthLoading" :loading="challongeOauthLoading"
@click="connectWithChallongeOAuth" @click="connectWithChallongeOAuth"
/> />
@@ -1041,7 +1047,7 @@ onBeforeUnmount(() => {
outline outline
color="white" color="white"
icon="vpn_key" icon="vpn_key"
label="Use personal API" :label="t('playersUsePersonalApi')"
@click="openChallongeManualTokenDialog" @click="openChallongeManualTokenDialog"
/> />
</div> </div>
@@ -1078,7 +1084,7 @@ onBeforeUnmount(() => {
input-debounce="0" input-debounce="0"
clearable clearable
dense dense
label="Tournament" :label="t('playersTournament')"
class="players-underlined-field" class="players-underlined-field"
@filter="filterChallongeTournaments" @filter="filterChallongeTournaments"
> >
@@ -1103,10 +1109,10 @@ onBeforeUnmount(() => {
unelevated unelevated
round round
icon="person_add" icon="person_add"
aria-label="Import players" :aria-label="t('playersImportPlayers')"
@click="openSelectedChallongeTournamentImportDialog" @click="openSelectedChallongeTournamentImportDialog"
> >
<QTooltip>Import players</QTooltip> <QTooltip>{{ t('playersImportPlayers') }}</QTooltip>
</QBtn> </QBtn>
</div> </div>
</div> </div>
@@ -1187,7 +1193,7 @@ onBeforeUnmount(() => {
v-model="selectedStartGGPlayerIds" v-model="selectedStartGGPlayerIds"
type="checkbox" type="checkbox"
:options="startGGPlayers.map((player) => ({ :options="startGGPlayers.map((player) => ({
label: `${player.gamertag}${player.team ? ` (${player.team})` : ''}${player.country ? ` - ${getCountryLabel(player.country)}` : ''}`, label: `${player.gamertag}${player.team ? ` (${player.team})` : ''}${player.country ? ` - ${getCountryLabel(player.country, locale)}` : ''}`,
value: player.id, value: player.id,
}))" }))"
/> />
@@ -1303,7 +1309,7 @@ onBeforeUnmount(() => {
<QCard class="players-dialog"> <QCard class="players-dialog">
<QCardSection> <QCardSection>
<div class="text-h6"> <div class="text-h6">
{{ editingId ? 'Edit player' : 'New player' }} {{ editingId ? 'Edit player' : t('playersNewPlayer') }}
</div> </div>
</QCardSection> </QCardSection>
<QSeparator /> <QSeparator />
@@ -1341,7 +1347,7 @@ onBeforeUnmount(() => {
hide-selected hide-selected
fill-input fill-input
clearable clearable
label="Country" :label="t('playersLabelCountry')"
dense dense
class="players-underlined-field" class="players-underlined-field"
@filter="filterCountries" @filter="filterCountries"
@@ -1350,7 +1356,7 @@ onBeforeUnmount(() => {
<div class="col-12"> <div class="col-12">
<QInput <QInput
v-model="form.team" v-model="form.team"
label="Team" :label="t('playersLabelTeam')"
dense dense
class="players-underlined-field" class="players-underlined-field"
/> />
+40 -4
View File
@@ -1,18 +1,54 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import type { Locale } from '../i18n';
import { locale, setLocale, t } from '../i18n';
defineOptions({ name: 'SettingsView' }); defineOptions({ name: 'SettingsView' });
useHead({ title: 'Settings' }); const languageOptions = computed(() => [
{ label: t('languageSpanish'), value: 'es' as const },
{ label: t('languageEnglish'), value: 'en' as const },
]);
const selectedLanguage = computed<Locale>({
get: () => locale.value,
set: (value) => {
setLocale(value);
},
});
useHead(() => ({ title: t('settingsTitle') }));
</script> </script>
<template> <template>
<QPage class="q-pa-lg"> <QPage class="q-pa-lg">
<div class="text-h4 q-mb-md"> <div class="text-h4 q-mb-md">
Settings {{ t('settingsTitle') }}
</div> </div>
<div class="text-body1"> <div class="text-body1 q-mb-lg">
Dashboard and bundle configuration. {{ t('settingsDescription') }}
</div> </div>
<QCard
flat
bordered
class="q-pa-md"
style="max-width: 360px;"
>
<QCardSection class="q-pa-none">
<QSelect
v-model="selectedLanguage"
outlined
emit-value
map-options
:label="t('settingsLanguageLabel')"
:options="languageOptions"
/>
<div class="text-caption text-grey-5 q-mt-sm">
{{ t('settingsLanguageHint') }}
</div>
</QCardSection>
</QCard>
</QPage> </QPage>
</template> </template>
+28 -6
View File
@@ -7,18 +7,38 @@ export interface CountryOption {
const baseCountries = getData(); const baseCountries = getData();
export const countryOptions: CountryOption[] = baseCountries const optionsCache = new Map<string, CountryOption[]>();
const getDisplayNames = (locale: string) => {
try {
return new Intl.DisplayNames([locale], { type: 'region' });
} catch {
return null;
}
};
export const getCountryOptions = (locale = 'en'): CountryOption[] => {
if (optionsCache.has(locale)) {
return optionsCache.get(locale)!;
}
const displayNames = getDisplayNames(locale);
const options = baseCountries
.map((country: CountryRecord) => ({ .map((country: CountryRecord) => ({
value: country.code, value: country.code,
label: country.name, label: displayNames?.of(country.code) ?? country.name,
})) }))
.sort((a: CountryOption, b: CountryOption) => a.label.localeCompare(b.label)); .sort((a: CountryOption, b: CountryOption) => a.label.localeCompare(b.label));
optionsCache.set(locale, options);
return options;
};
const countryByCode = new Map( const countryByCode = new Map(
countryOptions.map((country) => [country.value.toUpperCase(), country.label]), baseCountries.map((country) => [country.code.toUpperCase(), country.name]),
); );
const countryByName = new Map( const countryByName = new Map(
countryOptions.map((country) => [country.label.toLowerCase(), country.value]), baseCountries.map((country) => [country.name.toLowerCase(), country.code]),
); );
export const resolveCountryCode = (value?: string) => { export const resolveCountryCode = (value?: string) => {
@@ -37,7 +57,7 @@ export const resolveCountryCode = (value?: string) => {
return byName ?? ''; return byName ?? '';
}; };
export const getCountryLabel = (value?: string) => { export const getCountryLabel = (value?: string, locale = 'en') => {
if (!value) { if (!value) {
return ''; return '';
} }
@@ -45,5 +65,7 @@ export const getCountryLabel = (value?: string) => {
if (!resolved) { if (!resolved) {
return value; return value;
} }
return countryByCode.get(resolved) ?? value;
const match = getCountryOptions(locale).find((country) => country.value === resolved);
return match?.label ?? countryByCode.get(resolved) ?? value;
}; };