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">
import { onMounted, ref, watch } from 'vue';
import { t } from '../i18n';
import { useScoreboardStore } from '../stores/scoreboard';
const scoreboardStore = useScoreboardStore();
@@ -103,20 +104,20 @@ onMounted(() => {
<template>
<div class="bracket-panel">
<div class="text-h4">
Bracket
{{ t('bracketTitle') }}
</div>
<div class="column q-gutter-md">
<QSelect
v-model="stage"
label="Stage"
:label="t('bracketStage')"
:options="stageOptions"
dense
class="bracket-panel__field"
/>
<QSelect
v-model="bracketSide"
label="Bracket side"
:label="t('bracketSide')"
:options="bracketSideOptions"
dense
emit-value
@@ -126,7 +127,7 @@ onMounted(() => {
<div class="bracket-panel-custom">
<QInput
v-model="customText"
label="Custom progress"
:label="t('bracketCustomProgress')"
dense
class="bracket-panel-custom-input bracket-panel__field"
/>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { t } from '../i18n';
import { useCommentaryStore } from '../stores/commentary';
const commentaryStore = useCommentaryStore();
@@ -8,7 +9,7 @@ const commentaryStore = useCommentaryStore();
<div class="commentary-panel">
<div class="row items-center q-mb-md">
<div class="text-h4">
Commentary
{{ t('commentaryTitle') }}
</div>
</div>
@@ -16,7 +17,7 @@ const commentaryStore = useCommentaryStore();
<div class="commentary-panel__commentator">
<QInput
v-model="commentaryStore.leftCommentator"
label="Commentator #1"
:label="t('commentaryCommentator1')"
dense
class="commentary-panel__field"
>
@@ -27,7 +28,7 @@ const commentaryStore = useCommentaryStore();
<QInput
v-model="commentaryStore.leftCommentatorTwitter"
label="@Twitter / Text"
:label="t('commentaryTwitterText')"
dense
class="commentary-panel__field"
/>
@@ -45,7 +46,7 @@ const commentaryStore = useCommentaryStore();
<div class="commentary-panel__commentator">
<QInput
v-model="commentaryStore.rightCommentator"
label="Commentator #2"
:label="t('commentaryCommentator2')"
dense
class="commentary-panel__field"
>
@@ -56,7 +57,7 @@ const commentaryStore = useCommentaryStore();
<QInput
v-model="commentaryStore.rightCommentatorTwitter"
label="@Twitter / Text"
:label="t('commentaryTwitterText')"
dense
class="commentary-panel__field"
/>
@@ -1,10 +1,11 @@
<script setup lang="ts">
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 type { Schemas } from '../../../types';
import { usePlayersStore } from '../stores/players';
import { useScoreboardStore } from '../stores/scoreboard';
import { locale, t } from '../i18n';
const playersStore = usePlayersStore();
const scoreboardStore = useScoreboardStore();
@@ -21,8 +22,9 @@ const rightFocused = ref(false);
const leftCountryInput = ref('');
const rightCountryInput = ref('');
const leftCountryOptions = ref(countryOptions);
const rightCountryOptions = ref(countryOptions);
const countryOptions = computed(() => getCountryOptions(locale.value));
const leftCountryOptions = ref(countryOptions.value);
const rightCountryOptions = ref(countryOptions.value);
const leftCharacterInput = ref('');
const rightCharacterInput = ref('');
@@ -81,10 +83,10 @@ const filterCountries = (
update(() => {
const needle = value.toLowerCase().trim();
if (!needle) {
target.value = countryOptions;
target.value = countryOptions.value;
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 base = [{ label: '(Sin asignar)', value: '' }];
const base = [{ label: t('scoreboardUnassigned'), value: '' }];
const entries = Object.entries(playersStore.players) as [string, Schemas.Players[string]][];
const options = entries.map(([id, player]) => ({
value: id,
@@ -608,7 +610,7 @@ watch(
watch(
() => scoreboardStore.scoreboard.leftCountryOverride,
(value) => {
leftCountryInput.value = getCountryLabel(value);
leftCountryInput.value = getCountryLabel(value, locale.value);
},
{ immediate: true },
);
@@ -616,7 +618,7 @@ watch(
watch(
() => scoreboardStore.scoreboard.rightCountryOverride,
(value) => {
rightCountryInput.value = getCountryLabel(value);
rightCountryInput.value = getCountryLabel(value, locale.value);
},
{ immediate: true },
);
@@ -695,6 +697,11 @@ watch(
{ immediate: true },
);
watch(countryOptions, (value) => {
leftCountryOptions.value = value;
rightCountryOptions.value = value;
});
watch(
() => scoreboardStore.scoreboard.leftCharacter,
(value) => {
@@ -745,14 +752,14 @@ watch(
<img
v-if="leftPanelImage"
:src="leftPanelImage"
:alt="`${leftDisplayName || 'Left'} preview`"
:alt="`${leftDisplayName || t('scoreboardLeft')} ${t('scoreboardPreview')}`"
class="scoreboard-preview__image"
>
<div
v-else
class="scoreboard-preview__empty"
>
Left image
{{ t('scoreboardLeftImage') }}
</div>
</div>
<QSelect
@@ -763,7 +770,7 @@ watch(
option-label="label"
emit-value
map-options
label="Character"
:label="t('scoreboardLabelCharacter')"
dense
use-input
input-debounce="0"
@@ -780,7 +787,7 @@ watch(
v-model="scoreboardStore.scoreboard.leftPlayerId"
v-model:input-value="leftInput"
:options="leftPlayerOptions"
label="Player"
:label="t('scoreboardLabelPlayer')"
dense
emit-value
map-options
@@ -809,7 +816,7 @@ watch(
</QSelect>
<QInput
v-model="scoreboardStore.scoreboard.leftTeamOverride"
label="Team"
:label="t('scoreboardLabelTeam')"
dense
class="scoreboard-preview__field"
>
@@ -838,7 +845,7 @@ watch(
hide-selected
fill-input
clearable
label="Country"
:label="t('scoreboardLabelCountry')"
dense
class="scoreboard-preview__field"
@filter="onLeftCountryFilter"
@@ -864,7 +871,7 @@ watch(
v-model="scoreboardStore.scoreboard.game"
v-model:input-value="gameInput"
:options="fightingGameOptions"
label="Game"
:label="t('scoreboardLabelGame')"
dense
emit-value
map-options
@@ -945,7 +952,7 @@ watch(
v-model="scoreboardStore.scoreboard.rightPlayerId"
v-model:input-value="rightInput"
:options="rightPlayerOptions"
label="Player"
:label="t('scoreboardLabelPlayer')"
dense
emit-value
map-options
@@ -974,7 +981,7 @@ watch(
</QSelect>
<QInput
v-model="scoreboardStore.scoreboard.rightTeamOverride"
label="Team"
:label="t('scoreboardLabelTeam')"
dense
class="scoreboard-preview__field"
>
@@ -1003,7 +1010,7 @@ watch(
hide-selected
fill-input
clearable
label="Country"
:label="t('scoreboardLabelCountry')"
dense
class="scoreboard-preview__field"
@filter="onRightCountryFilter"
@@ -1026,14 +1033,14 @@ watch(
<img
v-if="rightPanelImage"
:src="rightPanelImage"
:alt="`${rightDisplayName || 'Right'} preview`"
:alt="`${rightDisplayName || t('scoreboardRight')} ${t('scoreboardPreview')}`"
class="scoreboard-preview__image"
>
<div
v-else
class="scoreboard-preview__empty"
>
Right image
{{ t('scoreboardRightImage') }}
</div>
</div>
<QSelect
@@ -1044,7 +1051,7 @@ watch(
option-label="label"
emit-value
map-options
label="Character"
:label="t('scoreboardLabelCharacter')"
dense
use-input
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">
const menuItems = [
{ label: 'Dashboard', to: '/', icon: 'dashboard' },
{ label: 'Players', to: '/players', icon: 'groups' },
{ label: 'Graphics', to: '/graphics', icon: 'collections' },
{ label: 'Settings', to: '/settings', icon: 'settings' },
{ label: 'About', to: '/about', icon: 'info' },
];
import { computed } from 'vue';
import { t } from './i18n';
const menuItems = computed(() => [
{ label: t('menuDashboard'), to: '/', icon: 'dashboard' },
{ 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;
</script>
+17 -18
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { useHead } from '@unhead/vue';
import { computed, onMounted, ref } from 'vue';
import { t } from '../i18n';
defineOptions({ name: 'AboutView' });
useHead({ title: 'About' });
useHead(() => ({ title: t('aboutTitle') }));
type ReleaseResponse = {
html_url: string;
@@ -101,13 +102,13 @@ async function checkForUpdates() {
);
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;
} catch (error) {
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 {
checkingUpdates.value = false;
}
@@ -121,7 +122,7 @@ onMounted(() => {
<template>
<QPage class="q-pa-lg">
<div class="text-h4 q-mb-md">
About
{{ t('aboutTitle') }}
</div>
<div class="row q-col-gutter-lg">
@@ -145,7 +146,7 @@ onMounted(() => {
{{ appName }}
</div>
<div class="text-caption text-grey-7">
Version {{ currentVersion }}
{{ t('aboutVersion') }} {{ currentVersion }}
</div>
</div>
</QCardSection>
@@ -154,7 +155,7 @@ onMounted(() => {
<QCardSection>
<p class="q-mb-sm">
Dashboard for producing fighting game overlays using NodeCG, Vue, and Quasar.
{{ t('aboutDescription') }}
</p>
<div class="column q-gutter-sm">
<QBtn
@@ -162,7 +163,7 @@ onMounted(() => {
target="_blank"
rel="noopener noreferrer"
icon="open_in_new"
label="Framework NodeCG"
:label="t('aboutFrameworkNodeCG')"
color="primary"
flat
no-caps
@@ -175,7 +176,7 @@ onMounted(() => {
<QCardSection>
<div class="text-subtitle2 q-mb-sm">
Collaborators and acknowledgments
{{ t('aboutCollaboratorsTitle') }}
</div>
<QList dense>
<QItem
@@ -205,10 +206,10 @@ onMounted(() => {
>
<QCardSection>
<div class="text-h6">
Update system (GitHub Releases)
{{ t('aboutUpdateSystemTitle') }}
</div>
<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>
</QCardSection>
@@ -216,7 +217,7 @@ onMounted(() => {
<QCardSection class="q-gutter-md">
<QBtn
label="Check for updates"
:label="t('aboutCheckUpdates')"
color="primary"
icon="sync"
:loading="checkingUpdates"
@@ -236,19 +237,19 @@ onMounted(() => {
/>
</template>
<div class="text-subtitle2">
Latest release: {{ releaseLabel }}
{{ t('aboutLatestRelease') }}: {{ releaseLabel }}
</div>
<div class="text-caption text-grey-7">
Published: {{ new Date(latestRelease.published_at).toLocaleString() }}
{{ t('aboutPublished') }}: {{ new Date(latestRelease.published_at).toLocaleString() }}
</div>
<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>
<template #action>
<QBtn
flat
color="primary"
label="View release"
:label="t('aboutViewRelease')"
:href="releaseUrl"
target="_blank"
rel="noopener noreferrer"
@@ -269,9 +270,7 @@ onMounted(() => {
rounded
class="bg-blue-1 text-blue-10"
>
Note for Electron: this panel only implements <strong>detection and notification</strong>. For real automatic
desktop updates, you need to integrate `autoUpdater` into Electron's main process
and publish signed artifacts per platform.
{{ t('aboutElectronNote') }}
</QBanner>
</QCardSection>
</QCard>
+9 -8
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { useHead } from '@unhead/vue';
import { computed } from 'vue';
import { t } from '../i18n';
defineOptions({ name: 'GraphicsView' });
@@ -14,7 +15,7 @@ type GraphicConfig = {
type PreviewKind = 'scoreboard' | 'commentary' | null;
useHead({ title: 'Graphics' });
useHead(() => ({ title: t('graphicsTitle') }));
const graphics = computed<GraphicConfig[]>(
() => bundlePackage.nodecg?.graphics ?? [],
@@ -84,17 +85,17 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
<template>
<QPage class="q-pa-lg">
<div class="text-h4 q-mb-md">
Graphics
{{ t('graphicsTitle') }}
</div>
<div class="text-body1 q-mb-lg">
Bundle graphics controls and status.
{{ t('graphicsDescription') }}
</div>
<div
v-if="graphics.length === 0"
class="text-body2 text-grey-5"
>
There are no graphics configured in this bundle.
{{ t('graphicsNoConfigured') }}
</div>
<div class="row q-col-gutter-md">
@@ -131,13 +132,13 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
<QBtn
color="primary"
icon="content_copy"
label="Copy URL"
:label="t('graphicsCopyUrl')"
@click="copyUrl(graphic)"
/>
<QBtn
color="secondary"
icon="open_with"
label="Drag into OBS"
:label="t('graphicsDragObs')"
draggable="true"
@dragstart="onDragStart($event, graphic)"
/>
@@ -148,14 +149,14 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
class="graphics-preview q-mt-md"
>
<div class="graphics-preview__label text-caption text-grey-5 q-mb-sm">
Overlay preview (real)
{{ t('graphicsOverlayPreview') }}
</div>
<div class="graphics-preview__frame-wrap">
<iframe
class="graphics-preview__frame"
:src="buildGraphicUrl(graphic)"
title="Graphic preview"
:title="t('graphicsPreviewTitle')"
loading="lazy"
/>
</div>
+42 -36
View File
@@ -5,11 +5,12 @@ defineOptions({ name: 'PlayersView' });
import type { QTableColumn } from 'quasar';
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 { locale, t } from '../i18n';
import { usePlayersStore } from '../stores/players';
useHead({ title: 'Players' });
useHead(() => ({ title: t('menuPlayers') }));
type PlayersMap = Schemas.Players;
type Player = PlayersMap[string];
@@ -74,39 +75,44 @@ const emptyPlayer: Player = {
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: 'team', label: 'Team', field: 'team', sortable: true, align: 'left' },
{ name: 'team', label: t('playersLabelTeam'), field: 'team', sortable: true, align: 'left' },
{
name: 'country',
label: 'Country',
field: (row) => getCountryLabel(row.country),
label: t('playersLabelCountry'),
field: (row) => getCountryLabel(row.country, locale.value),
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 filterCountries = (value: string, update: (callback: () => void) => void) => {
update(() => {
const needle = value.toLowerCase().trim();
if (!needle) {
filteredCountryOptions.value = countryOptions;
filteredCountryOptions.value = countryOptions.value;
return;
}
filteredCountryOptions.value = countryOptions.filter((country) =>
filteredCountryOptions.value = countryOptions.value.filter((country) =>
country.label.toLowerCase().includes(needle),
);
});
};
watch(countryOptions, (value) => {
filteredCountryOptions.value = value;
});
watch(
() => form.country,
(value) => {
countryInput.value = getCountryLabel(value);
countryInput.value = getCountryLabel(value, locale.value);
},
{ immediate: true },
);
@@ -517,7 +523,7 @@ const selectedChallongeTournamentOption = computed(() =>
const canImportSelectedChallongeTournament = computed(() => Boolean(selectedChallongeTournamentOption.value));
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) => {
update(() => {
@@ -805,13 +811,13 @@ onBeforeUnmount(() => {
<QPage class="q-pa-lg players-page">
<div class="row items-center q-mb-md">
<div class="text-h4">
Players
{{ t('menuPlayers') }}
</div>
<QSpace />
<QBtn
color="primary"
icon="add"
label="New player"
:label="t('playersNewPlayer')"
class="q-ml-sm"
@click="openCreateDialog"
/>
@@ -823,7 +829,7 @@ onBeforeUnmount(() => {
<QInput
v-model="filter"
dense
placeholder="Search..."
:placeholder="t('playersSearchPlaceholder')"
class="players-search players-underlined-field"
clearable
>
@@ -835,14 +841,14 @@ onBeforeUnmount(() => {
color="secondary"
outline
icon="file_upload"
label="Import"
:label="t('playersImport')"
@click="triggerImport"
/>
<QBtn
color="secondary"
outline
icon="file_download"
label="Export"
:label="t('playersExport')"
@click="exportPlayers"
/>
<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" />
</svg>
<span>StartGG</span>
<span>start.gg</span>
</div>
<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 class="row q-col-gutter-sm items-center">
<div class="col-auto">
@@ -911,7 +917,7 @@ onBeforeUnmount(() => {
v-if="!hasStartGGTokenConfigured"
color="primary"
icon="login"
label="Connect with start.gg"
:label="t('playersConnectStartgg')"
:loading="oauthLoading"
@click="connectWithStartGGOAuth"
/>
@@ -920,7 +926,7 @@ onBeforeUnmount(() => {
outline
color="positive"
icon="check_circle"
label="Connected"
:label="t('playersConnected')"
class="startgg-connected-btn"
@click="openManualTokenDialog"
/>
@@ -930,7 +936,7 @@ onBeforeUnmount(() => {
outline
color="white"
icon="vpn_key"
label="Use personal API"
:label="t('playersUsePersonalApi')"
@click="openManualTokenDialog"
/>
</div>
@@ -967,7 +973,7 @@ onBeforeUnmount(() => {
input-debounce="0"
clearable
dense
label="Tournament"
:label="t('playersTournament')"
class="players-underlined-field"
@filter="filterTournaments"
>
@@ -992,10 +998,10 @@ onBeforeUnmount(() => {
unelevated
round
icon="person_add"
aria-label="Import players"
:aria-label="t('playersImportPlayers')"
@click="openSelectedTournamentImportDialog"
>
<QTooltip>Import players</QTooltip>
<QTooltip>{{ t('playersImportPlayers') }}</QTooltip>
</QBtn>
</div>
</div>
@@ -1015,7 +1021,7 @@ onBeforeUnmount(() => {
<span>Challonge</span>
</div>
<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 class="row q-col-gutter-sm items-center">
<div class="col-auto">
@@ -1023,7 +1029,7 @@ onBeforeUnmount(() => {
v-if="!hasChallongeTokenConfigured"
color="primary"
icon="login"
label="Connect with Challonge"
:label="t('playersConnectChallonge')"
:loading="challongeOauthLoading"
@click="connectWithChallongeOAuth"
/>
@@ -1041,7 +1047,7 @@ onBeforeUnmount(() => {
outline
color="white"
icon="vpn_key"
label="Use personal API"
:label="t('playersUsePersonalApi')"
@click="openChallongeManualTokenDialog"
/>
</div>
@@ -1078,7 +1084,7 @@ onBeforeUnmount(() => {
input-debounce="0"
clearable
dense
label="Tournament"
:label="t('playersTournament')"
class="players-underlined-field"
@filter="filterChallongeTournaments"
>
@@ -1103,10 +1109,10 @@ onBeforeUnmount(() => {
unelevated
round
icon="person_add"
aria-label="Import players"
:aria-label="t('playersImportPlayers')"
@click="openSelectedChallongeTournamentImportDialog"
>
<QTooltip>Import players</QTooltip>
<QTooltip>{{ t('playersImportPlayers') }}</QTooltip>
</QBtn>
</div>
</div>
@@ -1187,7 +1193,7 @@ onBeforeUnmount(() => {
v-model="selectedStartGGPlayerIds"
type="checkbox"
: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,
}))"
/>
@@ -1303,7 +1309,7 @@ onBeforeUnmount(() => {
<QCard class="players-dialog">
<QCardSection>
<div class="text-h6">
{{ editingId ? 'Edit player' : 'New player' }}
{{ editingId ? 'Edit player' : t('playersNewPlayer') }}
</div>
</QCardSection>
<QSeparator />
@@ -1341,7 +1347,7 @@ onBeforeUnmount(() => {
hide-selected
fill-input
clearable
label="Country"
:label="t('playersLabelCountry')"
dense
class="players-underlined-field"
@filter="filterCountries"
@@ -1350,7 +1356,7 @@ onBeforeUnmount(() => {
<div class="col-12">
<QInput
v-model="form.team"
label="Team"
:label="t('playersLabelTeam')"
dense
class="players-underlined-field"
/>
+40 -4
View File
@@ -1,18 +1,54 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useHead } from '@unhead/vue';
import type { Locale } from '../i18n';
import { locale, setLocale, t } from '../i18n';
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>
<template>
<QPage class="q-pa-lg">
<div class="text-h4 q-mb-md">
Settings
{{ t('settingsTitle') }}
</div>
<div class="text-body1">
Dashboard and bundle configuration.
<div class="text-body1 q-mb-lg">
{{ t('settingsDescription') }}
</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>
</template>
+32 -10
View File
@@ -7,18 +7,38 @@ export interface CountryOption {
const baseCountries = getData();
export const countryOptions: CountryOption[] = baseCountries
.map((country: CountryRecord) => ({
value: country.code,
label: country.name,
}))
.sort((a: CountryOption, b: CountryOption) => a.label.localeCompare(b.label));
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) => ({
value: country.code,
label: displayNames?.of(country.code) ?? country.name,
}))
.sort((a: CountryOption, b: CountryOption) => a.label.localeCompare(b.label));
optionsCache.set(locale, options);
return options;
};
const countryByCode = new Map(
countryOptions.map((country) => [country.value.toUpperCase(), country.label]),
baseCountries.map((country) => [country.code.toUpperCase(), country.name]),
);
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) => {
@@ -37,7 +57,7 @@ export const resolveCountryCode = (value?: string) => {
return byName ?? '';
};
export const getCountryLabel = (value?: string) => {
export const getCountryLabel = (value?: string, locale = 'en') => {
if (!value) {
return '';
}
@@ -45,5 +65,7 @@ export const getCountryLabel = (value?: string) => {
if (!resolved) {
return value;
}
return countryByCode.get(resolved) ?? value;
const match = getCountryOptions(locale).find((country) => country.value === resolved);
return match?.label ?? countryByCode.get(resolved) ?? value;
};