From b4a110fd1e0cfb1592a1ae3ab72e4285e536ca0d Mon Sep 17 00:00:00 2001 From: Pandipipas <62224708+Pandipipas@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:00:31 +0100 Subject: [PATCH] Add Pinia persistence and scoreboard support (#15) * Add scoreboard replicant and Pinia persistence * Fix scoreboard replicant sync (#16) --- package-lock.json | 51 +++++++ package.json | 6 + schemas/scoreboard.json | 55 ++++++++ src/browser_shared/replicants.ts | 1 + src/dashboard/example/main.ts | 3 + src/dashboard/example/main.vue | 1 + src/dashboard/example/router.ts | 2 + src/dashboard/example/stores/players.ts | 132 ++++++++++++++++++ src/dashboard/example/stores/scoreboard.ts | 149 +++++++++++++++++++++ src/dashboard/example/views/Players.vue | 71 +--------- src/dashboard/example/views/Scoreboard.vue | 143 ++++++++++++++++++++ src/extension/util/replicants.ts | 1 + src/graphics/scoreboard/main.ts | 8 ++ src/graphics/scoreboard/main.vue | 135 +++++++++++++++++++ src/types/schemas.d.ts | 1 + src/types/schemas/scoreboard.d.ts | 17 +++ 16 files changed, 712 insertions(+), 64 deletions(-) create mode 100644 schemas/scoreboard.json create mode 100644 src/dashboard/example/stores/players.ts create mode 100644 src/dashboard/example/stores/scoreboard.ts create mode 100644 src/dashboard/example/views/Scoreboard.vue create mode 100644 src/graphics/scoreboard/main.ts create mode 100644 src/graphics/scoreboard/main.vue create mode 100644 src/types/schemas/scoreboard.d.ts diff --git a/package-lock.json b/package-lock.json index b6185cc..1a08218 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "eslint-plugin-vue": "^10.5.1", "nodecg": "^2.6.1", "nodecg-vue-composable": "^1.1.0", + "pinia": "^2.3.1", "quasar": "^2.18.5", "sass-embedded": "^1.93.3", "trash-cli": "^7.0.0", @@ -9071,6 +9072,29 @@ "node": ">=0.10.0" } }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/pinkie": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", @@ -11794,6 +11818,33 @@ } } }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/vue-eslint-parser": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz", diff --git a/package.json b/package.json index 057359f..55afd89 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "eslint-plugin-vue": "^10.5.1", "nodecg": "^2.6.1", "nodecg-vue-composable": "^1.1.0", + "pinia": "^2.3.1", "quasar": "^2.18.5", "sass-embedded": "^1.93.3", "trash-cli": "^7.0.0", @@ -69,6 +70,11 @@ "file": "example/main.html", "width": 1920, "height": 1080 + }, + { + "file": "scoreboard/main.html", + "width": 1920, + "height": 1080 } ] }, diff --git a/schemas/scoreboard.json b/schemas/scoreboard.json new file mode 100644 index 0000000..97049fa --- /dev/null +++ b/schemas/scoreboard.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "leftPlayerId": { + "type": "string", + "default": "" + }, + "rightPlayerId": { + "type": "string", + "default": "" + }, + "leftNameOverride": { + "type": "string", + "default": "" + }, + "rightNameOverride": { + "type": "string", + "default": "" + }, + "leftScore": { + "type": "integer", + "default": 0, + "minimum": 0 + }, + "rightScore": { + "type": "integer", + "default": 0, + "minimum": 0 + }, + "round": { + "type": "string", + "default": "" + } + }, + "required": [ + "leftPlayerId", + "rightPlayerId", + "leftNameOverride", + "rightNameOverride", + "leftScore", + "rightScore", + "round" + ], + "default": { + "leftPlayerId": "", + "rightPlayerId": "", + "leftNameOverride": "", + "rightNameOverride": "", + "leftScore": 0, + "rightScore": 0, + "round": "" + } +} diff --git a/src/browser_shared/replicants.ts b/src/browser_shared/replicants.ts index 4d97b39..667914d 100644 --- a/src/browser_shared/replicants.ts +++ b/src/browser_shared/replicants.ts @@ -11,3 +11,4 @@ const thisBundle = 'scoreko-dev'; */ export const exampleReplicant = useReplicant('exampleReplicant', thisBundle); export const playersReplicant = useReplicant('players', thisBundle); +export const scoreboardReplicant = useReplicant('scoreboard', thisBundle); diff --git a/src/dashboard/example/main.ts b/src/dashboard/example/main.ts index e31e9c7..da4a48e 100644 --- a/src/dashboard/example/main.ts +++ b/src/dashboard/example/main.ts @@ -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); diff --git a/src/dashboard/example/main.vue b/src/dashboard/example/main.vue index e41b596..1579589 100644 --- a/src/dashboard/example/main.vue +++ b/src/dashboard/example/main.vue @@ -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' }, diff --git a/src/dashboard/example/router.ts b/src/dashboard/example/router.ts index dd90558..bff8157 100644 --- a/src/dashboard/example/router.ts +++ b/src/dashboard/example/router.ts @@ -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 }, diff --git a/src/dashboard/example/stores/players.ts b/src/dashboard/example/stores/players.ts new file mode 100644 index 0000000..bfa1bf5 --- /dev/null +++ b/src/dashboard/example/stores/players.ts @@ -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) : {}; + 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).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({}); + const replicantRef = playersReplicant?.data as unknown as Ref | 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, + }; +}); diff --git a/src/dashboard/example/stores/scoreboard.ts b/src/dashboard/example/stores/scoreboard.ts new file mode 100644 index 0000000..5315743 --- /dev/null +++ b/src/dashboard/example/stores/scoreboard.ts @@ -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) : {}; + 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({ ...defaultScoreboard }); + const replicantRef = scoreboardReplicant?.data as unknown as Ref | 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, + }; +}); diff --git a/src/dashboard/example/views/Players.vue b/src/dashboard/example/views/Players.vue index 997132b..a134dd6 100644 --- a/src/dashboard/example/views/Players.vue +++ b/src/dashboard/example/views/Players.vue @@ -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({ - get: () => { - const dataRef = playersReplicant?.data as unknown as Ref | undefined; - return dataRef?.value ?? {}; - }, - set: (value) => { - const dataRef = playersReplicant?.data as unknown as Ref | undefined; - if (dataRef) { - dataRef.value = value; - } - }, -}); -const rows = computed(() => Object.entries(playersData.value).map(([id, player]) => ({ - id, - ...player, -}))); +const playersStore = usePlayersStore(); +const rows = computed(() => 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) : {}; - 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).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 { diff --git a/src/dashboard/example/views/Scoreboard.vue b/src/dashboard/example/views/Scoreboard.vue new file mode 100644 index 0000000..f11f63d --- /dev/null +++ b/src/dashboard/example/views/Scoreboard.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/src/extension/util/replicants.ts b/src/extension/util/replicants.ts index 6cbefff..1912f1a 100644 --- a/src/extension/util/replicants.ts +++ b/src/extension/util/replicants.ts @@ -18,3 +18,4 @@ function hasNoDefault(name: string) { */ export const exampleReplicant = hasDefault('exampleReplicant'); export const playersReplicant = hasDefault('players'); +export const scoreboardReplicant = hasDefault('scoreboard'); diff --git a/src/graphics/scoreboard/main.ts b/src/graphics/scoreboard/main.ts new file mode 100644 index 0000000..4367db3 --- /dev/null +++ b/src/graphics/scoreboard/main.ts @@ -0,0 +1,8 @@ +import { createHead } from '@unhead/vue/client'; +import { createApp } from 'vue'; +import App from './main.vue'; + +const app = createApp(App); +const head = createHead(); +app.use(head); +app.mount('#app'); diff --git a/src/graphics/scoreboard/main.vue b/src/graphics/scoreboard/main.vue new file mode 100644 index 0000000..52fc4db --- /dev/null +++ b/src/graphics/scoreboard/main.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/src/types/schemas.d.ts b/src/types/schemas.d.ts index 00eadf8..e1dc2ec 100644 --- a/src/types/schemas.d.ts +++ b/src/types/schemas.d.ts @@ -7,3 +7,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'; +export type { Scoreboard } from './schemas/scoreboard.d.ts'; diff --git a/src/types/schemas/scoreboard.d.ts b/src/types/schemas/scoreboard.d.ts new file mode 100644 index 0000000..ec1fcf8 --- /dev/null +++ b/src/types/schemas/scoreboard.d.ts @@ -0,0 +1,17 @@ +/* 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 Scoreboard { + leftPlayerId: string; + rightPlayerId: string; + leftNameOverride: string; + rightNameOverride: string; + leftScore: number; + rightScore: number; + round: string; +}