mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
Add Pinia persistence and scoreboard support (#15)
* Add scoreboard replicant and Pinia persistence * Fix scoreboard replicant sync (#16)
This commit is contained in:
Generated
+51
@@ -23,6 +23,7 @@
|
|||||||
"eslint-plugin-vue": "^10.5.1",
|
"eslint-plugin-vue": "^10.5.1",
|
||||||
"nodecg": "^2.6.1",
|
"nodecg": "^2.6.1",
|
||||||
"nodecg-vue-composable": "^1.1.0",
|
"nodecg-vue-composable": "^1.1.0",
|
||||||
|
"pinia": "^2.3.1",
|
||||||
"quasar": "^2.18.5",
|
"quasar": "^2.18.5",
|
||||||
"sass-embedded": "^1.93.3",
|
"sass-embedded": "^1.93.3",
|
||||||
"trash-cli": "^7.0.0",
|
"trash-cli": "^7.0.0",
|
||||||
@@ -9071,6 +9072,29 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/pinkie": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz",
|
"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": {
|
"node_modules/vue-eslint-parser": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"eslint-plugin-vue": "^10.5.1",
|
"eslint-plugin-vue": "^10.5.1",
|
||||||
"nodecg": "^2.6.1",
|
"nodecg": "^2.6.1",
|
||||||
"nodecg-vue-composable": "^1.1.0",
|
"nodecg-vue-composable": "^1.1.0",
|
||||||
|
"pinia": "^2.3.1",
|
||||||
"quasar": "^2.18.5",
|
"quasar": "^2.18.5",
|
||||||
"sass-embedded": "^1.93.3",
|
"sass-embedded": "^1.93.3",
|
||||||
"trash-cli": "^7.0.0",
|
"trash-cli": "^7.0.0",
|
||||||
@@ -69,6 +70,11 @@
|
|||||||
"file": "example/main.html",
|
"file": "example/main.html",
|
||||||
"width": 1920,
|
"width": 1920,
|
||||||
"height": 1080
|
"height": 1080
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "scoreboard/main.html",
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,3 +11,4 @@ const thisBundle = 'scoreko-dev';
|
|||||||
*/
|
*/
|
||||||
export const exampleReplicant = useReplicant<Schemas.ExampleReplicant>('exampleReplicant', thisBundle);
|
export const exampleReplicant = useReplicant<Schemas.ExampleReplicant>('exampleReplicant', thisBundle);
|
||||||
export const playersReplicant = useReplicant<Schemas.Players>('players', thisBundle);
|
export const playersReplicant = useReplicant<Schemas.Players>('players', thisBundle);
|
||||||
|
export const scoreboardReplicant = useReplicant<Schemas.Scoreboard>('scoreboard', thisBundle);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import '@quasar/extras/material-icons/material-icons.css';
|
import '@quasar/extras/material-icons/material-icons.css';
|
||||||
import '@quasar/extras/roboto-font/roboto-font.css';
|
import '@quasar/extras/roboto-font/roboto-font.css';
|
||||||
import { createHead } from '@unhead/vue/client';
|
import { createHead } from '@unhead/vue/client';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
import { Dark, Quasar } from 'quasar';
|
import { Dark, Quasar } from 'quasar';
|
||||||
import 'quasar/src/css/index.sass';
|
import 'quasar/src/css/index.sass';
|
||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
@@ -9,8 +10,10 @@ import router from './router';
|
|||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
const head = createHead();
|
const head = createHead();
|
||||||
|
const pinia = createPinia();
|
||||||
app.use(Quasar);
|
app.use(Quasar);
|
||||||
app.use(head);
|
app.use(head);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
app.use(pinia);
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
Dark.set(true);
|
Dark.set(true);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const route = useRoute();
|
|||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ label: 'Dashboard', to: '/', icon: 'dashboard' },
|
{ label: 'Dashboard', to: '/', icon: 'dashboard' },
|
||||||
{ label: 'Players', to: '/players', icon: 'groups' },
|
{ label: 'Players', to: '/players', icon: 'groups' },
|
||||||
|
{ label: 'Scoreboard', to: '/scoreboard', icon: 'scoreboard' },
|
||||||
{ label: 'Graphics', to: '/graphics', icon: 'collections' },
|
{ label: 'Graphics', to: '/graphics', icon: 'collections' },
|
||||||
{ label: 'Settings', to: '/settings', icon: 'settings' },
|
{ label: 'Settings', to: '/settings', icon: 'settings' },
|
||||||
{ label: 'About', to: '/about', icon: 'info' },
|
{ label: 'About', to: '/about', icon: 'info' },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import AboutView from './views/About.vue';
|
|||||||
import DashboardView from './views/Dashboard.vue';
|
import DashboardView from './views/Dashboard.vue';
|
||||||
import GraphicsView from './views/Graphics.vue';
|
import GraphicsView from './views/Graphics.vue';
|
||||||
import PlayersView from './views/Players.vue';
|
import PlayersView from './views/Players.vue';
|
||||||
|
import ScoreboardView from './views/Scoreboard.vue';
|
||||||
import SettingsView from './views/Settings.vue';
|
import SettingsView from './views/Settings.vue';
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@@ -10,6 +11,7 @@ const router = createRouter({
|
|||||||
routes: [
|
routes: [
|
||||||
{ path: '/', name: 'dashboard', component: DashboardView },
|
{ path: '/', name: 'dashboard', component: DashboardView },
|
||||||
{ path: '/players', name: 'players', component: PlayersView },
|
{ path: '/players', name: 'players', component: PlayersView },
|
||||||
|
{ path: '/scoreboard', name: 'scoreboard', component: ScoreboardView },
|
||||||
{ path: '/graphics', name: 'graphics', component: GraphicsView },
|
{ path: '/graphics', name: 'graphics', component: GraphicsView },
|
||||||
{ path: '/settings', name: 'settings', component: SettingsView },
|
{ path: '/settings', name: 'settings', component: SettingsView },
|
||||||
{ path: '/about', name: 'about', component: AboutView },
|
{ path: '/about', name: 'about', component: AboutView },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -2,9 +2,8 @@
|
|||||||
import { useHead } from '@unhead/vue';
|
import { useHead } from '@unhead/vue';
|
||||||
import type { QTableColumn } from 'quasar';
|
import type { QTableColumn } from 'quasar';
|
||||||
import { computed, reactive, ref } from 'vue';
|
import { computed, reactive, ref } from 'vue';
|
||||||
import type { Ref } from 'vue';
|
|
||||||
import { playersReplicant } from '../../../browser_shared/replicants';
|
|
||||||
import type { Schemas } from '../../../types';
|
import type { Schemas } from '../../../types';
|
||||||
|
import { usePlayersStore } from '../stores/players';
|
||||||
|
|
||||||
useHead({ title: 'Players' });
|
useHead({ title: 'Players' });
|
||||||
|
|
||||||
@@ -15,22 +14,8 @@ interface PlayerRow extends Player {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playersData = computed<PlayersMap>({
|
const playersStore = usePlayersStore();
|
||||||
get: () => {
|
const rows = computed<PlayerRow[]>(() => playersStore.rows);
|
||||||
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 filter = ref('');
|
||||||
const isDialogOpen = ref(false);
|
const isDialogOpen = ref(false);
|
||||||
@@ -79,60 +64,21 @@ const openEditDialog = (row: PlayerRow) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const savePlayer = () => {
|
const savePlayer = () => {
|
||||||
if (!playersReplicant?.data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const id = editingId.value ?? generateId();
|
const id = editingId.value ?? generateId();
|
||||||
playersData.value = {
|
playersStore.upsertPlayer(id, { ...form });
|
||||||
...playersData.value,
|
|
||||||
[id]: { ...form },
|
|
||||||
};
|
|
||||||
isDialogOpen.value = false;
|
isDialogOpen.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deletePlayer = (row: PlayerRow) => {
|
const deletePlayer = (row: PlayerRow) => {
|
||||||
if (!playersReplicant?.data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const confirmed = window.confirm(`¿Eliminar a ${row.gamertag || 'este jugador'}?`);
|
const confirmed = window.confirm(`¿Eliminar a ${row.gamertag || 'este jugador'}?`);
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const next = { ...playersData.value };
|
playersStore.removePlayer(row.id);
|
||||||
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 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 blob = new Blob([data], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
@@ -147,9 +93,6 @@ const triggerImport = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = async (event: Event) => {
|
const handleImport = async (event: Event) => {
|
||||||
if (!playersReplicant?.data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target = event.target as HTMLInputElement | null;
|
const target = event.target as HTMLInputElement | null;
|
||||||
const file = target?.files?.[0];
|
const file = target?.files?.[0];
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -158,7 +101,7 @@ const handleImport = async (event: Event) => {
|
|||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const parsed = JSON.parse(text) as unknown;
|
const parsed = JSON.parse(text) as unknown;
|
||||||
playersData.value = normalizePlayers(parsed);
|
playersStore.setPlayers(parsed as PlayersMap);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert('No se pudo importar el JSON. Verifica el formato.');
|
window.alert('No se pudo importar el JSON. Verifica el formato.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -18,3 +18,4 @@ function hasNoDefault<T>(name: string) {
|
|||||||
*/
|
*/
|
||||||
export const exampleReplicant = hasDefault<Schemas.ExampleReplicant>('exampleReplicant');
|
export const exampleReplicant = hasDefault<Schemas.ExampleReplicant>('exampleReplicant');
|
||||||
export const playersReplicant = hasDefault<Schemas.Players>('players');
|
export const playersReplicant = hasDefault<Schemas.Players>('players');
|
||||||
|
export const scoreboardReplicant = hasDefault<Schemas.Scoreboard>('scoreboard');
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useHead } from '@unhead/vue';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import { playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
|
||||||
|
import type { Schemas } from '../../types';
|
||||||
|
|
||||||
|
useHead({ title: 'Scoreboard' });
|
||||||
|
|
||||||
|
const defaultScoreboard: Schemas.Scoreboard = {
|
||||||
|
leftPlayerId: '',
|
||||||
|
rightPlayerId: '',
|
||||||
|
leftNameOverride: '',
|
||||||
|
rightNameOverride: '',
|
||||||
|
leftScore: 0,
|
||||||
|
rightScore: 0,
|
||||||
|
round: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const playersData = playersReplicant?.data as unknown as Ref<Schemas.Players | undefined> | undefined;
|
||||||
|
const scoreboardData = scoreboardReplicant?.data as unknown as Ref<Schemas.Scoreboard | undefined> | undefined;
|
||||||
|
|
||||||
|
const players = computed<Schemas.Players>(() => playersData?.value ?? {});
|
||||||
|
const scoreboard = computed<Schemas.Scoreboard>(() => scoreboardData?.value ?? defaultScoreboard);
|
||||||
|
|
||||||
|
const leftName = computed(() => {
|
||||||
|
if (scoreboard.value.leftNameOverride) {
|
||||||
|
return scoreboard.value.leftNameOverride;
|
||||||
|
}
|
||||||
|
const player = players.value[scoreboard.value.leftPlayerId];
|
||||||
|
return player?.gamertag || 'Jugador 1';
|
||||||
|
});
|
||||||
|
|
||||||
|
const rightName = computed(() => {
|
||||||
|
if (scoreboard.value.rightNameOverride) {
|
||||||
|
return scoreboard.value.rightNameOverride;
|
||||||
|
}
|
||||||
|
const player = players.value[scoreboard.value.rightPlayerId];
|
||||||
|
return player?.gamertag || 'Jugador 2';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="scoreboard-root">
|
||||||
|
<div class="scoreboard-panel">
|
||||||
|
<div class="scoreboard-round">
|
||||||
|
{{ scoreboard.round || 'Round' }}
|
||||||
|
</div>
|
||||||
|
<div class="scoreboard-row">
|
||||||
|
<div class="scoreboard-side">
|
||||||
|
<div class="scoreboard-name">
|
||||||
|
{{ leftName }}
|
||||||
|
</div>
|
||||||
|
<div class="scoreboard-score">
|
||||||
|
{{ scoreboard.leftScore }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scoreboard-divider">VS</div>
|
||||||
|
<div class="scoreboard-side">
|
||||||
|
<div class="scoreboard-name">
|
||||||
|
{{ rightName }}
|
||||||
|
</div>
|
||||||
|
<div class="scoreboard-score">
|
||||||
|
{{ scoreboard.rightScore }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scoreboard-root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 40px;
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
color: #ffffff;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-panel {
|
||||||
|
width: min(900px, 100%);
|
||||||
|
padding: 24px 32px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(10, 12, 20, 0.85);
|
||||||
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-round {
|
||||||
|
font-size: 20px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-side {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-divider {
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-name {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-score {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
min-width: 64px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Vendored
+1
@@ -7,3 +7,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';
|
export type { Players } from './schemas/players.d.ts';
|
||||||
|
export type { Scoreboard } from './schemas/scoreboard.d.ts';
|
||||||
|
|||||||
Vendored
+17
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user