Files
scoreko-dev/src/dashboard/scoreko-dev/views/Players.vue
T

597 lines
16 KiB
Vue

<script setup lang="ts">
import { useHead } from '@unhead/vue';
defineOptions({ name: 'PlayersView' });
import type { QTableColumn } from 'quasar';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { countryOptions, getCountryLabel } from '../../../shared/countries';
import type { Schemas } from '../../../types';
import { usePlayersStore } from '../stores/players';
useHead({ title: 'Players' });
type PlayersMap = Schemas.Players;
type Player = PlayersMap[string];
interface PlayerRow extends Player {
id: string;
}
interface StartGGTournament {
id: number;
name: string;
slug: string;
startAt: number | null;
}
interface StartGGImportedPlayer extends Player {
id: string;
}
const STARTGG_TOKEN_STORAGE_KEY = 'scoreko-dev.startgg-token';
const playersStore = usePlayersStore();
const rows = computed<PlayerRow[]>(() => playersStore.rows);
const filter = ref('');
const isDialogOpen = ref(false);
const editingId = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const emptyPlayer: Player = {
gamertag: '',
name: '',
country: '',
team: '',
twitter: '',
};
const form = reactive<Player>({ ...emptyPlayer });
const columns: QTableColumn<PlayerRow>[] = [
{ name: 'gamertag', label: 'Gamertag', field: 'gamertag', sortable: true, align: 'left' },
{ name: 'team', label: 'Team', field: 'team', sortable: true, align: 'left' },
{
name: 'country',
label: 'Country',
field: (row) => getCountryLabel(row.country),
sortable: true,
align: 'left',
},
{ name: 'twitter', label: 'Twitter', field: 'twitter', sortable: true, align: 'left' },
{ name: 'actions', label: 'Actions', field: (row) => row.id, sortable: false, align: 'right' },
];
const filteredCountryOptions = ref(countryOptions);
const countryInput = ref('');
const filterCountries = (value: string, update: (callback: () => void) => void) => {
update(() => {
const needle = value.toLowerCase().trim();
if (!needle) {
filteredCountryOptions.value = countryOptions;
return;
}
filteredCountryOptions.value = countryOptions.filter((country) =>
country.label.toLowerCase().includes(needle),
);
});
};
watch(
() => form.country,
(value) => {
countryInput.value = getCountryLabel(value);
},
{ immediate: true },
);
const startGGToken = ref(localStorage.getItem(STARTGG_TOKEN_STORAGE_KEY) ?? '');
const recentTournaments = ref<StartGGTournament[]>([]);
const loadingTournaments = ref(false);
const tournamentsError = ref('');
const isImportDialogOpen = ref(false);
const loadingTournamentPlayers = ref(false);
const selectedTournament = ref<StartGGTournament | null>(null);
const startGGPlayers = ref<StartGGImportedPlayer[]>([]);
const selectedStartGGPlayerIds = ref<string[]>([]);
watch(startGGToken, (value) => {
localStorage.setItem(STARTGG_TOKEN_STORAGE_KEY, value);
});
const sendNodeCGMessage = <T>(messageName: string, payload: unknown): Promise<T> =>
new Promise((resolve, reject) => {
nodecg.sendMessage(messageName, payload, (error, response) => {
if (error) {
reject(new Error(String(error)));
return;
}
resolve(response as T);
});
});
const loadRecentTournaments = async () => {
const token = startGGToken.value.trim();
if (!token) {
tournamentsError.value = 'Añade tu token de start.gg para cargar torneos.';
recentTournaments.value = [];
return;
}
tournamentsError.value = '';
loadingTournaments.value = true;
try {
const tournaments = await sendNodeCGMessage<StartGGTournament[]>('startgg:fetchRecentTournaments', {
token,
});
recentTournaments.value = tournaments;
if (!tournaments.length) {
tournamentsError.value = 'No hay torneos recientes para esta cuenta.';
}
} catch (error) {
tournamentsError.value = error instanceof Error ? error.message : 'No se pudieron cargar torneos.';
recentTournaments.value = [];
} finally {
loadingTournaments.value = false;
}
};
const openStartGGImportDialog = async (tournament: StartGGTournament) => {
selectedTournament.value = tournament;
isImportDialogOpen.value = true;
loadingTournamentPlayers.value = true;
selectedStartGGPlayerIds.value = [];
startGGPlayers.value = [];
try {
const importedPlayers = await sendNodeCGMessage<StartGGImportedPlayer[]>('startgg:fetchTournamentPlayers', {
token: startGGToken.value.trim(),
slug: tournament.slug,
});
startGGPlayers.value = importedPlayers;
selectedStartGGPlayerIds.value = importedPlayers.map((player) => player.id);
} catch (error) {
const message = error instanceof Error ? error.message : 'No se pudieron cargar jugadores';
window.alert(message);
isImportDialogOpen.value = false;
} finally {
loadingTournamentPlayers.value = false;
}
};
const importSelectedStartGGPlayers = () => {
const selectedPlayers = startGGPlayers.value.filter((player) =>
selectedStartGGPlayerIds.value.includes(player.id),
);
selectedPlayers.forEach((player) => {
playersStore.upsertPlayer(player.id, {
gamertag: player.gamertag,
name: player.name,
team: player.team,
country: player.country,
twitter: player.twitter,
});
});
isImportDialogOpen.value = false;
};
const generateId = () => {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
const openCreateDialog = () => {
editingId.value = null;
Object.assign(form, emptyPlayer);
isDialogOpen.value = true;
};
const openEditDialog = (row: PlayerRow) => {
editingId.value = row.id;
const { id, ...playerData } = row;
void id;
Object.assign(form, playerData);
isDialogOpen.value = true;
};
const savePlayer = () => {
const id = editingId.value ?? generateId();
playersStore.upsertPlayer(id, { ...form });
isDialogOpen.value = false;
};
const deletePlayer = (row: PlayerRow) => {
const confirmed = window.confirm(`Delete ${row.gamertag || 'this player'}?`);
if (!confirmed) {
return;
}
playersStore.removePlayer(row.id);
};
const exportPlayers = () => {
const data = JSON.stringify(playersStore.players, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'players.json';
link.click();
URL.revokeObjectURL(url);
};
const triggerImport = () => {
fileInput.value?.click();
};
const handleImport = async (event: Event) => {
const target = event.target as HTMLInputElement | null;
const file = target?.files?.[0];
if (!file) {
return;
}
try {
const text = await file.text();
const parsed = JSON.parse(text) as unknown;
playersStore.setPlayers(parsed as PlayersMap);
} catch {
window.alert('Could not import JSON. Check the format.');
} finally {
if (target) {
target.value = '';
}
}
};
onMounted(() => {
if (startGGToken.value.trim()) {
void loadRecentTournaments();
}
});
</script>
<template>
<QPage class="q-pa-lg players-page">
<div class="row items-center q-mb-md">
<div class="text-h4">
Players
</div>
<QSpace />
<QBtn
color="primary"
icon="add"
label="New player"
class="q-ml-sm"
@click="openCreateDialog"
/>
</div>
<div class="players-content row q-col-gutter-md">
<div class="col players-main-column">
<div class="row items-center q-gutter-sm q-mb-md">
<QInput
v-model="filter"
dense
placeholder="Search..."
class="players-search players-underlined-field"
clearable
>
<template #prepend>
<QIcon name="search" />
</template>
</QInput>
<QBtn
color="secondary"
outline
icon="file_upload"
label="Import"
@click="triggerImport"
/>
<QBtn
color="secondary"
outline
icon="file_download"
label="Export"
@click="exportPlayers"
/>
<input
ref="fileInput"
type="file"
class="visually-hidden"
accept="application/json"
@change="handleImport"
>
</div>
<QTable
flat
bordered
row-key="id"
:rows="rows"
:columns="columns"
:filter="filter"
:rows-per-page-options="[10, 20, 50]"
>
<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)"
/>
</QTd>
</template>
</QTable>
</div>
<div class="col-12 col-lg-4 players-startgg-column">
<QCard
flat
bordered
class="q-pa-md"
>
<div class="text-h6 q-mb-sm">
Integración start.gg
</div>
<div class="text-caption q-mb-md">
Pega tu token personal de start.gg para cargar automáticamente tus torneos creados o donde eres admin.
</div>
<div class="row q-col-gutter-sm items-center">
<div class="col-12">
<QInput
v-model="startGGToken"
label="start.gg API Token"
dense
outlined
type="password"
/>
</div>
<div class="col-12">
<QBtn
color="secondary"
icon="sync"
label="Cargar torneos"
:loading="loadingTournaments"
@click="loadRecentTournaments"
/>
</div>
</div>
<div
v-if="tournamentsError"
class="text-negative q-mt-sm"
>
{{ tournamentsError }}
</div>
<QList
v-if="recentTournaments.length"
bordered
separator
class="q-mt-md startgg-tournaments-list"
>
<QItem
v-for="tournament in recentTournaments"
:key="tournament.id"
clickable
>
<QItemSection>
<QItemLabel>{{ tournament.name }}</QItemLabel>
<QItemLabel caption>
{{ tournament.slug }}
</QItemLabel>
</QItemSection>
<QItemSection side>
<QBtn
color="primary"
unelevated
label="Importar jugadores"
@click.stop="openStartGGImportDialog(tournament)"
/>
</QItemSection>
</QItem>
</QList>
</QCard>
</div>
</div>
<QDialog v-model="isImportDialogOpen">
<QCard class="players-dialog">
<QCardSection>
<div class="text-h6">
Importar desde {{ selectedTournament?.name || 'start.gg' }}
</div>
</QCardSection>
<QSeparator />
<QCardSection>
<div
v-if="loadingTournamentPlayers"
class="row items-center q-gutter-sm"
>
<QSpinner />
<span>Cargando inscritos...</span>
</div>
<div v-else>
<QOptionGroup
v-model="selectedStartGGPlayerIds"
type="checkbox"
:options="startGGPlayers.map((player) => ({
label: `${player.gamertag}${player.team ? ` (${player.team})` : ''}${player.country ? ` - ${getCountryLabel(player.country)}` : ''}`,
value: player.id,
}))"
/>
</div>
</QCardSection>
<QSeparator />
<QCardActions align="right">
<QBtn
flat
label="Cancelar"
color="secondary"
@click="isImportDialogOpen = false"
/>
<QBtn
color="primary"
label="Importar seleccionados"
:disable="!selectedStartGGPlayerIds.length"
@click="importSelectedStartGGPlayers"
/>
</QCardActions>
</QCard>
</QDialog>
<QDialog v-model="isDialogOpen">
<QCard class="players-dialog">
<QCardSection>
<div class="text-h6">
{{ editingId ? 'Edit player' : 'New player' }}
</div>
</QCardSection>
<QSeparator />
<QCardSection>
<QForm @submit.prevent="savePlayer">
<div class="row q-col-gutter-md">
<div class="col-12">
<QInput
v-model="form.gamertag"
label="Gamertag"
dense
class="players-underlined-field"
autofocus
/>
</div>
<div class="col-12">
<QInput
v-model="form.name"
label="Name"
dense
class="players-underlined-field"
/>
</div>
<div class="col-12">
<QSelect
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
label="Country"
dense
class="players-underlined-field"
@filter="filterCountries"
/>
</div>
<div class="col-12">
<QInput
v-model="form.team"
label="Team"
dense
class="players-underlined-field"
/>
</div>
<div class="col-12">
<QInput
v-model="form.twitter"
label="Twitter"
dense
class="players-underlined-field"
/>
</div>
</div>
</QForm>
</QCardSection>
<QSeparator />
<QCardActions align="right">
<QBtn
flat
label="Cancel"
color="secondary"
@click="isDialogOpen = false"
/>
<QBtn
color="primary"
label="Save"
@click="savePlayer"
/>
</QCardActions>
</QCard>
</QDialog>
</QPage>
</template>
<style scoped>
.players-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.players-search {
min-width: 240px;
}
.players-content {
align-items: flex-start;
}
.players-main-column {
min-width: 0;
flex: 1 1 auto;
}
.players-startgg-column {
min-width: 320px;
}
.players-dialog {
min-width: 320px;
width: min(720px, 90vw);
}
.startgg-tournaments-list {
max-height: 280px;
overflow: auto;
}
.players-underlined-field :deep(.q-field__control) {
min-height: 28px;
padding: 0;
background: transparent !important;
border-radius: 0;
}
.players-underlined-field :deep(.q-field__control:before),
.players-underlined-field :deep(.q-field__control:after) {
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.34);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
</style>