feat: enhance settings view with integration options for start.gg and Challonge, add manual token dialogs, and improve keyboard shortcut management

This commit is contained in:
2026-05-19 03:21:50 +02:00
parent 67d9d20b56
commit 787de05034
3 changed files with 653 additions and 556 deletions
+90 -43
View File
@@ -24,6 +24,14 @@ type Translations = {
settingsShortcutRightDecrementHint: string; settingsShortcutRightDecrementHint: string;
settingsShortcutReset: string; settingsShortcutReset: string;
settingsShortcutRecordingHint: string; settingsShortcutRecordingHint: string;
settingsShortcutConflictWarning: string;
settingsShortcutStartRecording: string;
settingsShortcutStopRecording: string;
settingsShortcutResetSingle: string;
settingsIntegrationsTitle: string;
settingsIntegrationsDescription: string;
settingsDisconnect: string;
settingsNotConnected: string;
languageEnglish: string; languageEnglish: string;
languageSpanish: string; languageSpanish: string;
scoreboardUnassigned: string; scoreboardUnassigned: string;
@@ -53,6 +61,8 @@ type Translations = {
aboutElectronNote: string; aboutElectronNote: string;
aboutUnknownReleaseError: string; aboutUnknownReleaseError: string;
aboutGitHubStatusError: string; aboutGitHubStatusError: string;
aboutChangelog: string;
aboutTechStackTitle: string;
graphicsTitle: string; graphicsTitle: string;
graphicsDescription: string; graphicsDescription: string;
graphicsNoConfigured: string; graphicsNoConfigured: string;
@@ -61,10 +71,16 @@ type Translations = {
graphicsScoreboard: string; graphicsScoreboard: string;
graphicsCommentary: string; graphicsCommentary: string;
graphicsSkinLabel: string; graphicsSkinLabel: string;
graphicsCopied: string;
graphicsOpenBrowser: string;
commentaryTitle: string; commentaryTitle: string;
commentaryCommentator1: string; commentaryCommentator1: string;
commentaryCommentator2: string; commentaryCommentator2: string;
commentaryTwitterText: string; commentaryTwitterText: string;
commentaryTwitterMaxLength: string;
commentaryTwitterInvalidChars: string;
commentarySwap: string;
commentaryClear: string;
bracketTitle: string; bracketTitle: string;
bracketStage: string; bracketStage: string;
bracketSide: string; bracketSide: string;
@@ -85,18 +101,8 @@ type Translations = {
playersSearchPlaceholder: string; playersSearchPlaceholder: string;
playersImport: string; playersImport: string;
playersExport: string; playersExport: string;
commentaryTwitterMaxLength: string; playersConnectInSettings: string;
commentaryTwitterInvalidChars: string; playersConnectInSettingsSuffix: string;
commentarySwap: string;
commentaryClear: string;
aboutChangelog : string;
aboutTechStackTitle : string;
settingsShortcutConflictWarning : string;
settingsShortcutStartRecording: string;
settingsShortcutStopRecording: string;
settingsShortcutResetSingle: string;
graphicsCopied : string;
graphicsOpenBrowser : string;
}; };
const STORAGE_KEY = 'scoreko-dev.language'; const STORAGE_KEY = 'scoreko-dev.language';
@@ -108,6 +114,8 @@ const messages: Record<Locale, Translations> = {
menuGraphics: 'Graphics', menuGraphics: 'Graphics',
menuSettings: 'Settings', menuSettings: 'Settings',
menuAbout: 'About', menuAbout: 'About',
// ── Settings ────────────────────────────────────────────────────────────
settingsTitle: 'Settings', settingsTitle: 'Settings',
settingsDescription: 'Dashboard and bundle configuration.', settingsDescription: 'Dashboard and bundle configuration.',
settingsLanguageLabel: 'Language', settingsLanguageLabel: 'Language',
@@ -124,8 +132,20 @@ const messages: Record<Locale, Translations> = {
settingsShortcutRightDecrementHint: 'Decreases right player score by one.', settingsShortcutRightDecrementHint: 'Decreases right player score by one.',
settingsShortcutReset: 'Reset shortcuts', settingsShortcutReset: 'Reset shortcuts',
settingsShortcutRecordingHint: 'Press the desired shortcut now (example: Alt+1).', settingsShortcutRecordingHint: 'Press the desired shortcut now (example: Alt+1).',
settingsShortcutConflictWarning: 'This shortcut is already assigned to another action.',
settingsShortcutStartRecording: 'Start recording shortcut',
settingsShortcutStopRecording: 'Stop recording shortcut',
settingsShortcutResetSingle: 'Reset this shortcut',
settingsIntegrationsTitle: 'Integrations',
settingsIntegrationsDescription: 'Connect your tournament platform accounts to import players directly from brackets.',
settingsDisconnect: 'Disconnect',
settingsNotConnected: 'Not connected',
// ── Language ─────────────────────────────────────────────────────────────
languageEnglish: 'English', languageEnglish: 'English',
languageSpanish: 'Spanish', languageSpanish: 'Spanish',
// ── Scoreboard ───────────────────────────────────────────────────────────
scoreboardUnassigned: '(Unassigned)', scoreboardUnassigned: '(Unassigned)',
scoreboardLeft: 'Left', scoreboardLeft: 'Left',
scoreboardRight: 'Right', scoreboardRight: 'Right',
@@ -137,6 +157,8 @@ const messages: Record<Locale, Translations> = {
scoreboardLabelTeam: 'Team', scoreboardLabelTeam: 'Team',
scoreboardLabelCountry: 'Country', scoreboardLabelCountry: 'Country',
scoreboardLabelGame: 'Game', scoreboardLabelGame: 'Game',
// ── About ────────────────────────────────────────────────────────────────
aboutTitle: 'About', aboutTitle: 'About',
aboutVersion: 'Version', aboutVersion: 'Version',
aboutDescription: 'Dashboard for producing fighting game overlays using NodeCG, Vue, and Quasar.', aboutDescription: 'Dashboard for producing fighting game overlays using NodeCG, Vue, and Quasar.',
@@ -153,6 +175,10 @@ const messages: Record<Locale, Translations> = {
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.', 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.', aboutUnknownReleaseError: 'Unknown error while checking releases.',
aboutGitHubStatusError: 'GitHub responded with status', aboutGitHubStatusError: 'GitHub responded with status',
aboutChangelog: 'Changelog',
aboutTechStackTitle: 'Tech stack',
// ── Graphics ─────────────────────────────────────────────────────────────
graphicsTitle: 'Graphics', graphicsTitle: 'Graphics',
graphicsDescription: 'Bundle graphics controls and status.', graphicsDescription: 'Bundle graphics controls and status.',
graphicsNoConfigured: 'There are no graphics configured in this bundle.', graphicsNoConfigured: 'There are no graphics configured in this bundle.',
@@ -161,19 +187,31 @@ const messages: Record<Locale, Translations> = {
graphicsScoreboard: 'Scoreboard', graphicsScoreboard: 'Scoreboard',
graphicsCommentary: 'Commentary', graphicsCommentary: 'Commentary',
graphicsSkinLabel: 'Skin', graphicsSkinLabel: 'Skin',
graphicsCopied: 'URL copied to clipboard',
graphicsOpenBrowser: 'Open in browser',
// ── Commentary ───────────────────────────────────────────────────────────
commentaryTitle: 'Commentary', commentaryTitle: 'Commentary',
commentaryCommentator1: 'Commentator #1', commentaryCommentator1: 'Commentator #1',
commentaryCommentator2: 'Commentator #2', commentaryCommentator2: 'Commentator #2',
commentaryTwitterText: '@Twitter / Text', commentaryTwitterText: '@Twitter / Text',
commentaryTwitterMaxLength: 'Twitter character limit exceeded',
commentaryTwitterInvalidChars: 'Invalid characters in Twitter text',
commentarySwap: 'Swap commentators',
commentaryClear: 'Clear commentary',
// ── Bracket ──────────────────────────────────────────────────────────────
bracketTitle: 'Bracket', bracketTitle: 'Bracket',
bracketStage: 'Stage', bracketStage: 'Stage',
bracketSide: 'Bracket side', bracketSide: 'Bracket side',
bracketCustomProgress: 'Custom progress', bracketCustomProgress: 'Custom progress',
bracketPreview: 'Preview', bracketPreview: 'Preview',
// ── Players ──────────────────────────────────────────────────────────────
playersLabelTeam: 'Team', playersLabelTeam: 'Team',
playersLabelCountry: 'Country', playersLabelCountry: 'Country',
playersLabelActions: 'Actions', 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.', playersStartggHelp: 'Connect via OAuth (recommended) or paste your personal token to load tournaments you created or administrate.',
playersConnectStartgg: 'Connect with start.gg', playersConnectStartgg: 'Connect with start.gg',
playersConnected: 'Connected', playersConnected: 'Connected',
playersUsePersonalApi: 'Use personal API', playersUsePersonalApi: 'Use personal API',
@@ -185,25 +223,18 @@ const messages: Record<Locale, Translations> = {
playersSearchPlaceholder: 'Search...', playersSearchPlaceholder: 'Search...',
playersImport: 'Import', playersImport: 'Import',
playersExport: 'Export', playersExport: 'Export',
commentaryTwitterMaxLength: 'Twitter character limit exceeded', playersConnectInSettings: 'Connect your account in',
commentaryTwitterInvalidChars: 'Invalid characters in Twitter text', playersConnectInSettingsSuffix: 'to import players from tournaments.',
commentarySwap: 'Swap commentators',
commentaryClear: 'Clear commentary',
aboutChangelog: 'Changelog',
aboutTechStackTitle: 'Tech stack',
settingsShortcutConflictWarning: 'This shortcut is already assigned to',
settingsShortcutStartRecording: 'Start recording shortcut',
settingsShortcutStopRecording: 'Stop recording shortcut',
settingsShortcutResetSingle: 'Reset single player score shortcut',
graphicsCopied: 'URL copied to clipboard',
graphicsOpenBrowser: 'Open in browser',
}, },
es: { es: {
menuDashboard: 'Panel', menuDashboard: 'Panel',
menuPlayers: 'Jugadores', menuPlayers: 'Jugadores',
menuGraphics: 'Gráficos', menuGraphics: 'Gráficos',
menuSettings: 'Configuración', menuSettings: 'Configuración',
menuAbout: 'Acerca de', menuAbout: 'Acerca de',
// ── Settings ────────────────────────────────────────────────────────────
settingsTitle: 'Configuración', settingsTitle: 'Configuración',
settingsDescription: 'Configuración del dashboard y del bundle.', settingsDescription: 'Configuración del dashboard y del bundle.',
settingsLanguageLabel: 'Idioma', settingsLanguageLabel: 'Idioma',
@@ -220,8 +251,20 @@ const messages: Record<Locale, Translations> = {
settingsShortcutRightDecrementHint: 'Reduce en uno el score del jugador derecho.', settingsShortcutRightDecrementHint: 'Reduce en uno el score del jugador derecho.',
settingsShortcutReset: 'Restablecer atajos', settingsShortcutReset: 'Restablecer atajos',
settingsShortcutRecordingHint: 'Pulsa ahora el atajo deseado (ejemplo: Alt+1).', settingsShortcutRecordingHint: 'Pulsa ahora el atajo deseado (ejemplo: Alt+1).',
settingsShortcutConflictWarning: 'Este atajo ya está asignado a otra acción.',
settingsShortcutStartRecording: 'Iniciar grabación de atajo',
settingsShortcutStopRecording: 'Detener grabación de atajo',
settingsShortcutResetSingle: 'Restablecer este atajo',
settingsIntegrationsTitle: 'Integraciones',
settingsIntegrationsDescription: 'Conecta tus cuentas de plataformas de torneos para importar jugadores directamente desde los brackets.',
settingsDisconnect: 'Desconectar',
settingsNotConnected: 'No conectado',
// ── Language ─────────────────────────────────────────────────────────────
languageEnglish: 'Inglés', languageEnglish: 'Inglés',
languageSpanish: 'Castellano', languageSpanish: 'Castellano',
// ── Scoreboard ───────────────────────────────────────────────────────────
scoreboardUnassigned: '(Sin asignar)', scoreboardUnassigned: '(Sin asignar)',
scoreboardLeft: 'Izquierda', scoreboardLeft: 'Izquierda',
scoreboardRight: 'Derecha', scoreboardRight: 'Derecha',
@@ -233,6 +276,8 @@ const messages: Record<Locale, Translations> = {
scoreboardLabelTeam: 'Equipo', scoreboardLabelTeam: 'Equipo',
scoreboardLabelCountry: 'País', scoreboardLabelCountry: 'País',
scoreboardLabelGame: 'Juego', scoreboardLabelGame: 'Juego',
// ── About ────────────────────────────────────────────────────────────────
aboutTitle: 'Acerca de', aboutTitle: 'Acerca de',
aboutVersion: 'Versión', aboutVersion: 'Versión',
aboutDescription: 'Dashboard para producir overlays de juegos de lucha usando NodeCG, Vue y Quasar.', aboutDescription: 'Dashboard para producir overlays de juegos de lucha usando NodeCG, Vue y Quasar.',
@@ -249,6 +294,10 @@ const messages: Record<Locale, Translations> = {
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.', 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.', aboutUnknownReleaseError: 'Error desconocido al consultar releases.',
aboutGitHubStatusError: 'GitHub respondió con estado', aboutGitHubStatusError: 'GitHub respondió con estado',
aboutChangelog: 'Changelog',
aboutTechStackTitle: 'Tech stack',
// ── Graphics ─────────────────────────────────────────────────────────────
graphicsTitle: 'Gráficos', graphicsTitle: 'Gráficos',
graphicsDescription: 'Controles y estado de los gráficos del bundle.', graphicsDescription: 'Controles y estado de los gráficos del bundle.',
graphicsNoConfigured: 'No hay gráficos configurados en este bundle.', graphicsNoConfigured: 'No hay gráficos configurados en este bundle.',
@@ -257,19 +306,31 @@ const messages: Record<Locale, Translations> = {
graphicsScoreboard: 'Scoreboard', graphicsScoreboard: 'Scoreboard',
graphicsCommentary: 'Comentario', graphicsCommentary: 'Comentario',
graphicsSkinLabel: 'Skin', graphicsSkinLabel: 'Skin',
graphicsCopied: 'URL copiada al portapapeles',
graphicsOpenBrowser: 'Abrir en el navegador',
// ── Commentary ───────────────────────────────────────────────────────────
commentaryTitle: 'Comentario', commentaryTitle: 'Comentario',
commentaryCommentator1: 'Comentarista #1', commentaryCommentator1: 'Comentarista #1',
commentaryCommentator2: 'Comentarista #2', commentaryCommentator2: 'Comentarista #2',
commentaryTwitterText: '@Twitter / Texto', commentaryTwitterText: '@Twitter / Texto',
commentaryTwitterMaxLength: 'Se excedió el límite de caracteres de Twitter',
commentaryTwitterInvalidChars: 'Caracteres inválidos en el texto de Twitter',
commentarySwap: 'Intercambiar comentaristas',
commentaryClear: 'Limpiar comentario',
// ── Bracket ──────────────────────────────────────────────────────────────
bracketTitle: 'Bracket', bracketTitle: 'Bracket',
bracketStage: 'Etapa', bracketStage: 'Etapa',
bracketSide: 'Lado del bracket', bracketSide: 'Lado del bracket',
bracketCustomProgress: 'Progreso personalizado', bracketCustomProgress: 'Progreso personalizado',
bracketPreview: 'Vista previa', bracketPreview: 'Vista previa',
// ── Players ──────────────────────────────────────────────────────────────
playersLabelTeam: 'Equipo', playersLabelTeam: 'Equipo',
playersLabelCountry: 'País', playersLabelCountry: 'País',
playersLabelActions: 'Acciones', 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.', playersStartggHelp: 'Conéctate por OAuth (recomendado) o pega tu token personal para cargar torneos que creaste o administras.',
playersConnectStartgg: 'Conectar con start.gg', playersConnectStartgg: 'Conectar con start.gg',
playersConnected: 'Conectado', playersConnected: 'Conectado',
playersUsePersonalApi: 'Usar API personal', playersUsePersonalApi: 'Usar API personal',
@@ -281,28 +342,15 @@ const messages: Record<Locale, Translations> = {
playersSearchPlaceholder: 'Buscar...', playersSearchPlaceholder: 'Buscar...',
playersImport: 'Importar', playersImport: 'Importar',
playersExport: 'Exportar', playersExport: 'Exportar',
commentaryTwitterMaxLength: 'Se excedió el límite de caracteres de Twitter', playersConnectInSettings: 'Conecta tu cuenta en',
commentaryTwitterInvalidChars: 'Caracteres inválidos en el texto de Twitter', playersConnectInSettingsSuffix: 'para importar jugadores desde torneos.',
commentarySwap: 'Intercambiar comentaristas',
commentaryClear: 'Limpiar comentario',
aboutChangelog: 'Changelog',
aboutTechStackTitle: 'Tech stack',
settingsShortcutConflictWarning: 'This shortcut is already assigned to',
settingsShortcutStartRecording: 'Start recording shortcut',
settingsShortcutStopRecording: 'Stop recording shortcut',
settingsShortcutResetSingle: 'Reset single player score shortcut',
graphicsCopied: 'URL copiada al portapapeles',
graphicsOpenBrowser: 'Abrir en el navegador',
}, },
}; };
const normalizeLocale = (value: unknown): Locale => (value === 'es' ? 'es' : 'en'); const normalizeLocale = (value: unknown): Locale => (value === 'es' ? 'es' : 'en');
const getStoredLocale = (): Locale => { const getStoredLocale = (): Locale => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') return 'en';
return 'en';
}
return normalizeLocale(localStorage.getItem(STORAGE_KEY)); return normalizeLocale(localStorage.getItem(STORAGE_KEY));
}; };
@@ -310,7 +358,6 @@ export const locale = ref<Locale>(getStoredLocale());
export const setLocale = (value: Locale) => { export const setLocale = (value: Locale) => {
locale.value = normalizeLocale(value); locale.value = normalizeLocale(value);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, locale.value); localStorage.setItem(STORAGE_KEY, locale.value);
} }
+165 -361
View File
@@ -35,7 +35,7 @@ const playersStore = usePlayersStore();
const $q = useQuasar(); const $q = useQuasar();
const rows = computed<PlayerRow[]>(() => playersStore.rows); const rows = computed<PlayerRow[]>(() => playersStore.rows);
// ─── Integraciones ───────────────────────────────────────────────────────────── // ─── Integraciones (solo se usa para importar torneos) ─────────────────────────
const startgg = useIntegration({ const startgg = useIntegration({
messagePrefix: 'startgg', messagePrefix: 'startgg',
@@ -57,7 +57,6 @@ const challonge = useIntegration({
playersStore, playersStore,
}); });
// Notifica errores de apertura del diálogo de importación (sustituye window.alert)
watch(() => startgg.importDialogError, (msg) => { watch(() => startgg.importDialogError, (msg) => {
if (msg) $q.notify({ type: 'negative', message: msg }); if (msg) $q.notify({ type: 'negative', message: msg });
}); });
@@ -83,12 +82,6 @@ const formatExpiresAt = (ts: number): string =>
year: 'numeric', year: 'numeric',
}); });
// ─── Label de conexión de Challonge ───────────────────────────────────────────
const challongeConnectionLabel = computed(() =>
challonge.hasValidatedToken ? t('playersConnected') : 'Token set',
);
// ─── Tabla de jugadores ──────────────────────────────────────────────────────── // ─── Tabla de jugadores ────────────────────────────────────────────────────────
const filter = ref(''); const filter = ref('');
@@ -152,7 +145,6 @@ const openCreateDialog = () => {
const openEditDialog = (row: PlayerRow) => { const openEditDialog = (row: PlayerRow) => {
editingId.value = row.id; editingId.value = row.id;
// CORRECCIÓN: evitar el patrón `void id` usando un alias _id
const { id: _id, ...playerData } = row; const { id: _id, ...playerData } = row;
Object.assign(form, playerData); Object.assign(form, playerData);
isDialogOpen.value = true; isDialogOpen.value = true;
@@ -169,34 +161,6 @@ const deletePlayer = (row: PlayerRow) => {
} }
}; };
// ─── Diálogos de token manual ──────────────────────────────────────────────────
const isStartggManualDialogOpen = ref(false);
const startggManualDraft = ref('');
const openStartggManualDialog = () => {
startggManualDraft.value = startgg.token;
isStartggManualDialogOpen.value = true;
};
const saveStartggManualToken = () => {
startgg.token = startggManualDraft.value.trim();
isStartggManualDialogOpen.value = false;
};
const isChallongeManualDialogOpen = ref(false);
const challongeManualDraft = ref('');
const openChallongeManualDialog = () => {
challongeManualDraft.value = challonge.token;
isChallongeManualDialogOpen.value = true;
};
const saveChallongeManualToken = () => {
challonge.token = challongeManualDraft.value.trim();
isChallongeManualDialogOpen.value = false;
};
// ─── Exportar / importar JSON ────────────────────────────────────────────────── // ─── Exportar / importar JSON ──────────────────────────────────────────────────
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
@@ -232,6 +196,8 @@ const handleImport = async (event: Event) => {
<template> <template>
<QPage class="q-pa-lg players-page"> <QPage class="q-pa-lg players-page">
<!-- Cabecera -->
<div class="row items-center q-mb-md"> <div class="row items-center q-mb-md">
<div class="text-h5 text-weight-medium"> <div class="text-h5 text-weight-medium">
{{ t('menuPlayers') }} {{ t('menuPlayers') }}
@@ -248,7 +214,9 @@ const handleImport = async (event: Event) => {
</div> </div>
<div class="players-content row q-col-gutter-md"> <div class="players-content row q-col-gutter-md">
<div class="col-12">
<!-- Columna principal: tabla -->
<div class="col-12 col-lg-8 players-main-column">
<div class="row items-center q-gutter-sm q-mb-md"> <div class="row items-center q-gutter-sm q-mb-md">
<QInput <QInput
v-model="filter" v-model="filter"
@@ -262,19 +230,14 @@ const handleImport = async (event: Event) => {
</template> </template>
</QInput> </QInput>
<span class="text-caption text-grey-6">{{ rows.length }} players</span> <span class="text-caption text-grey-6">{{ rows.length }} players</span>
<QSpace />
<QBtn <QBtn
color="secondary" color="secondary" outline icon="file_upload" no-caps
outline
icon="file_upload"
no-caps
:label="t('playersImport')" :label="t('playersImport')"
@click="triggerImport" @click="triggerImport"
/> />
<QBtn <QBtn
color="secondary" color="secondary" outline icon="file_download" no-caps
outline
icon="file_download"
no-caps
:label="t('playersExport')" :label="t('playersExport')"
@click="exportPlayers" @click="exportPlayers"
/> />
@@ -286,9 +249,7 @@ const handleImport = async (event: Event) => {
@change="handleImport" @change="handleImport"
> >
</div> </div>
</div>
<div class="col-12 col-lg-8 players-main-column">
<QTable <QTable
flat flat
bordered bordered
@@ -328,238 +289,175 @@ const handleImport = async (event: Event) => {
</QTooltip> </QTooltip>
</QChip> </QChip>
</div> </div>
<div <div v-if="row.name" class="text-caption text-grey-6">
v-if="row.name"
class="text-caption text-grey-6"
>
{{ row.name }} {{ row.name }}
</div> </div>
</QTd> </QTd>
</template> </template>
<template #body-cell-actions="{ row }"> <template #body-cell-actions="{ row }">
<QTd align="right"> <QTd align="right">
<QBtn <QBtn size="sm" flat icon="edit" @click="openEditDialog(row)" />
size="sm" <QBtn size="sm" flat color="negative" icon="delete" @click="deletePlayer(row)" />
flat
icon="edit"
@click="openEditDialog(row)"
/>
<QBtn
size="sm"
flat
color="negative"
icon="delete"
@click="deletePlayer(row)"
/>
</QTd> </QTd>
</template> </template>
</QTable> </QTable>
</div> </div>
<!-- Panel de integraciones --> <!-- Columna lateral: importar desde torneo -->
<div class="col-12 col-lg-4 players-startgg-column"> <div class="col-12 col-lg-4 players-import-column">
<div class="players-integrations-stack"> <div class="players-integrations-stack">
<!-- start.gg --> <!-- start.gg -->
<QCard flat bordered class="q-pa-md"> <QCard flat bordered class="q-pa-md">
<div class="text-h6 q-mb-sm startgg-heading"> <div class="row items-center q-mb-xs q-gutter-x-sm">
<svg class="startgg-heading__icon" viewBox="0 0 40 40" fill="none" aria-hidden="true"> <svg style="width: 18px; height: 18px; flex-shrink: 0;" viewBox="0 0 40 40" fill="none" aria-hidden="true">
<path d="M1.25 20h7.5A1.25 1.25 0 0 0 10 18.75v-7.5A1.25 1.25 0 0 1 11.25 10h27.5A1.25 1.25 0 0 0 40 8.75V1.25A1.25 1.25 0 0 0 38.75 0H10A10 10 0 0 0 0 10v8.75A1.25 1.25 0 0 0 1.25 20Z" fill="#3f80ff" /> <path d="M1.25 20h7.5A1.25 1.25 0 0 0 10 18.75v-7.5A1.25 1.25 0 0 1 11.25 10h27.5A1.25 1.25 0 0 0 40 8.75V1.25A1.25 1.25 0 0 0 38.75 0H10A10 10 0 0 0 0 10v8.75A1.25 1.25 0 0 0 1.25 20Z" fill="#3f80ff" />
<path d="M38.75 20h-7.5A1.25 1.25 0 0 0 30 21.25v7.5A1.25 1.25 0 0 1 28.75 30H1.25A1.25 1.25 0 0 0 0 31.25v7.5A1.25 1.25 0 0 0 1.25 40H30A10 10 0 0 0 40 30V21.25A1.25 1.25 0 0 0 38.75 20Z" fill="#ff2768" /> <path d="M38.75 20h-7.5A1.25 1.25 0 0 0 30 21.25v7.5A1.25 1.25 0 0 1 28.75 30H1.25A1.25 1.25 0 0 0 0 31.25v7.5A1.25 1.25 0 0 0 1.25 40H30A10 10 0 0 0 40 30V21.25A1.25 1.25 0 0 0 38.75 20Z" fill="#ff2768" />
</svg> </svg>
<span>start.gg</span> <span class="text-subtitle2">start.gg</span>
</div> <QSpace />
<div class="text-caption text-grey-6 q-mb-md"> <QChip
{{ t('playersStartggHelp') }} v-if="startgg.hasTokenConfigured"
</div> dense size="sm"
<div class="row q-col-gutter-sm items-center"> :color="startgg.hasValidatedToken ? 'positive' : 'warning'"
<div class="col-auto">
<QBtn
v-if="!startgg.hasTokenConfigured"
color="primary"
icon="login"
no-caps
:label="t('playersConnectStartgg')"
:loading="startgg.oauthLoading"
@click="startgg.connectWithOAuth"
/>
<QBtn
v-else
outline
color="positive"
icon="check_circle"
no-caps
:label="t('playersConnected')"
class="startgg-connected-btn"
@click="openStartggManualDialog"
/>
</div>
<div class="col-auto">
<QBtn
outline
color="white"
icon="vpn_key"
no-caps
:label="t('playersUsePersonalApi')"
@click="openStartggManualDialog"
/>
</div>
</div>
<div v-if="startgg.tournamentsError" class="text-negative q-mt-sm">
{{ startgg.tournamentsError }}
</div>
<div class="row items-center q-mt-md startgg-tournament-row">
<QBtn
flat round dense
text-color="white" text-color="white"
icon="sync" >
class="startgg-refresh-btn" {{ t('playersConnected') }}
:loading="startgg.loadingTournaments" </QChip>
@click="startgg.loadRecentTournaments" <QChip v-else dense size="sm" color="grey-7" text-color="white" icon="link_off">
/> {{ t('settingsNotConnected') || 'Not connected' }}
<div class="col"> </QChip>
<QSelect
v-model="startgg.selectedTournamentSlug"
v-model:input-value="startgg.tournamentInput"
:options="startgg.filteredTournamentOptions"
option-value="value"
option-label="label"
emit-value
map-options
use-input
hide-selected
fill-input
input-debounce="0"
clearable
dense
:label="t('playersTournament')"
class="players-underlined-field"
@filter="startgg.filterTournaments"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel>{{ scope.opt.label }}</QItemLabel>
<QItemLabel caption>{{ scope.opt.caption }}</QItemLabel>
</QItemSection>
</QItem>
</template>
</QSelect>
</div>
<div v-if="startgg.canImportSelectedTournament" class="col-auto">
<QBtn
color="primary"
unelevated
round
icon="person_add"
:aria-label="t('playersImportPlayers')"
@click="startgg.openSelectedTournamentImportDialog"
>
<QTooltip>{{ t('playersImportPlayers') }}</QTooltip>
</QBtn>
</div>
</div> </div>
<!-- Sin token: aviso con enlace a Settings -->
<div v-if="!startgg.hasTokenConfigured" class="text-caption text-grey-6 q-mt-sm">
{{ t('playersConnectInSettings') || 'Connect your start.gg account in' }}
<RouterLink to="/settings" class="text-primary">Settings</RouterLink>
{{ t('playersConnectInSettingsSuffix') || 'to import players from tournaments.' }}
</div>
<!-- Con token: selector de torneo -->
<template v-else>
<div v-if="startgg.tournamentsError" class="text-negative text-caption q-mt-xs">
{{ startgg.tournamentsError }}
</div>
<div class="row items-center q-mt-sm players-tournament-row">
<QBtn
flat round dense text-color="white" icon="sync"
class="players-refresh-btn"
:loading="startgg.loadingTournaments"
@click="startgg.loadRecentTournaments"
>
<QTooltip>Refresh tournaments</QTooltip>
</QBtn>
<div class="col">
<QSelect
v-model="startgg.selectedTournamentSlug"
v-model:input-value="startgg.tournamentInput"
:options="startgg.filteredTournamentOptions"
option-value="value" option-label="label"
emit-value map-options use-input hide-selected fill-input
input-debounce="0" clearable dense
:label="t('playersTournament')"
class="players-underlined-field"
@filter="startgg.filterTournaments"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel>{{ scope.opt.label }}</QItemLabel>
<QItemLabel caption>{{ scope.opt.caption }}</QItemLabel>
</QItemSection>
</QItem>
</template>
</QSelect>
</div>
<div v-if="startgg.canImportSelectedTournament" class="col-auto q-ml-xs">
<QBtn
color="primary" unelevated round icon="person_add"
:aria-label="t('playersImportPlayers')"
@click="startgg.openSelectedTournamentImportDialog"
>
<QTooltip>{{ t('playersImportPlayers') }}</QTooltip>
</QBtn>
</div>
</div>
</template>
</QCard> </QCard>
<!-- Challonge --> <!-- Challonge -->
<QCard flat bordered class="q-pa-md"> <QCard flat bordered class="q-pa-md">
<div class="text-h6 q-mb-sm startgg-heading"> <div class="row items-center q-mb-xs q-gutter-x-sm">
<img <img src="https://challonge.com/favicon.ico" alt="Challonge" style="width: 18px; height: 18px; border-radius: 4px; flex-shrink: 0;">
class="challonge-heading__icon" <span class="text-subtitle2">Challonge</span>
src="https://challonge.com/favicon.ico" <QSpace />
alt="Challonge" <QChip
> v-if="challonge.hasTokenConfigured"
<span>Challonge</span> dense size="sm"
</div> :color="challonge.hasValidatedToken ? 'positive' : 'warning'"
<div class="text-caption text-grey-6 q-mb-md">
{{ t('playersChallongeHelp') }}
</div>
<div class="row q-col-gutter-sm items-center">
<div class="col-auto">
<QBtn
v-if="!challonge.hasTokenConfigured"
color="primary"
icon="login"
no-caps
:label="t('playersConnectChallonge')"
:loading="challonge.oauthLoading"
@click="challonge.connectWithOAuth"
/>
<QBtn
v-else
outline
:color="challonge.hasValidatedToken ? 'positive' : 'warning'"
icon="check_circle"
no-caps
:label="challongeConnectionLabel"
@click="openChallongeManualDialog"
/>
</div>
<div class="col-auto">
<QBtn
outline
color="white"
icon="vpn_key"
no-caps
:label="t('playersUsePersonalApi')"
@click="openChallongeManualDialog"
/>
</div>
</div>
<div v-if="challonge.tournamentsError" class="text-negative q-mt-sm">
{{ challonge.tournamentsError }}
</div>
<div class="row items-center q-mt-md startgg-tournament-row">
<QBtn
flat round dense
text-color="white" text-color="white"
icon="sync" >
class="startgg-refresh-btn" {{ challonge.hasValidatedToken ? t('playersConnected') : 'Token set' }}
:loading="challonge.loadingTournaments" </QChip>
@click="challonge.loadRecentTournaments" <QChip v-else dense size="sm" color="grey-7" text-color="white" icon="link_off">
/> {{ t('settingsNotConnected') || 'Not connected' }}
<div class="col"> </QChip>
<QSelect
v-model="challonge.selectedTournamentSlug"
v-model:input-value="challonge.tournamentInput"
:options="challonge.filteredTournamentOptions"
option-value="value"
option-label="label"
emit-value
map-options
use-input
hide-selected
fill-input
input-debounce="0"
clearable
dense
:label="t('playersTournament')"
class="players-underlined-field"
@filter="challonge.filterTournaments"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel>{{ scope.opt.label }}</QItemLabel>
<QItemLabel caption>{{ scope.opt.caption }}</QItemLabel>
</QItemSection>
</QItem>
</template>
</QSelect>
</div>
<div v-if="challonge.canImportSelectedTournament" class="col-auto">
<QBtn
color="primary"
unelevated
round
icon="person_add"
:aria-label="t('playersImportPlayers')"
@click="challonge.openSelectedTournamentImportDialog"
>
<QTooltip>{{ t('playersImportPlayers') }}</QTooltip>
</QBtn>
</div>
</div> </div>
<!-- Sin token: aviso -->
<div v-if="!challonge.hasTokenConfigured" class="text-caption text-grey-6 q-mt-sm">
{{ t('playersConnectInSettings') || 'Connect your Challonge account in' }}
<RouterLink to="/settings" class="text-primary">Settings</RouterLink>
{{ t('playersConnectInSettingsSuffix') || 'to import players from tournaments.' }}
</div>
<!-- Con token: selector de torneo -->
<template v-else>
<div v-if="challonge.tournamentsError" class="text-negative text-caption q-mt-xs">
{{ challonge.tournamentsError }}
</div>
<div class="row items-center q-mt-sm players-tournament-row">
<QBtn
flat round dense text-color="white" icon="sync"
class="players-refresh-btn"
:loading="challonge.loadingTournaments"
@click="challonge.loadRecentTournaments"
>
<QTooltip>Refresh tournaments</QTooltip>
</QBtn>
<div class="col">
<QSelect
v-model="challonge.selectedTournamentSlug"
v-model:input-value="challonge.tournamentInput"
:options="challonge.filteredTournamentOptions"
option-value="value" option-label="label"
emit-value map-options use-input hide-selected fill-input
input-debounce="0" clearable dense
:label="t('playersTournament')"
class="players-underlined-field"
@filter="challonge.filterTournaments"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel>{{ scope.opt.label }}</QItemLabel>
<QItemLabel caption>{{ scope.opt.caption }}</QItemLabel>
</QItemSection>
</QItem>
</template>
</QSelect>
</div>
<div v-if="challonge.canImportSelectedTournament" class="col-auto q-ml-xs">
<QBtn
color="primary" unelevated round icon="person_add"
:aria-label="t('playersImportPlayers')"
@click="challonge.openSelectedTournamentImportDialog"
>
<QTooltip>{{ t('playersImportPlayers') }}</QTooltip>
</QBtn>
</div>
</div>
</template>
</QCard> </QCard>
</div> </div>
@@ -570,9 +468,7 @@ const handleImport = async (event: Event) => {
<QDialog v-model="startgg.importDialogOpen"> <QDialog v-model="startgg.importDialogOpen">
<QCard class="players-dialog"> <QCard class="players-dialog">
<QCardSection> <QCardSection>
<div class="text-h6"> <div class="text-h6">Import from {{ startgg.importingTournament?.name || 'start.gg' }}</div>
Import from {{ startgg.importingTournament?.name || 'start.gg' }}
</div>
</QCardSection> </QCardSection>
<QSeparator /> <QSeparator />
<QCardSection> <QCardSection>
@@ -617,9 +513,7 @@ const handleImport = async (event: Event) => {
<QDialog v-model="challonge.importDialogOpen"> <QDialog v-model="challonge.importDialogOpen">
<QCard class="players-dialog"> <QCard class="players-dialog">
<QCardSection> <QCardSection>
<div class="text-h6"> <div class="text-h6">Import from {{ challonge.importingTournament?.name || 'Challonge' }}</div>
Import from {{ challonge.importingTournament?.name || 'Challonge' }}
</div>
</QCardSection> </QCardSection>
<QSeparator /> <QSeparator />
<QCardSection> <QCardSection>
@@ -660,65 +554,6 @@ const handleImport = async (event: Event) => {
</QCard> </QCard>
</QDialog> </QDialog>
<!-- Diálogo token personal start.gg -->
<QDialog v-model="isStartggManualDialogOpen">
<QCard class="players-dialog">
<QCardSection>
<div class="text-h6">Personal start.gg API</div>
</QCardSection>
<QSeparator />
<QCardSection>
<div class="text-body2 q-mb-sm">
If OAuth fails, you can create your personal token manually with these steps:
</div>
<ol class="q-pl-md q-mb-md manual-token-steps">
<li>Go to https://start.gg/admin/profile/developer</li>
<li>Sign in with your account</li>
<li>From the 3 access tokens, click <strong>Third Party</strong></li>
<li>Create a new one and fill the description with any name you want</li>
<li>Copy the generated token and paste it into Scoreko</li>
</ol>
<QInput
v-model="startggManualDraft"
label="Paste your personal token"
dense outlined type="password"
/>
</QCardSection>
<QSeparator />
<QCardActions align="right">
<QBtn flat no-caps label="Cancel" color="secondary" @click="isStartggManualDialogOpen = false" />
<QBtn flat no-caps color="negative" label="Delete token" @click="startggManualDraft = ''; saveStartggManualToken()" />
<QBtn no-caps color="primary" label="Save token" @click="saveStartggManualToken" />
</QCardActions>
</QCard>
</QDialog>
<!-- Diálogo token personal Challonge -->
<QDialog v-model="isChallongeManualDialogOpen">
<QCard class="players-dialog">
<QCardSection>
<div class="text-h6">Personal Challonge API</div>
</QCardSection>
<QSeparator />
<QCardSection>
<div class="text-body2 q-mb-sm">
If OAuth fails, paste a personal Challonge API token.
</div>
<QInput
v-model="challongeManualDraft"
label="Paste your personal Challonge token"
dense outlined type="password"
/>
</QCardSection>
<QSeparator />
<QCardActions align="right">
<QBtn flat no-caps label="Cancel" color="secondary" @click="isChallongeManualDialogOpen = false" />
<QBtn flat no-caps color="negative" label="Delete token" @click="challongeManualDraft = ''; saveChallongeManualToken()" />
<QBtn no-caps color="primary" label="Save token" @click="saveChallongeManualToken" />
</QCardActions>
</QCard>
</QDialog>
<!-- Diálogo crear / editar jugador --> <!-- Diálogo crear / editar jugador -->
<QDialog v-model="isDialogOpen"> <QDialog v-model="isDialogOpen">
<QCard class="players-dialog"> <QCard class="players-dialog">
@@ -750,18 +585,11 @@ const handleImport = async (event: Event) => {
v-model="form.country" v-model="form.country"
v-model:input-value="countryInput" v-model:input-value="countryInput"
:options="filteredCountryOptions" :options="filteredCountryOptions"
option-value="value" option-value="value" option-label="label"
option-label="label" emit-value map-options use-input input-debounce="0"
emit-value hide-selected fill-input clearable
map-options
use-input
input-debounce="0"
hide-selected
fill-input
clearable
:label="t('playersLabelCountry')" :label="t('playersLabelCountry')"
dense dense class="players-underlined-field"
class="players-underlined-field"
@filter="filterCountries" @filter="filterCountries"
/> />
</div> </div>
@@ -781,6 +609,7 @@ const handleImport = async (event: Event) => {
</QCardActions> </QCardActions>
</QCard> </QCard>
</QDialog> </QDialog>
</QPage> </QPage>
</template> </template>
@@ -804,8 +633,8 @@ const handleImport = async (event: Event) => {
flex: 1 1 auto; flex: 1 1 auto;
} }
.players-startgg-column { .players-import-column {
min-width: 320px; min-width: 300px;
} }
.players-integrations-stack { .players-integrations-stack {
@@ -819,35 +648,14 @@ const handleImport = async (event: Event) => {
width: min(720px, 90vw); width: min(720px, 90vw);
} }
.startgg-heading { .players-tournament-row {
display: inline-flex;
align-items: center;
gap: 8px;
}
.startgg-heading__icon {
width: 20px;
height: 20px;
}
.challonge-heading__icon {
width: 20px;
height: 20px;
border-radius: 4px;
}
.startgg-tournament-row {
gap: 4px; gap: 4px;
} }
.startgg-refresh-btn:hover { .players-refresh-btn:hover {
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.12);
} }
.startgg-connected-btn {
font-weight: 600;
}
.players-underlined-field :deep(.q-field__control) { .players-underlined-field :deep(.q-field__control) {
min-height: 28px; min-height: 28px;
padding: 0; padding: 0;
@@ -861,10 +669,6 @@ const handleImport = async (event: Event) => {
border-bottom: 1px solid rgba(255, 255, 255, 0.34); border-bottom: 1px solid rgba(255, 255, 255, 0.34);
} }
.manual-token-steps {
line-height: 1.5;
}
.visually-hidden { .visually-hidden {
position: absolute; position: absolute;
width: 1px; width: 1px;
+395 -149
View File
@@ -1,8 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { computed, onBeforeUnmount, ref } from 'vue'; import { useQuasar } from 'quasar';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useIntegration } from '../composables/useIntegration';
import type { Locale } from '../i18n'; import type { Locale } from '../i18n';
import { locale, setLocale, t } from '../i18n'; import { locale, setLocale, t } from '../i18n';
import { usePlayersStore } from '../stores/players';
import { import {
eventToShortcut, eventToShortcut,
type ShortcutAction, type ShortcutAction,
@@ -13,6 +16,8 @@ defineOptions({ name: 'SettingsView' });
useHead(() => ({ title: t('settingsTitle') })); useHead(() => ({ title: t('settingsTitle') }));
// ─── Idioma ────────────────────────────────────────────────────────────────────
const languageOptions = computed(() => [ const languageOptions = computed(() => [
{ label: t('languageSpanish'), value: 'es' as const }, { label: t('languageSpanish'), value: 'es' as const },
{ label: t('languageEnglish'), value: 'en' as const }, { label: t('languageEnglish'), value: 'en' as const },
@@ -20,15 +25,13 @@ const languageOptions = computed(() => [
const selectedLanguage = computed<Locale>({ const selectedLanguage = computed<Locale>({
get: () => locale.value, get: () => locale.value,
set: (value) => { set: (value) => { setLocale(value); },
setLocale(value);
},
}); });
// ─── Atajos de teclado ─────────────────────────────────────────────────────────
const shortcutSettingsStore = useShortcutSettingsStore(); const shortcutSettingsStore = useShortcutSettingsStore();
const recordingAction = ref<ShortcutAction | null>(null); const recordingAction = ref<ShortcutAction | null>(null);
// Ref para detectar clicks fuera del contenedor de atajos
const shortcutsContainerRef = ref<HTMLElement | null>(null); const shortcutsContainerRef = ref<HTMLElement | null>(null);
const shortcutFields = computed<{ action: ShortcutAction; label: string; hint: string }[]>(() => [ const shortcutFields = computed<{ action: ShortcutAction; label: string; hint: string }[]>(() => [
@@ -38,7 +41,6 @@ const shortcutFields = computed<{ action: ShortcutAction; label: string; hint: s
{ action: 'rightDecrement', label: t('settingsShortcutRightDecrementLabel'), hint: t('settingsShortcutRightDecrementHint') }, { action: 'rightDecrement', label: t('settingsShortcutRightDecrementLabel'), hint: t('settingsShortcutRightDecrementHint') },
]); ]);
// Detecta atajos duplicados entre acciones
const conflictingActions = computed(() => { const conflictingActions = computed(() => {
const seen = new Map<string, ShortcutAction>(); const seen = new Map<string, ShortcutAction>();
const conflicts = new Set<ShortcutAction>(); const conflicts = new Set<ShortcutAction>();
@@ -62,43 +64,24 @@ const stopRecording = () => {
const onRecordKeydown = (event: KeyboardEvent) => { const onRecordKeydown = (event: KeyboardEvent) => {
if (!recordingAction.value) return; if (!recordingAction.value) return;
if (event.key === 'Escape') { event.preventDefault(); stopRecording(); return; }
// Escape cancela la grabación sin asignar ningún atajo
if (event.key === 'Escape') {
event.preventDefault();
stopRecording();
return;
}
const shortcut = eventToShortcut(event); const shortcut = eventToShortcut(event);
if (!shortcut) return; if (!shortcut) return;
event.preventDefault(); event.preventDefault();
shortcutSettingsStore.setShortcut(recordingAction.value, shortcut); shortcutSettingsStore.setShortcut(recordingAction.value, shortcut);
stopRecording(); stopRecording();
}; };
// Click fuera del área de atajos también cancela la grabación
const onDocumentMousedown = (event: MouseEvent) => { const onDocumentMousedown = (event: MouseEvent) => {
if ( if (recordingAction.value && shortcutsContainerRef.value && !shortcutsContainerRef.value.contains(event.target as Node)) {
recordingAction.value &&
shortcutsContainerRef.value &&
!shortcutsContainerRef.value.contains(event.target as Node)
) {
stopRecording(); stopRecording();
} }
}; };
const startRecording = (action: ShortcutAction) => { const startRecording = (action: ShortcutAction) => {
if (recordingAction.value === action) { if (recordingAction.value === action) { stopRecording(); return; }
stopRecording();
return;
}
recordingAction.value = action; recordingAction.value = action;
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') document.body.dataset.shortcutRecording = 'true';
document.body.dataset.shortcutRecording = 'true';
}
}; };
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -113,6 +96,79 @@ onBeforeUnmount(() => {
} }
stopRecording(); stopRecording();
}); });
// ─── Integraciones ─────────────────────────────────────────────────────────────
const STARTGG_TOKEN_STORAGE_KEY = 'scoreko-dev.startgg-token';
const CHALLONGE_TOKEN_STORAGE_KEY = 'scoreko-dev.challonge-token';
const STARTGG_TEMP_PLAYERS_STORAGE_KEY = 'scoreko-dev.startgg-temp-players';
const CHALLONGE_TEMP_PLAYERS_STORAGE_KEY = 'scoreko-dev.challonge-temp-players';
const TEMP_FALLBACK_DURATION_SECONDS = 12 * 60 * 60;
const playersStore = usePlayersStore();
const $q = useQuasar();
const startgg = useIntegration({
messagePrefix: 'startgg',
providerLabel: 'start.gg',
tokenStorageKey: STARTGG_TOKEN_STORAGE_KEY,
tempPlayersStorageKey: STARTGG_TEMP_PLAYERS_STORAGE_KEY,
tempFallbackDurationSeconds: TEMP_FALLBACK_DURATION_SECONDS,
playersStore,
});
const challonge = useIntegration({
messagePrefix: 'challonge',
providerLabel: 'Challonge',
tokenStorageKey: CHALLONGE_TOKEN_STORAGE_KEY,
tempPlayersStorageKey: CHALLONGE_TEMP_PLAYERS_STORAGE_KEY,
tempFallbackDurationSeconds: TEMP_FALLBACK_DURATION_SECONDS,
on401Message:
'Challonge rejected the token (401 Unauthorized). Re-connect OAuth so it grants scopes (me, tournaments:read, participants:read) or paste a valid personal API token.',
playersStore,
});
// ─── Diálogos de token manual ──────────────────────────────────────────────────
const isStartggManualDialogOpen = ref(false);
const startggManualDraft = ref('');
const openStartggManualDialog = () => {
startggManualDraft.value = startgg.token;
isStartggManualDialogOpen.value = true;
};
const saveStartggManualToken = () => {
startgg.token = startggManualDraft.value.trim();
isStartggManualDialogOpen.value = false;
$q.notify({ type: 'positive', message: startgg.token ? 'start.gg token saved.' : 'start.gg token removed.' });
};
const isChallongeManualDialogOpen = ref(false);
const challongeManualDraft = ref('');
const openChallongeManualDialog = () => {
challongeManualDraft.value = challonge.token;
isChallongeManualDialogOpen.value = true;
};
const saveChallongeManualToken = () => {
challonge.token = challongeManualDraft.value.trim();
isChallongeManualDialogOpen.value = false;
$q.notify({ type: 'positive', message: challonge.token ? 'Challonge token saved.' : 'Challonge token removed.' });
};
// Label de estado de Challonge
const challongeConnectionLabel = computed(() =>
challonge.hasValidatedToken ? t('playersConnected') : 'Token set',
);
watch(() => startgg.importDialogError, (msg) => {
if (msg) $q.notify({ type: 'negative', message: msg });
});
watch(() => challonge.importDialogError, (msg) => {
if (msg) $q.notify({ type: 'negative', message: msg });
});
</script> </script>
<template> <template>
@@ -126,134 +182,324 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<QCard <div class="column q-gutter-lg settings-layout">
flat
bordered
class="settings-card"
>
<!-- Language -->
<QCardSection class="q-pa-lg">
<!--
Label movido al propio QSelect (más idiomático en Quasar con outlined).
Se elimina el text-overline redundante de encima.
-->
<QSelect
v-model="selectedLanguage"
emit-value
map-options
:options="languageOptions"
:label="t('settingsLanguageLabel')"
outlined
dense
/>
<div class="text-caption text-grey-6 q-mt-sm"> <!-- Idioma -->
{{ t('settingsLanguageHint') }} <QCard flat bordered class="settings-card">
</div> <QCardSection class="q-pa-lg">
</QCardSection> <div class="text-overline text-grey-6 q-mb-md">{{ t('settingsLanguageLabel') }}</div>
<QSelect
<QSeparator /> v-model="selectedLanguage"
emit-value
<!-- Shortcuts --> map-options
<QCardSection class="q-pa-lg"> :options="languageOptions"
<div class="row items-center justify-between q-mb-xs"> :label="t('settingsLanguageLabel')"
<div class="text-overline text-grey-6">
{{ t('settingsShortcutTitle') }}
</div>
<QBtn
round
dense
flat
color="primary"
icon="restart_alt"
:aria-label="t('settingsShortcutReset')"
@click="shortcutSettingsStore.resetShortcuts"
>
<QTooltip>{{ t('settingsShortcutReset') }}</QTooltip>
</QBtn>
</div>
<div class="text-caption text-grey-6 q-mb-lg">
{{ t('settingsShortcutDescription') }}
</div>
<!-- Aviso de conflicto: se muestra si dos acciones comparten el mismo atajo -->
<QBanner
v-if="conflictingActions.size > 0"
class="bg-warning text-white q-mb-md"
rounded
dense
>
<template #avatar>
<QIcon name="warning" color="white" />
</template>
{{ t('settingsShortcutConflictWarning') }}
</QBanner>
<!--
ref="shortcutsContainerRef" permite detectar clicks fuera
de esta área para cancelar la grabación automáticamente.
-->
<div
ref="shortcutsContainerRef"
class="column q-gutter-md"
>
<QInput
v-for="field in shortcutFields"
:key="field.action"
:model-value="shortcutSettingsStore.shortcuts[field.action]"
:hint="recordingAction === field.action ? t('settingsShortcutRecordingHint') : field.hint"
:color="
recordingAction === field.action
? 'negative'
: conflictingActions.has(field.action)
? 'warning'
: 'primary'
"
readonly
outlined outlined
dense dense
bottom-slots style="max-width: 280px"
:label="field.label" />
> <div class="text-caption text-grey-6 q-mt-sm">
<template #append> {{ t('settingsLanguageHint') }}
<!-- Botón grabar / detener --> </div>
<QBtn </QCardSection>
flat </QCard>
round
dense
:icon="recordingAction === field.action ? 'stop_circle' : 'keyboard'"
:color="recordingAction === field.action ? 'negative' : 'primary'"
:aria-label="
recordingAction === field.action
? t('settingsShortcutStopRecording')
: t('settingsShortcutStartRecording')
"
@click="startRecording(field.action)"
/>
<!-- Botón reset individual por atajo --> <!-- Integraciones -->
<QBtn <QCard flat bordered class="settings-card">
flat <QCardSection class="q-pa-lg">
round <div class="text-overline text-grey-6 q-mb-xs">{{ t('settingsIntegrationsTitle') || 'Integrations' }}</div>
dense <div class="text-caption text-grey-6 q-mb-lg">
icon="restart_alt" {{ t('settingsIntegrationsDescription') || 'Connect your tournament platform accounts to import players directly from brackets.' }}
color="grey-5" </div>
:aria-label="t('settingsShortcutResetSingle')"
@click="shortcutSettingsStore.resetShortcut(field.action)" <div class="column q-gutter-md">
>
<QTooltip>{{ t('settingsShortcutResetSingle') }}</QTooltip> <!-- start.gg -->
</QBtn> <div class="integration-row">
<div class="integration-row__logo">
<svg style="width: 28px; height: 28px;" viewBox="0 0 40 40" fill="none" aria-hidden="true">
<path d="M1.25 20h7.5A1.25 1.25 0 0 0 10 18.75v-7.5A1.25 1.25 0 0 1 11.25 10h27.5A1.25 1.25 0 0 0 40 8.75V1.25A1.25 1.25 0 0 0 38.75 0H10A10 10 0 0 0 0 10v8.75A1.25 1.25 0 0 0 1.25 20Z" fill="#3f80ff" />
<path d="M38.75 20h-7.5A1.25 1.25 0 0 0 30 21.25v7.5A1.25 1.25 0 0 1 28.75 30H1.25A1.25 1.25 0 0 0 0 31.25v7.5A1.25 1.25 0 0 0 1.25 40H30A10 10 0 0 0 40 30V21.25A1.25 1.25 0 0 0 38.75 20Z" fill="#ff2768" />
</svg>
</div>
<div class="integration-row__info">
<div class="text-body2 text-weight-medium">start.gg</div>
<div class="text-caption text-grey-6">{{ t('playersStartggHelp') }}</div>
</div>
<div class="integration-row__actions row q-gutter-sm items-center">
<QChip
v-if="startgg.hasTokenConfigured"
dense
:color="startgg.hasValidatedToken ? 'positive' : 'warning'"
text-color="white"
icon="check_circle"
>
{{ t('playersConnected') }}
</QChip>
<QBtn
v-if="!startgg.hasTokenConfigured"
color="primary"
icon="login"
no-caps
unelevated
:label="t('playersConnectStartgg')"
:loading="startgg.oauthLoading"
@click="startgg.connectWithOAuth"
/>
<QBtn
v-else
flat
color="negative"
icon="link_off"
no-caps
size="sm"
:label="t('settingsDisconnect') || 'Disconnect'"
@click="startggManualDraft = ''; startgg.token = ''; $q.notify({ type: 'info', message: 'start.gg disconnected.' })"
/>
<QBtn
outline
:color="startgg.hasTokenConfigured ? 'grey-5' : 'white'"
icon="vpn_key"
no-caps
size="sm"
:label="t('playersUsePersonalApi')"
@click="openStartggManualDialog"
/>
</div>
</div>
<QSeparator />
<!-- Challonge -->
<div class="integration-row">
<div class="integration-row__logo">
<img
src="https://challonge.com/favicon.ico"
alt="Challonge"
style="width: 28px; height: 28px; border-radius: 6px;"
>
</div>
<div class="integration-row__info">
<div class="text-body2 text-weight-medium">Challonge</div>
<div class="text-caption text-grey-6">{{ t('playersChallongeHelp') }}</div>
</div>
<div class="integration-row__actions row q-gutter-sm items-center">
<QChip
v-if="challonge.hasTokenConfigured"
dense
:color="challonge.hasValidatedToken ? 'positive' : 'warning'"
text-color="white"
icon="check_circle"
>
{{ challongeConnectionLabel }}
</QChip>
<QBtn
v-if="!challonge.hasTokenConfigured"
color="primary"
icon="login"
no-caps
unelevated
:label="t('playersConnectChallonge')"
:loading="challonge.oauthLoading"
@click="challonge.connectWithOAuth"
/>
<QBtn
v-else
flat
color="negative"
icon="link_off"
no-caps
size="sm"
:label="t('settingsDisconnect') || 'Disconnect'"
@click="challongeManualDraft = ''; challonge.token = ''; $q.notify({ type: 'info', message: 'Challonge disconnected.' })"
/>
<QBtn
outline
:color="challonge.hasTokenConfigured ? 'grey-5' : 'white'"
icon="vpn_key"
no-caps
size="sm"
:label="t('playersUsePersonalApi')"
@click="openChallongeManualDialog"
/>
</div>
</div>
</div>
</QCardSection>
</QCard>
<!-- Atajos de teclado -->
<QCard flat bordered class="settings-card">
<QCardSection class="q-pa-lg">
<div class="row items-center justify-between q-mb-xs">
<div class="text-overline text-grey-6">
{{ t('settingsShortcutTitle') }}
</div>
<QBtn
round dense flat color="primary" icon="restart_alt"
:aria-label="t('settingsShortcutReset')"
@click="shortcutSettingsStore.resetShortcuts"
>
<QTooltip>{{ t('settingsShortcutReset') }}</QTooltip>
</QBtn>
</div>
<div class="text-caption text-grey-6 q-mb-lg">
{{ t('settingsShortcutDescription') }}
</div>
<QBanner
v-if="conflictingActions.size > 0"
class="bg-warning text-white q-mb-md"
rounded dense
>
<template #avatar>
<QIcon name="warning" color="white" />
</template> </template>
</QInput> {{ t('settingsShortcutConflictWarning') }}
</div> </QBanner>
</QCardSection>
</QCard> <div ref="shortcutsContainerRef" class="column q-gutter-md">
<QInput
v-for="field in shortcutFields"
:key="field.action"
:model-value="shortcutSettingsStore.shortcuts[field.action]"
:hint="recordingAction === field.action ? t('settingsShortcutRecordingHint') : field.hint"
:color="
recordingAction === field.action
? 'negative'
: conflictingActions.has(field.action)
? 'warning'
: 'primary'
"
readonly outlined dense bottom-slots
:label="field.label"
>
<template #append>
<QBtn
flat round dense
:icon="recordingAction === field.action ? 'stop_circle' : 'keyboard'"
:color="recordingAction === field.action ? 'negative' : 'primary'"
:aria-label="recordingAction === field.action ? t('settingsShortcutStopRecording') : t('settingsShortcutStartRecording')"
@click="startRecording(field.action)"
/>
<QBtn
flat round dense icon="restart_alt" color="grey-5"
:aria-label="t('settingsShortcutResetSingle')"
@click="shortcutSettingsStore.resetShortcut(field.action)"
>
<QTooltip>{{ t('settingsShortcutResetSingle') }}</QTooltip>
</QBtn>
</template>
</QInput>
</div>
</QCardSection>
</QCard>
</div>
<!-- Diálogo token personal start.gg -->
<QDialog v-model="isStartggManualDialogOpen">
<QCard class="settings-dialog">
<QCardSection>
<div class="text-h6">Personal start.gg API token</div>
</QCardSection>
<QSeparator />
<QCardSection>
<div class="text-body2 q-mb-sm">
If OAuth fails, you can create a personal token manually:
</div>
<ol class="q-pl-md q-mb-md settings-token-steps">
<li>Go to https://start.gg/admin/profile/developer</li>
<li>Sign in with your account</li>
<li>From the 3 access tokens, click <strong>Third Party</strong></li>
<li>Create a new one and fill the description with any name you want</li>
<li>Copy the generated token and paste it below</li>
</ol>
<QInput
v-model="startggManualDraft"
label="Paste your personal token"
dense outlined type="password"
/>
</QCardSection>
<QSeparator />
<QCardActions align="right">
<QBtn flat no-caps label="Cancel" color="secondary" @click="isStartggManualDialogOpen = false" />
<QBtn flat no-caps color="negative" label="Delete token" @click="startggManualDraft = ''; saveStartggManualToken()" />
<QBtn no-caps color="primary" label="Save token" @click="saveStartggManualToken" />
</QCardActions>
</QCard>
</QDialog>
<!-- Diálogo token personal Challonge -->
<QDialog v-model="isChallongeManualDialogOpen">
<QCard class="settings-dialog">
<QCardSection>
<div class="text-h6">Personal Challonge API token</div>
</QCardSection>
<QSeparator />
<QCardSection>
<div class="text-body2 q-mb-sm">
If OAuth fails, paste a personal Challonge API token.
</div>
<QInput
v-model="challongeManualDraft"
label="Paste your personal Challonge token"
dense outlined type="password"
/>
</QCardSection>
<QSeparator />
<QCardActions align="right">
<QBtn flat no-caps label="Cancel" color="secondary" @click="isChallongeManualDialogOpen = false" />
<QBtn flat no-caps color="negative" label="Delete token" @click="challongeManualDraft = ''; saveChallongeManualToken()" />
<QBtn no-caps color="primary" label="Save token" @click="saveChallongeManualToken" />
</QCardActions>
</QCard>
</QDialog>
</QPage> </QPage>
</template> </template>
<style scoped> <style scoped>
.settings-layout {
max-width: 680px;
}
.settings-card { .settings-card {
max-width: 600px; width: 100%;
}
.settings-dialog {
min-width: 320px;
width: min(560px, 90vw);
}
.settings-token-steps {
line-height: 1.6;
}
/* Fila de integración: logo | info | acciones */
.integration-row {
display: flex;
align-items: center;
gap: 16px;
}
.integration-row__logo {
flex-shrink: 0;
width: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.integration-row__info {
flex: 1 1 auto;
min-width: 0;
}
.integration-row__actions {
flex-shrink: 0;
} }
</style> </style>