mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
Add players replicant schema and dashboard CRUD (#5)
* Add players replicant and dashboard CRUD * Fix players table typing
This commit is contained in:
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -10,3 +10,4 @@ const thisBundle = 'nodecg-vue-ts-template';
|
|||||||
* For more information see https://github.com/Dan-Shields/nodecg-vue-composable
|
* For more information see https://github.com/Dan-Shields/nodecg-vue-composable
|
||||||
*/
|
*/
|
||||||
export const exampleReplicant = useReplicant<Schemas.ExampleReplicant>('exampleReplicant', thisBundle);
|
export const exampleReplicant = useReplicant<Schemas.ExampleReplicant>('exampleReplicant', thisBundle);
|
||||||
|
export const playersReplicant = useReplicant<Schemas.Players>('players', thisBundle);
|
||||||
|
|||||||
@@ -1,14 +1,376 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useHead } from '@unhead/vue';
|
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' });
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<QPage class="q-pa-lg">
|
<QPage class="q-pa-lg players-page">
|
||||||
<div class="text-h4 q-mb-md">Players</div>
|
<div class="row items-center q-mb-md">
|
||||||
<div class="text-body1">
|
<div class="text-h4">Players</div>
|
||||||
Gestión de jugadores y datos relacionados.
|
<QSpace />
|
||||||
|
<QBtn
|
||||||
|
color="primary"
|
||||||
|
icon="add"
|
||||||
|
label="Nuevo jugador"
|
||||||
|
class="q-ml-sm"
|
||||||
|
@click="openCreateDialog"
|
||||||
|
/>
|
||||||
</div>
|
</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>
|
</QPage>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ function hasNoDefault<T>(name: string) {
|
|||||||
* and to make sure they have any correct settings on startup.
|
* and to make sure they have any correct settings on startup.
|
||||||
*/
|
*/
|
||||||
export const exampleReplicant = hasDefault<Schemas.ExampleReplicant>('exampleReplicant');
|
export const exampleReplicant = hasDefault<Schemas.ExampleReplicant>('exampleReplicant');
|
||||||
|
export const playersReplicant = hasDefault<Schemas.Players>('players');
|
||||||
|
|||||||
Vendored
+1
@@ -6,3 +6,4 @@
|
|||||||
|
|
||||||
export type { Configschema } from './schemas/configschema.d.ts';
|
export type { Configschema } from './schemas/configschema.d.ts';
|
||||||
export type { ExampleReplicant } from './schemas/exampleReplicant.d.ts';
|
export type { ExampleReplicant } from './schemas/exampleReplicant.d.ts';
|
||||||
|
export type { Players } from './schemas/players.d.ts';
|
||||||
|
|||||||
Vendored
+20
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user