Add players replicant schema and dashboard CRUD (#5)

* Add players replicant and dashboard CRUD

* Fix players table typing
This commit is contained in:
Pandipipas
2026-02-08 02:05:18 +01:00
committed by GitHub
parent ddb877adf5
commit 5f13143586
6 changed files with 442 additions and 4 deletions
+53
View File
@@ -0,0 +1,53 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"properties": {
"gamertag": {
"type": "string",
"default": ""
},
"team": {
"type": "string",
"default": ""
},
"country": {
"type": "string",
"default": ""
},
"twitter": {
"type": "string",
"default": ""
},
"realName": {
"type": "string",
"default": ""
},
"pronouns": {
"type": "string",
"default": ""
},
"twitch": {
"type": "string",
"default": ""
},
"notes": {
"type": "string",
"default": ""
}
},
"required": [
"gamertag",
"team",
"country",
"twitter",
"realName",
"pronouns",
"twitch",
"notes"
]
},
"default": {}
}
+1
View File
@@ -10,3 +10,4 @@ const thisBundle = 'nodecg-vue-ts-template';
* For more information see https://github.com/Dan-Shields/nodecg-vue-composable
*/
export const exampleReplicant = useReplicant<Schemas.ExampleReplicant>('exampleReplicant', thisBundle);
export const playersReplicant = useReplicant<Schemas.Players>('players', thisBundle);
+366 -4
View File
@@ -1,14 +1,376 @@
<script setup lang="ts">
import { useHead } from '@unhead/vue';
import type { QTableColumn } from 'quasar';
import { computed, reactive, ref } from 'vue';
import type { Ref } from 'vue';
import { playersReplicant } from '../../../browser_shared/replicants';
import type { Schemas } from '../../../types';
useHead({ title: 'Players' });
type PlayersMap = Schemas.Players;
type Player = PlayersMap[string];
interface PlayerRow extends Player {
id: string;
}
const playersData = computed<PlayersMap>({
get: () => {
const dataRef = playersReplicant?.data as unknown as Ref<PlayersMap | undefined> | undefined;
return dataRef?.value ?? {};
},
set: (value) => {
const dataRef = playersReplicant?.data as unknown as Ref<PlayersMap | undefined> | undefined;
if (dataRef) {
dataRef.value = value;
}
},
});
const rows = computed<PlayerRow[]>(() => Object.entries(playersData.value).map(([id, player]) => ({
id,
...player,
})));
const filter = ref('');
const isDialogOpen = ref(false);
const editingId = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const emptyPlayer: Player = {
gamertag: '',
team: '',
country: '',
twitter: '',
realName: '',
pronouns: '',
twitch: '',
notes: '',
};
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: '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 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: _id, ...playerData } = row;
Object.assign(form, playerData);
isDialogOpen.value = true;
};
const savePlayer = () => {
if (!playersReplicant?.data) {
return;
}
const id = editingId.value ?? generateId();
playersData.value = {
...playersData.value,
[id]: { ...form },
};
isDialogOpen.value = false;
};
const deletePlayer = (row: PlayerRow) => {
if (!playersReplicant?.data) {
return;
}
const confirmed = window.confirm(`¿Eliminar a ${row.gamertag || 'este jugador'}?`);
if (!confirmed) {
return;
}
const next = { ...playersData.value };
delete next[row.id];
playersData.value = next;
};
const normalizePlayer = (input: unknown): Player => {
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
return {
gamertag: typeof candidate.gamertag === 'string' ? candidate.gamertag : '',
team: typeof candidate.team === 'string' ? candidate.team : '',
country: typeof candidate.country === 'string' ? candidate.country : '',
twitter: typeof candidate.twitter === 'string' ? candidate.twitter : '',
realName: typeof candidate.realName === 'string' ? candidate.realName : '',
pronouns: typeof candidate.pronouns === 'string' ? candidate.pronouns : '',
twitch: typeof candidate.twitch === 'string' ? candidate.twitch : '',
notes: typeof candidate.notes === 'string' ? candidate.notes : '',
};
};
const normalizePlayers = (input: unknown): PlayersMap => {
if (typeof input !== 'object' || input === null) {
return {};
}
const result: PlayersMap = {};
Object.entries(input as Record<string, unknown>).forEach(([id, value]) => {
if (!id) {
return;
}
result[id] = normalizePlayer(value);
});
return result;
};
const exportPlayers = () => {
const data = JSON.stringify(playersData.value, 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) => {
if (!playersReplicant?.data) {
return;
}
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;
playersData.value = normalizePlayers(parsed);
} catch (error) {
window.alert('No se pudo importar el JSON. Verifica el formato.');
} finally {
if (target) {
target.value = '';
}
}
};
</script>
<template>
<QPage class="q-pa-lg">
<div class="text-h4 q-mb-md">Players</div>
<div class="text-body1">
Gestión de jugadores y datos relacionados.
<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="Nuevo jugador"
class="q-ml-sm"
@click="openCreateDialog"
/>
</div>
<div class="row items-center q-gutter-sm q-mb-md">
<QInput
v-model="filter"
dense
outlined
placeholder="Buscar..."
class="players-search"
clearable
>
<template #prepend>
<QIcon name="search" />
</template>
</QInput>
<QBtn
color="secondary"
outline
icon="file_upload"
label="Importar JSON"
@click="triggerImport"
/>
<QBtn
color="secondary"
outline
icon="file_download"
label="Exportar JSON"
@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>
<QDialog v-model="isDialogOpen">
<QCard class="players-dialog">
<QCardSection>
<div class="text-h6">
{{ editingId ? 'Editar jugador' : 'Nuevo jugador' }}
</div>
</QCardSection>
<QSeparator />
<QCardSection>
<QForm @submit.prevent="savePlayer">
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<QInput
v-model="form.gamertag"
label="Gamertag"
dense
outlined
autofocus
/>
</div>
<div class="col-12 col-md-6">
<QInput
v-model="form.team"
label="Team"
dense
outlined
/>
</div>
<div class="col-12 col-md-6">
<QInput
v-model="form.country"
label="Country"
dense
outlined
/>
</div>
<div class="col-12 col-md-6">
<QInput
v-model="form.twitter"
label="Twitter"
dense
outlined
/>
</div>
<div class="col-12 col-md-6">
<QInput
v-model="form.realName"
label="Nombre real"
dense
outlined
/>
</div>
<div class="col-12 col-md-6">
<QInput
v-model="form.pronouns"
label="Pronombres"
dense
outlined
/>
</div>
<div class="col-12 col-md-6">
<QInput
v-model="form.twitch"
label="Twitch"
dense
outlined
/>
</div>
<div class="col-12">
<QInput
v-model="form.notes"
type="textarea"
label="Notas"
dense
outlined
autogrow
/>
</div>
</div>
</QForm>
</QCardSection>
<QSeparator />
<QCardActions align="right">
<QBtn
flat
label="Cancelar"
color="secondary"
@click="isDialogOpen = false"
/>
<QBtn
color="primary"
label="Guardar"
@click="savePlayer"
/>
</QCardActions>
</QCard>
</QDialog>
</QPage>
</template>
<style scoped>
.players-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.players-search {
min-width: 240px;
}
.players-dialog {
min-width: 320px;
width: min(720px, 90vw);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
</style>
+1
View File
@@ -17,3 +17,4 @@ function hasNoDefault<T>(name: string) {
* and to make sure they have any correct settings on startup.
*/
export const exampleReplicant = hasDefault<Schemas.ExampleReplicant>('exampleReplicant');
export const playersReplicant = hasDefault<Schemas.Players>('players');
+1
View File
@@ -6,3 +6,4 @@
export type { Configschema } from './schemas/configschema.d.ts';
export type { ExampleReplicant } from './schemas/exampleReplicant.d.ts';
export type { Players } from './schemas/players.d.ts';
+20
View File
@@ -0,0 +1,20 @@
/* prettier-ignore */
/* eslint-disable */
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/
export interface Players {
[k: string]: {
gamertag: string;
team: string;
country: string;
twitter: string;
realName: string;
pronouns: string;
twitch: string;
notes: string;
};
}