Add Pinia persistence and scoreboard support (#15)

* Add scoreboard replicant and Pinia persistence

* Fix scoreboard replicant sync (#16)
This commit is contained in:
Pandipipas
2026-02-08 17:00:31 +01:00
committed by GitHub
parent e0323cca3f
commit b4a110fd1e
16 changed files with 712 additions and 64 deletions
+3
View File
@@ -1,6 +1,7 @@
import '@quasar/extras/material-icons/material-icons.css';
import '@quasar/extras/roboto-font/roboto-font.css';
import { createHead } from '@unhead/vue/client';
import { createPinia } from 'pinia';
import { Dark, Quasar } from 'quasar';
import 'quasar/src/css/index.sass';
import { createApp } from 'vue';
@@ -9,8 +10,10 @@ import router from './router';
const app = createApp(App);
const head = createHead();
const pinia = createPinia();
app.use(Quasar);
app.use(head);
app.use(router);
app.use(pinia);
app.mount('#app');
Dark.set(true);
+1
View File
@@ -7,6 +7,7 @@ const route = useRoute();
const menuItems = [
{ label: 'Dashboard', to: '/', icon: 'dashboard' },
{ label: 'Players', to: '/players', icon: 'groups' },
{ label: 'Scoreboard', to: '/scoreboard', icon: 'scoreboard' },
{ label: 'Graphics', to: '/graphics', icon: 'collections' },
{ label: 'Settings', to: '/settings', icon: 'settings' },
{ label: 'About', to: '/about', icon: 'info' },
+2
View File
@@ -3,6 +3,7 @@ import AboutView from './views/About.vue';
import DashboardView from './views/Dashboard.vue';
import GraphicsView from './views/Graphics.vue';
import PlayersView from './views/Players.vue';
import ScoreboardView from './views/Scoreboard.vue';
import SettingsView from './views/Settings.vue';
const router = createRouter({
@@ -10,6 +11,7 @@ const router = createRouter({
routes: [
{ path: '/', name: 'dashboard', component: DashboardView },
{ path: '/players', name: 'players', component: PlayersView },
{ path: '/scoreboard', name: 'scoreboard', component: ScoreboardView },
{ path: '/graphics', name: 'graphics', component: GraphicsView },
{ path: '/settings', name: 'settings', component: SettingsView },
{ path: '/about', name: 'about', component: AboutView },
+132
View File
@@ -0,0 +1,132 @@
import { defineStore } from 'pinia';
import { computed, ref, watch } from 'vue';
import type { Ref } from 'vue';
import { playersReplicant } from '../../../browser_shared/replicants';
import type { Schemas } from '../../../types';
type PlayersMap = Schemas.Players;
type Player = PlayersMap[string];
const STORAGE_KEY = 'scoreko-dev.players';
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 readStorage = (): PlayersMap | null => {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as unknown;
return normalizePlayers(parsed);
} catch {
return null;
}
};
const writeStorage = (value: PlayersMap) => {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
} catch {
// Ignore storage errors (quota, disabled, etc.)
}
};
export const usePlayersStore = defineStore('players', () => {
const players = ref<PlayersMap>({});
const replicantRef = playersReplicant?.data as unknown as Ref<PlayersMap | undefined> | undefined;
const storageSnapshot = readStorage();
if (storageSnapshot) {
players.value = storageSnapshot;
}
const isApplyingReplicant = ref(false);
watch(
() => replicantRef?.value,
(value) => {
if (!value) {
return;
}
isApplyingReplicant.value = true;
players.value = normalizePlayers(value);
isApplyingReplicant.value = false;
writeStorage(players.value);
},
{ deep: true, immediate: true }
);
watch(
players,
(value) => {
writeStorage(value);
if (isApplyingReplicant.value || !replicantRef) {
return;
}
replicantRef.value = normalizePlayers(value);
},
{ deep: true }
);
const setPlayers = (value: PlayersMap) => {
players.value = normalizePlayers(value);
};
const upsertPlayer = (id: string, player: Player) => {
players.value = {
...players.value,
[id]: normalizePlayer(player),
};
};
const removePlayer = (id: string) => {
const next = { ...players.value };
delete next[id];
players.value = next;
};
const rows = computed(() => Object.entries(players.value).map(([id, player]) => ({
id,
...player,
})));
return {
players,
rows,
setPlayers,
upsertPlayer,
removePlayer,
};
});
+149
View File
@@ -0,0 +1,149 @@
import { defineStore } from 'pinia';
import { computed, ref, watch } from 'vue';
import type { Ref } from 'vue';
import { scoreboardReplicant } from '../../../browser_shared/replicants';
import type { Schemas } from '../../../types';
type Scoreboard = Schemas.Scoreboard;
const STORAGE_KEY = 'scoreko-dev.scoreboard';
const defaultScoreboard: Scoreboard = {
leftPlayerId: '',
rightPlayerId: '',
leftNameOverride: '',
rightNameOverride: '',
leftScore: 0,
rightScore: 0,
round: '',
};
const normalizeScoreboard = (input: unknown): Scoreboard => {
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
return {
leftPlayerId: typeof candidate.leftPlayerId === 'string' ? candidate.leftPlayerId : '',
rightPlayerId: typeof candidate.rightPlayerId === 'string' ? candidate.rightPlayerId : '',
leftNameOverride: typeof candidate.leftNameOverride === 'string' ? candidate.leftNameOverride : '',
rightNameOverride: typeof candidate.rightNameOverride === 'string' ? candidate.rightNameOverride : '',
leftScore: typeof candidate.leftScore === 'number' ? Math.max(0, Math.floor(candidate.leftScore)) : 0,
rightScore: typeof candidate.rightScore === 'number' ? Math.max(0, Math.floor(candidate.rightScore)) : 0,
round: typeof candidate.round === 'string' ? candidate.round : '',
};
};
const readStorage = (): Scoreboard | null => {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as unknown;
return normalizeScoreboard(parsed);
} catch {
return null;
}
};
const writeStorage = (value: Scoreboard) => {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
} catch {
// Ignore storage errors (quota, disabled, etc.)
}
};
export const useScoreboardStore = defineStore('scoreboard', () => {
const scoreboard = ref<Scoreboard>({ ...defaultScoreboard });
const replicantRef = scoreboardReplicant?.data as unknown as Ref<Scoreboard | undefined> | undefined;
const storageSnapshot = readStorage();
if (storageSnapshot) {
scoreboard.value = storageSnapshot;
}
const isApplyingReplicant = ref(false);
watch(
() => replicantRef?.value,
(value) => {
if (!value) {
return;
}
isApplyingReplicant.value = true;
scoreboard.value = normalizeScoreboard(value);
writeStorage(scoreboard.value);
isApplyingReplicant.value = false;
},
{ deep: true, immediate: true }
);
watch(
scoreboard,
(value) => {
writeStorage(value);
if (isApplyingReplicant.value || !replicantRef) {
return;
}
replicantRef.value = normalizeScoreboard(value);
},
{ deep: true, flush: 'sync' }
);
const setScoreboard = (value: Scoreboard) => {
scoreboard.value = normalizeScoreboard(value);
};
const swapPlayers = () => {
scoreboard.value = {
...scoreboard.value,
leftPlayerId: scoreboard.value.rightPlayerId,
rightPlayerId: scoreboard.value.leftPlayerId,
leftNameOverride: scoreboard.value.rightNameOverride,
rightNameOverride: scoreboard.value.leftNameOverride,
leftScore: scoreboard.value.rightScore,
rightScore: scoreboard.value.leftScore,
};
};
const resetScores = () => {
scoreboard.value = {
...scoreboard.value,
leftScore: 0,
rightScore: 0,
};
};
const leftScore = computed({
get: () => scoreboard.value.leftScore,
set: (value: number) => {
scoreboard.value = {
...scoreboard.value,
leftScore: Math.max(0, Math.floor(value)),
};
},
});
const rightScore = computed({
get: () => scoreboard.value.rightScore,
set: (value: number) => {
scoreboard.value = {
...scoreboard.value,
rightScore: Math.max(0, Math.floor(value)),
};
},
});
return {
scoreboard,
leftScore,
rightScore,
setScoreboard,
swapPlayers,
resetScores,
};
});
+7 -64
View File
@@ -2,9 +2,8 @@
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';
import { usePlayersStore } from '../stores/players';
useHead({ title: 'Players' });
@@ -15,22 +14,8 @@ 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 playersStore = usePlayersStore();
const rows = computed<PlayerRow[]>(() => playersStore.rows);
const filter = ref('');
const isDialogOpen = ref(false);
@@ -79,60 +64,21 @@ const openEditDialog = (row: PlayerRow) => {
};
const savePlayer = () => {
if (!playersReplicant?.data) {
return;
}
const id = editingId.value ?? generateId();
playersData.value = {
...playersData.value,
[id]: { ...form },
};
playersStore.upsertPlayer(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;
playersStore.removePlayer(row.id);
};
const exportPlayers = () => {
const data = JSON.stringify(playersData.value, null, 2);
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');
@@ -147,9 +93,6 @@ const triggerImport = () => {
};
const handleImport = async (event: Event) => {
if (!playersReplicant?.data) {
return;
}
const target = event.target as HTMLInputElement | null;
const file = target?.files?.[0];
if (!file) {
@@ -158,7 +101,7 @@ const handleImport = async (event: Event) => {
try {
const text = await file.text();
const parsed = JSON.parse(text) as unknown;
playersData.value = normalizePlayers(parsed);
playersStore.setPlayers(parsed as PlayersMap);
} catch (error) {
window.alert('No se pudo importar el JSON. Verifica el formato.');
} finally {
+143
View File
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { useHead } from '@unhead/vue';
import { computed } from 'vue';
import type { Schemas } from '../../../types';
import { usePlayersStore } from '../stores/players';
import { useScoreboardStore } from '../stores/scoreboard';
useHead({ title: 'Scoreboard' });
const playersStore = usePlayersStore();
const scoreboardStore = useScoreboardStore();
const playerOptions = computed(() => {
const base = [{ label: '(Sin asignar)', value: '' }];
const entries = Object.entries(playersStore.players) as [string, Schemas.Players[string]][];
const options = entries.map(([id, player]) => ({
value: id,
label: player.gamertag || id,
}));
return base.concat(options);
});
</script>
<template>
<QPage class="q-pa-lg scoreboard-page">
<div class="row items-center q-mb-md">
<div class="text-h4">Scoreboard</div>
<QSpace />
<QBtn
color="secondary"
outline
icon="swap_horiz"
label="Intercambiar lados"
class="q-mr-sm"
@click="scoreboardStore.swapPlayers"
/>
<QBtn
color="secondary"
outline
icon="restart_alt"
label="Reset scores"
@click="scoreboardStore.resetScores"
/>
</div>
<div class="row q-col-gutter-lg">
<div class="col-12 col-md-6">
<QCard flat bordered>
<QCardSection>
<div class="text-subtitle1 text-weight-bold">Lado izquierdo</div>
</QCardSection>
<QSeparator />
<QCardSection>
<QSelect
v-model="scoreboardStore.scoreboard.leftPlayerId"
:options="playerOptions"
label="Jugador"
dense
outlined
emit-value
map-options
/>
<QInput
v-model="scoreboardStore.scoreboard.leftNameOverride"
label="Nombre override"
dense
outlined
class="q-mt-md"
/>
<QInput
v-model.number="scoreboardStore.leftScore"
type="number"
label="Score"
dense
outlined
class="q-mt-md"
min="0"
/>
</QCardSection>
</QCard>
</div>
<div class="col-12 col-md-6">
<QCard flat bordered>
<QCardSection>
<div class="text-subtitle1 text-weight-bold">Lado derecho</div>
</QCardSection>
<QSeparator />
<QCardSection>
<QSelect
v-model="scoreboardStore.scoreboard.rightPlayerId"
:options="playerOptions"
label="Jugador"
dense
outlined
emit-value
map-options
/>
<QInput
v-model="scoreboardStore.scoreboard.rightNameOverride"
label="Nombre override"
dense
outlined
class="q-mt-md"
/>
<QInput
v-model.number="scoreboardStore.rightScore"
type="number"
label="Score"
dense
outlined
class="q-mt-md"
min="0"
/>
</QCardSection>
</QCard>
</div>
</div>
<QCard flat bordered class="q-mt-lg">
<QCardSection>
<div class="text-subtitle1 text-weight-bold">Detalles de la ronda</div>
</QCardSection>
<QSeparator />
<QCardSection>
<QInput
v-model="scoreboardStore.scoreboard.round"
label="Ronda (ej. Winners Finals)"
dense
outlined
/>
</QCardSection>
</QCard>
</QPage>
</template>
<style scoped>
.scoreboard-page {
display: flex;
flex-direction: column;
gap: 16px;
}
</style>