mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-05 19:22:07 +00:00
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:
@@ -24,6 +24,14 @@ type Translations = {
|
||||
settingsShortcutRightDecrementHint: string;
|
||||
settingsShortcutReset: string;
|
||||
settingsShortcutRecordingHint: string;
|
||||
settingsShortcutConflictWarning: string;
|
||||
settingsShortcutStartRecording: string;
|
||||
settingsShortcutStopRecording: string;
|
||||
settingsShortcutResetSingle: string;
|
||||
settingsIntegrationsTitle: string;
|
||||
settingsIntegrationsDescription: string;
|
||||
settingsDisconnect: string;
|
||||
settingsNotConnected: string;
|
||||
languageEnglish: string;
|
||||
languageSpanish: string;
|
||||
scoreboardUnassigned: string;
|
||||
@@ -53,6 +61,8 @@ type Translations = {
|
||||
aboutElectronNote: string;
|
||||
aboutUnknownReleaseError: string;
|
||||
aboutGitHubStatusError: string;
|
||||
aboutChangelog: string;
|
||||
aboutTechStackTitle: string;
|
||||
graphicsTitle: string;
|
||||
graphicsDescription: string;
|
||||
graphicsNoConfigured: string;
|
||||
@@ -61,10 +71,16 @@ type Translations = {
|
||||
graphicsScoreboard: string;
|
||||
graphicsCommentary: string;
|
||||
graphicsSkinLabel: string;
|
||||
graphicsCopied: string;
|
||||
graphicsOpenBrowser: string;
|
||||
commentaryTitle: string;
|
||||
commentaryCommentator1: string;
|
||||
commentaryCommentator2: string;
|
||||
commentaryTwitterText: string;
|
||||
commentaryTwitterMaxLength: string;
|
||||
commentaryTwitterInvalidChars: string;
|
||||
commentarySwap: string;
|
||||
commentaryClear: string;
|
||||
bracketTitle: string;
|
||||
bracketStage: string;
|
||||
bracketSide: string;
|
||||
@@ -85,18 +101,8 @@ type Translations = {
|
||||
playersSearchPlaceholder: string;
|
||||
playersImport: string;
|
||||
playersExport: string;
|
||||
commentaryTwitterMaxLength: string;
|
||||
commentaryTwitterInvalidChars: string;
|
||||
commentarySwap: string;
|
||||
commentaryClear: string;
|
||||
aboutChangelog : string;
|
||||
aboutTechStackTitle : string;
|
||||
settingsShortcutConflictWarning : string;
|
||||
settingsShortcutStartRecording: string;
|
||||
settingsShortcutStopRecording: string;
|
||||
settingsShortcutResetSingle: string;
|
||||
graphicsCopied : string;
|
||||
graphicsOpenBrowser : string;
|
||||
playersConnectInSettings: string;
|
||||
playersConnectInSettingsSuffix: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'scoreko-dev.language';
|
||||
@@ -108,6 +114,8 @@ const messages: Record<Locale, Translations> = {
|
||||
menuGraphics: 'Graphics',
|
||||
menuSettings: 'Settings',
|
||||
menuAbout: 'About',
|
||||
|
||||
// ── Settings ────────────────────────────────────────────────────────────
|
||||
settingsTitle: 'Settings',
|
||||
settingsDescription: 'Dashboard and bundle configuration.',
|
||||
settingsLanguageLabel: 'Language',
|
||||
@@ -124,8 +132,20 @@ const messages: Record<Locale, Translations> = {
|
||||
settingsShortcutRightDecrementHint: 'Decreases right player score by one.',
|
||||
settingsShortcutReset: 'Reset shortcuts',
|
||||
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',
|
||||
languageSpanish: 'Spanish',
|
||||
|
||||
// ── Scoreboard ───────────────────────────────────────────────────────────
|
||||
scoreboardUnassigned: '(Unassigned)',
|
||||
scoreboardLeft: 'Left',
|
||||
scoreboardRight: 'Right',
|
||||
@@ -137,6 +157,8 @@ const messages: Record<Locale, Translations> = {
|
||||
scoreboardLabelTeam: 'Team',
|
||||
scoreboardLabelCountry: 'Country',
|
||||
scoreboardLabelGame: 'Game',
|
||||
|
||||
// ── About ────────────────────────────────────────────────────────────────
|
||||
aboutTitle: 'About',
|
||||
aboutVersion: 'Version',
|
||||
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.',
|
||||
aboutUnknownReleaseError: 'Unknown error while checking releases.',
|
||||
aboutGitHubStatusError: 'GitHub responded with status',
|
||||
aboutChangelog: 'Changelog',
|
||||
aboutTechStackTitle: 'Tech stack',
|
||||
|
||||
// ── Graphics ─────────────────────────────────────────────────────────────
|
||||
graphicsTitle: 'Graphics',
|
||||
graphicsDescription: 'Bundle graphics controls and status.',
|
||||
graphicsNoConfigured: 'There are no graphics configured in this bundle.',
|
||||
@@ -161,19 +187,31 @@ const messages: Record<Locale, Translations> = {
|
||||
graphicsScoreboard: 'Scoreboard',
|
||||
graphicsCommentary: 'Commentary',
|
||||
graphicsSkinLabel: 'Skin',
|
||||
graphicsCopied: 'URL copied to clipboard',
|
||||
graphicsOpenBrowser: 'Open in browser',
|
||||
|
||||
// ── Commentary ───────────────────────────────────────────────────────────
|
||||
commentaryTitle: 'Commentary',
|
||||
commentaryCommentator1: 'Commentator #1',
|
||||
commentaryCommentator2: 'Commentator #2',
|
||||
commentaryTwitterText: '@Twitter / Text',
|
||||
commentaryTwitterMaxLength: 'Twitter character limit exceeded',
|
||||
commentaryTwitterInvalidChars: 'Invalid characters in Twitter text',
|
||||
commentarySwap: 'Swap commentators',
|
||||
commentaryClear: 'Clear commentary',
|
||||
|
||||
// ── Bracket ──────────────────────────────────────────────────────────────
|
||||
bracketTitle: 'Bracket',
|
||||
bracketStage: 'Stage',
|
||||
bracketSide: 'Bracket side',
|
||||
bracketCustomProgress: 'Custom progress',
|
||||
bracketPreview: 'Preview',
|
||||
|
||||
// ── Players ──────────────────────────────────────────────────────────────
|
||||
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.',
|
||||
playersStartggHelp: 'Connect via OAuth (recommended) or paste your personal token to load tournaments you created or administrate.',
|
||||
playersConnectStartgg: 'Connect with start.gg',
|
||||
playersConnected: 'Connected',
|
||||
playersUsePersonalApi: 'Use personal API',
|
||||
@@ -185,25 +223,18 @@ const messages: Record<Locale, Translations> = {
|
||||
playersSearchPlaceholder: 'Search...',
|
||||
playersImport: 'Import',
|
||||
playersExport: 'Export',
|
||||
commentaryTwitterMaxLength: 'Twitter character limit exceeded',
|
||||
commentaryTwitterInvalidChars: 'Invalid characters in Twitter text',
|
||||
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',
|
||||
playersConnectInSettings: 'Connect your account in',
|
||||
playersConnectInSettingsSuffix: 'to import players from tournaments.',
|
||||
},
|
||||
|
||||
es: {
|
||||
menuDashboard: 'Panel',
|
||||
menuPlayers: 'Jugadores',
|
||||
menuGraphics: 'Gráficos',
|
||||
menuSettings: 'Configuración',
|
||||
menuAbout: 'Acerca de',
|
||||
|
||||
// ── Settings ────────────────────────────────────────────────────────────
|
||||
settingsTitle: 'Configuración',
|
||||
settingsDescription: 'Configuración del dashboard y del bundle.',
|
||||
settingsLanguageLabel: 'Idioma',
|
||||
@@ -220,8 +251,20 @@ const messages: Record<Locale, Translations> = {
|
||||
settingsShortcutRightDecrementHint: 'Reduce en uno el score del jugador derecho.',
|
||||
settingsShortcutReset: 'Restablecer atajos',
|
||||
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',
|
||||
languageSpanish: 'Castellano',
|
||||
|
||||
// ── Scoreboard ───────────────────────────────────────────────────────────
|
||||
scoreboardUnassigned: '(Sin asignar)',
|
||||
scoreboardLeft: 'Izquierda',
|
||||
scoreboardRight: 'Derecha',
|
||||
@@ -233,6 +276,8 @@ const messages: Record<Locale, Translations> = {
|
||||
scoreboardLabelTeam: 'Equipo',
|
||||
scoreboardLabelCountry: 'País',
|
||||
scoreboardLabelGame: 'Juego',
|
||||
|
||||
// ── About ────────────────────────────────────────────────────────────────
|
||||
aboutTitle: 'Acerca de',
|
||||
aboutVersion: 'Versión',
|
||||
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.',
|
||||
aboutUnknownReleaseError: 'Error desconocido al consultar releases.',
|
||||
aboutGitHubStatusError: 'GitHub respondió con estado',
|
||||
aboutChangelog: 'Changelog',
|
||||
aboutTechStackTitle: 'Tech stack',
|
||||
|
||||
// ── Graphics ─────────────────────────────────────────────────────────────
|
||||
graphicsTitle: 'Gráficos',
|
||||
graphicsDescription: 'Controles y estado de los gráficos del bundle.',
|
||||
graphicsNoConfigured: 'No hay gráficos configurados en este bundle.',
|
||||
@@ -257,19 +306,31 @@ const messages: Record<Locale, Translations> = {
|
||||
graphicsScoreboard: 'Scoreboard',
|
||||
graphicsCommentary: 'Comentario',
|
||||
graphicsSkinLabel: 'Skin',
|
||||
graphicsCopied: 'URL copiada al portapapeles',
|
||||
graphicsOpenBrowser: 'Abrir en el navegador',
|
||||
|
||||
// ── Commentary ───────────────────────────────────────────────────────────
|
||||
commentaryTitle: 'Comentario',
|
||||
commentaryCommentator1: 'Comentarista #1',
|
||||
commentaryCommentator2: 'Comentarista #2',
|
||||
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',
|
||||
bracketStage: 'Etapa',
|
||||
bracketSide: 'Lado del bracket',
|
||||
bracketCustomProgress: 'Progreso personalizado',
|
||||
bracketPreview: 'Vista previa',
|
||||
|
||||
// ── Players ──────────────────────────────────────────────────────────────
|
||||
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.',
|
||||
playersStartggHelp: 'Conéctate por OAuth (recomendado) o pega tu token personal para cargar torneos que creaste o administras.',
|
||||
playersConnectStartgg: 'Conectar con start.gg',
|
||||
playersConnected: 'Conectado',
|
||||
playersUsePersonalApi: 'Usar API personal',
|
||||
@@ -281,28 +342,15 @@ const messages: Record<Locale, Translations> = {
|
||||
playersSearchPlaceholder: 'Buscar...',
|
||||
playersImport: 'Importar',
|
||||
playersExport: 'Exportar',
|
||||
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',
|
||||
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',
|
||||
playersConnectInSettings: 'Conecta tu cuenta en',
|
||||
playersConnectInSettingsSuffix: 'para importar jugadores desde torneos.',
|
||||
},
|
||||
};
|
||||
|
||||
const normalizeLocale = (value: unknown): Locale => (value === 'es' ? 'es' : 'en');
|
||||
|
||||
const getStoredLocale = (): Locale => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') return 'en';
|
||||
return normalizeLocale(localStorage.getItem(STORAGE_KEY));
|
||||
};
|
||||
|
||||
@@ -310,7 +358,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ const playersStore = usePlayersStore();
|
||||
const $q = useQuasar();
|
||||
const rows = computed<PlayerRow[]>(() => playersStore.rows);
|
||||
|
||||
// ─── Integraciones ─────────────────────────────────────────────────────────────
|
||||
// ─── Integraciones (solo se usa para importar torneos) ─────────────────────────
|
||||
|
||||
const startgg = useIntegration({
|
||||
messagePrefix: 'startgg',
|
||||
@@ -57,7 +57,6 @@ const challonge = useIntegration({
|
||||
playersStore,
|
||||
});
|
||||
|
||||
// Notifica errores de apertura del diálogo de importación (sustituye window.alert)
|
||||
watch(() => startgg.importDialogError, (msg) => {
|
||||
if (msg) $q.notify({ type: 'negative', message: msg });
|
||||
});
|
||||
@@ -83,12 +82,6 @@ const formatExpiresAt = (ts: number): string =>
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
// ─── Label de conexión de Challonge ───────────────────────────────────────────
|
||||
|
||||
const challongeConnectionLabel = computed(() =>
|
||||
challonge.hasValidatedToken ? t('playersConnected') : 'Token set',
|
||||
);
|
||||
|
||||
// ─── Tabla de jugadores ────────────────────────────────────────────────────────
|
||||
|
||||
const filter = ref('');
|
||||
@@ -152,7 +145,6 @@ const openCreateDialog = () => {
|
||||
|
||||
const openEditDialog = (row: PlayerRow) => {
|
||||
editingId.value = row.id;
|
||||
// CORRECCIÓN: evitar el patrón `void id` usando un alias _id
|
||||
const { id: _id, ...playerData } = row;
|
||||
Object.assign(form, playerData);
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
@@ -232,6 +196,8 @@ const handleImport = async (event: Event) => {
|
||||
|
||||
<template>
|
||||
<QPage class="q-pa-lg players-page">
|
||||
|
||||
<!-- ── Cabecera ────────────────────────────────────────────────────────── -->
|
||||
<div class="row items-center q-mb-md">
|
||||
<div class="text-h5 text-weight-medium">
|
||||
{{ t('menuPlayers') }}
|
||||
@@ -248,7 +214,9 @@ const handleImport = async (event: Event) => {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<QInput
|
||||
v-model="filter"
|
||||
@@ -262,19 +230,14 @@ const handleImport = async (event: Event) => {
|
||||
</template>
|
||||
</QInput>
|
||||
<span class="text-caption text-grey-6">{{ rows.length }} players</span>
|
||||
<QSpace />
|
||||
<QBtn
|
||||
color="secondary"
|
||||
outline
|
||||
icon="file_upload"
|
||||
no-caps
|
||||
color="secondary" outline icon="file_upload" no-caps
|
||||
:label="t('playersImport')"
|
||||
@click="triggerImport"
|
||||
/>
|
||||
<QBtn
|
||||
color="secondary"
|
||||
outline
|
||||
icon="file_download"
|
||||
no-caps
|
||||
color="secondary" outline icon="file_download" no-caps
|
||||
:label="t('playersExport')"
|
||||
@click="exportPlayers"
|
||||
/>
|
||||
@@ -286,9 +249,7 @@ const handleImport = async (event: Event) => {
|
||||
@change="handleImport"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-8 players-main-column">
|
||||
<QTable
|
||||
flat
|
||||
bordered
|
||||
@@ -328,238 +289,175 @@ const handleImport = async (event: Event) => {
|
||||
</QTooltip>
|
||||
</QChip>
|
||||
</div>
|
||||
<div
|
||||
v-if="row.name"
|
||||
class="text-caption text-grey-6"
|
||||
>
|
||||
<div v-if="row.name" class="text-caption text-grey-6">
|
||||
{{ row.name }}
|
||||
</div>
|
||||
</QTd>
|
||||
</template>
|
||||
<template #body-cell-actions="{ row }">
|
||||
<QTd align="right">
|
||||
<QBtn
|
||||
size="sm"
|
||||
flat
|
||||
icon="edit"
|
||||
@click="openEditDialog(row)"
|
||||
/>
|
||||
<QBtn
|
||||
size="sm"
|
||||
flat
|
||||
color="negative"
|
||||
icon="delete"
|
||||
@click="deletePlayer(row)"
|
||||
/>
|
||||
<QBtn size="sm" flat icon="edit" @click="openEditDialog(row)" />
|
||||
<QBtn size="sm" flat color="negative" icon="delete" @click="deletePlayer(row)" />
|
||||
</QTd>
|
||||
</template>
|
||||
</QTable>
|
||||
</div>
|
||||
|
||||
<!-- Panel de integraciones -->
|
||||
<div class="col-12 col-lg-4 players-startgg-column">
|
||||
<!-- ── Columna lateral: importar desde torneo ───────────────────────── -->
|
||||
<div class="col-12 col-lg-4 players-import-column">
|
||||
<div class="players-integrations-stack">
|
||||
|
||||
<!-- ── start.gg ─────────────────────────────────────────────────── -->
|
||||
<!-- start.gg -->
|
||||
<QCard flat bordered class="q-pa-md">
|
||||
<div class="text-h6 q-mb-sm startgg-heading">
|
||||
<svg class="startgg-heading__icon" viewBox="0 0 40 40" fill="none" aria-hidden="true">
|
||||
<div class="row items-center q-mb-xs q-gutter-x-sm">
|
||||
<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="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>
|
||||
<span>start.gg</span>
|
||||
</div>
|
||||
<div class="text-caption text-grey-6 q-mb-md">
|
||||
{{ t('playersStartggHelp') }}
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm items-center">
|
||||
<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
|
||||
<span class="text-subtitle2">start.gg</span>
|
||||
<QSpace />
|
||||
<QChip
|
||||
v-if="startgg.hasTokenConfigured"
|
||||
dense size="sm"
|
||||
:color="startgg.hasValidatedToken ? 'positive' : 'warning'"
|
||||
text-color="white"
|
||||
icon="sync"
|
||||
class="startgg-refresh-btn"
|
||||
:loading="startgg.loadingTournaments"
|
||||
@click="startgg.loadRecentTournaments"
|
||||
/>
|
||||
<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">
|
||||
<QBtn
|
||||
color="primary"
|
||||
unelevated
|
||||
round
|
||||
icon="person_add"
|
||||
:aria-label="t('playersImportPlayers')"
|
||||
@click="startgg.openSelectedTournamentImportDialog"
|
||||
>
|
||||
<QTooltip>{{ t('playersImportPlayers') }}</QTooltip>
|
||||
</QBtn>
|
||||
</div>
|
||||
>
|
||||
{{ t('playersConnected') }}
|
||||
</QChip>
|
||||
<QChip v-else dense size="sm" color="grey-7" text-color="white" icon="link_off">
|
||||
{{ t('settingsNotConnected') || 'Not connected' }}
|
||||
</QChip>
|
||||
</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>
|
||||
|
||||
<!-- ── Challonge ──────────────────────────────────────────────────── -->
|
||||
<!-- Challonge -->
|
||||
<QCard flat bordered class="q-pa-md">
|
||||
<div class="text-h6 q-mb-sm startgg-heading">
|
||||
<img
|
||||
class="challonge-heading__icon"
|
||||
src="https://challonge.com/favicon.ico"
|
||||
alt="Challonge"
|
||||
>
|
||||
<span>Challonge</span>
|
||||
</div>
|
||||
<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
|
||||
<div class="row items-center q-mb-xs q-gutter-x-sm">
|
||||
<img src="https://challonge.com/favicon.ico" alt="Challonge" style="width: 18px; height: 18px; border-radius: 4px; flex-shrink: 0;">
|
||||
<span class="text-subtitle2">Challonge</span>
|
||||
<QSpace />
|
||||
<QChip
|
||||
v-if="challonge.hasTokenConfigured"
|
||||
dense size="sm"
|
||||
:color="challonge.hasValidatedToken ? 'positive' : 'warning'"
|
||||
text-color="white"
|
||||
icon="sync"
|
||||
class="startgg-refresh-btn"
|
||||
:loading="challonge.loadingTournaments"
|
||||
@click="challonge.loadRecentTournaments"
|
||||
/>
|
||||
<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">
|
||||
<QBtn
|
||||
color="primary"
|
||||
unelevated
|
||||
round
|
||||
icon="person_add"
|
||||
:aria-label="t('playersImportPlayers')"
|
||||
@click="challonge.openSelectedTournamentImportDialog"
|
||||
>
|
||||
<QTooltip>{{ t('playersImportPlayers') }}</QTooltip>
|
||||
</QBtn>
|
||||
</div>
|
||||
>
|
||||
{{ challonge.hasValidatedToken ? t('playersConnected') : 'Token set' }}
|
||||
</QChip>
|
||||
<QChip v-else dense size="sm" color="grey-7" text-color="white" icon="link_off">
|
||||
{{ t('settingsNotConnected') || 'Not connected' }}
|
||||
</QChip>
|
||||
</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>
|
||||
|
||||
</div>
|
||||
@@ -570,9 +468,7 @@ const handleImport = async (event: Event) => {
|
||||
<QDialog v-model="startgg.importDialogOpen">
|
||||
<QCard class="players-dialog">
|
||||
<QCardSection>
|
||||
<div class="text-h6">
|
||||
Import from {{ startgg.importingTournament?.name || 'start.gg' }}
|
||||
</div>
|
||||
<div class="text-h6">Import from {{ startgg.importingTournament?.name || 'start.gg' }}</div>
|
||||
</QCardSection>
|
||||
<QSeparator />
|
||||
<QCardSection>
|
||||
@@ -617,9 +513,7 @@ const handleImport = async (event: Event) => {
|
||||
<QDialog v-model="challonge.importDialogOpen">
|
||||
<QCard class="players-dialog">
|
||||
<QCardSection>
|
||||
<div class="text-h6">
|
||||
Import from {{ challonge.importingTournament?.name || 'Challonge' }}
|
||||
</div>
|
||||
<div class="text-h6">Import from {{ challonge.importingTournament?.name || 'Challonge' }}</div>
|
||||
</QCardSection>
|
||||
<QSeparator />
|
||||
<QCardSection>
|
||||
@@ -660,65 +554,6 @@ const handleImport = async (event: Event) => {
|
||||
</QCard>
|
||||
</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 ─────────────────────────────────── -->
|
||||
<QDialog v-model="isDialogOpen">
|
||||
<QCard class="players-dialog">
|
||||
@@ -750,18 +585,11 @@ const handleImport = async (event: Event) => {
|
||||
v-model="form.country"
|
||||
v-model:input-value="countryInput"
|
||||
:options="filteredCountryOptions"
|
||||
option-value="value"
|
||||
option-label="label"
|
||||
emit-value
|
||||
map-options
|
||||
use-input
|
||||
input-debounce="0"
|
||||
hide-selected
|
||||
fill-input
|
||||
clearable
|
||||
option-value="value" option-label="label"
|
||||
emit-value map-options use-input input-debounce="0"
|
||||
hide-selected fill-input clearable
|
||||
:label="t('playersLabelCountry')"
|
||||
dense
|
||||
class="players-underlined-field"
|
||||
dense class="players-underlined-field"
|
||||
@filter="filterCountries"
|
||||
/>
|
||||
</div>
|
||||
@@ -781,6 +609,7 @@ const handleImport = async (event: Event) => {
|
||||
</QCardActions>
|
||||
</QCard>
|
||||
</QDialog>
|
||||
|
||||
</QPage>
|
||||
</template>
|
||||
|
||||
@@ -804,8 +633,8 @@ const handleImport = async (event: Event) => {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.players-startgg-column {
|
||||
min-width: 320px;
|
||||
.players-import-column {
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.players-integrations-stack {
|
||||
@@ -819,35 +648,14 @@ const handleImport = async (event: Event) => {
|
||||
width: min(720px, 90vw);
|
||||
}
|
||||
|
||||
.startgg-heading {
|
||||
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 {
|
||||
.players-tournament-row {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.startgg-refresh-btn:hover {
|
||||
.players-refresh-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.startgg-connected-btn {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.players-underlined-field :deep(.q-field__control) {
|
||||
min-height: 28px;
|
||||
padding: 0;
|
||||
@@ -861,10 +669,6 @@ const handleImport = async (event: Event) => {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.34);
|
||||
}
|
||||
|
||||
.manual-token-steps {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -875,4 +679,4 @@ const handleImport = async (event: Event) => {
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
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 { locale, setLocale, t } from '../i18n';
|
||||
import { usePlayersStore } from '../stores/players';
|
||||
import {
|
||||
eventToShortcut,
|
||||
type ShortcutAction,
|
||||
@@ -13,6 +16,8 @@ defineOptions({ name: 'SettingsView' });
|
||||
|
||||
useHead(() => ({ title: t('settingsTitle') }));
|
||||
|
||||
// ─── Idioma ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const languageOptions = computed(() => [
|
||||
{ label: t('languageSpanish'), value: 'es' as const },
|
||||
{ label: t('languageEnglish'), value: 'en' as const },
|
||||
@@ -20,15 +25,13 @@ const languageOptions = computed(() => [
|
||||
|
||||
const selectedLanguage = computed<Locale>({
|
||||
get: () => locale.value,
|
||||
set: (value) => {
|
||||
setLocale(value);
|
||||
},
|
||||
set: (value) => { setLocale(value); },
|
||||
});
|
||||
|
||||
// ─── Atajos de teclado ─────────────────────────────────────────────────────────
|
||||
|
||||
const shortcutSettingsStore = useShortcutSettingsStore();
|
||||
const recordingAction = ref<ShortcutAction | null>(null);
|
||||
|
||||
// Ref para detectar clicks fuera del contenedor de atajos
|
||||
const shortcutsContainerRef = ref<HTMLElement | null>(null);
|
||||
|
||||
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') },
|
||||
]);
|
||||
|
||||
// Detecta atajos duplicados entre acciones
|
||||
const conflictingActions = computed(() => {
|
||||
const seen = new Map<string, ShortcutAction>();
|
||||
const conflicts = new Set<ShortcutAction>();
|
||||
@@ -62,43 +64,24 @@ const stopRecording = () => {
|
||||
|
||||
const onRecordKeydown = (event: KeyboardEvent) => {
|
||||
if (!recordingAction.value) return;
|
||||
|
||||
// Escape cancela la grabación sin asignar ningún atajo
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
stopRecording();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') { event.preventDefault(); stopRecording(); return; }
|
||||
const shortcut = eventToShortcut(event);
|
||||
if (!shortcut) return;
|
||||
|
||||
event.preventDefault();
|
||||
shortcutSettingsStore.setShortcut(recordingAction.value, shortcut);
|
||||
stopRecording();
|
||||
};
|
||||
|
||||
// Click fuera del área de atajos también cancela la grabación
|
||||
const onDocumentMousedown = (event: MouseEvent) => {
|
||||
if (
|
||||
recordingAction.value &&
|
||||
shortcutsContainerRef.value &&
|
||||
!shortcutsContainerRef.value.contains(event.target as Node)
|
||||
) {
|
||||
if (recordingAction.value && shortcutsContainerRef.value && !shortcutsContainerRef.value.contains(event.target as Node)) {
|
||||
stopRecording();
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = (action: ShortcutAction) => {
|
||||
if (recordingAction.value === action) {
|
||||
stopRecording();
|
||||
return;
|
||||
}
|
||||
|
||||
if (recordingAction.value === action) { stopRecording(); return; }
|
||||
recordingAction.value = action;
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.dataset.shortcutRecording = 'true';
|
||||
}
|
||||
if (typeof document !== 'undefined') document.body.dataset.shortcutRecording = 'true';
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -113,6 +96,79 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -126,134 +182,324 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QCard
|
||||
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="column q-gutter-lg settings-layout">
|
||||
|
||||
<div class="text-caption text-grey-6 q-mt-sm">
|
||||
{{ t('settingsLanguageHint') }}
|
||||
</div>
|
||||
</QCardSection>
|
||||
|
||||
<QSeparator />
|
||||
|
||||
<!-- Shortcuts -->
|
||||
<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>
|
||||
|
||||
<!-- 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
|
||||
<!-- ── Idioma ──────────────────────────────────────────────────────────── -->
|
||||
<QCard flat bordered class="settings-card">
|
||||
<QCardSection class="q-pa-lg">
|
||||
<div class="text-overline text-grey-6 q-mb-md">{{ t('settingsLanguageLabel') }}</div>
|
||||
<QSelect
|
||||
v-model="selectedLanguage"
|
||||
emit-value
|
||||
map-options
|
||||
:options="languageOptions"
|
||||
:label="t('settingsLanguageLabel')"
|
||||
outlined
|
||||
dense
|
||||
bottom-slots
|
||||
:label="field.label"
|
||||
>
|
||||
<template #append>
|
||||
<!-- Botón grabar / detener -->
|
||||
<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)"
|
||||
/>
|
||||
style="max-width: 280px"
|
||||
/>
|
||||
<div class="text-caption text-grey-6 q-mt-sm">
|
||||
{{ t('settingsLanguageHint') }}
|
||||
</div>
|
||||
</QCardSection>
|
||||
</QCard>
|
||||
|
||||
<!-- Botón reset individual por atajo -->
|
||||
<QBtn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
icon="restart_alt"
|
||||
color="grey-5"
|
||||
:aria-label="t('settingsShortcutResetSingle')"
|
||||
@click="shortcutSettingsStore.resetShortcut(field.action)"
|
||||
>
|
||||
<QTooltip>{{ t('settingsShortcutResetSingle') }}</QTooltip>
|
||||
</QBtn>
|
||||
<!-- ── Integraciones ───────────────────────────────────────────────────── -->
|
||||
<QCard flat bordered class="settings-card">
|
||||
<QCardSection class="q-pa-lg">
|
||||
<div class="text-overline text-grey-6 q-mb-xs">{{ t('settingsIntegrationsTitle') || 'Integrations' }}</div>
|
||||
<div class="text-caption text-grey-6 q-mb-lg">
|
||||
{{ t('settingsIntegrationsDescription') || 'Connect your tournament platform accounts to import players directly from brackets.' }}
|
||||
</div>
|
||||
|
||||
<div class="column q-gutter-md">
|
||||
|
||||
<!-- start.gg -->
|
||||
<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>
|
||||
</QInput>
|
||||
</div>
|
||||
</QCardSection>
|
||||
</QCard>
|
||||
{{ t('settingsShortcutConflictWarning') }}
|
||||
</QBanner>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-card {
|
||||
max-width: 600px;
|
||||
.settings-layout {
|
||||
max-width: 680px;
|
||||
}
|
||||
</style>
|
||||
|
||||
.settings-card {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user