mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
597 lines
16 KiB
Vue
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>
|