diff --git a/src/dashboard/scoreko-dev/components/PlayerSidePanel.vue b/src/dashboard/scoreko-dev/components/PlayerSidePanel.vue new file mode 100644 index 0000000..7f48147 --- /dev/null +++ b/src/dashboard/scoreko-dev/components/PlayerSidePanel.vue @@ -0,0 +1,503 @@ + + + + + diff --git a/src/dashboard/scoreko-dev/components/ScoreCenterPanel.vue b/src/dashboard/scoreko-dev/components/ScoreCenterPanel.vue new file mode 100644 index 0000000..26faae3 --- /dev/null +++ b/src/dashboard/scoreko-dev/components/ScoreCenterPanel.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/src/dashboard/scoreko-dev/components/ScoreboardPanel.vue b/src/dashboard/scoreko-dev/components/ScoreboardPanel.vue index 354187b..cb95ecb 100644 --- a/src/dashboard/scoreko-dev/components/ScoreboardPanel.vue +++ b/src/dashboard/scoreko-dev/components/ScoreboardPanel.vue @@ -1,1114 +1,24 @@ @@ -1120,7 +30,6 @@ watch( gap: 16px; } - .scoreboard-preview { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, auto) minmax(0, 1fr); @@ -1129,168 +38,6 @@ watch( align-items: center; } -.scoreboard-preview__side { - display: flex; - align-items: center; -} - -.scoreboard-preview__image-column { - width: min(100%, 320px); - display: flex; - flex-direction: column; - gap: 8px; -} - -.scoreboard-preview__side-inner { - width: 100%; - display: grid; - grid-template-columns: minmax(220px, 320px) minmax(180px, 1fr); - align-items: center; - gap: 14px; -} - -.scoreboard-preview__side--right { - text-align: right; -} - -.scoreboard-preview__side--right .scoreboard-preview__side-inner { - grid-template-columns: minmax(180px, 1fr) minmax(220px, 320px); -} - - -.scoreboard-preview__side .scoreboard-preview__image-column { - justify-self: start; -} - -.scoreboard-preview__side--right .scoreboard-preview__image-column { - justify-self: end; -} - -.scoreboard-preview__image-wrap { - position: relative; - width: min(100%, 320px); - aspect-ratio: 4 / 4; - overflow: visible; - contain: layout; -} - -.scoreboard-preview__image { - position: absolute; - inset: 0; - display: block; - width: 100%; - height: 100%; - object-fit: contain; - object-position: center; - transform: scale(1.5); - transform-origin: center center; - pointer-events: none; -} - -.scoreboard-preview__empty { - height: 100%; - display: flex; - align-items: center; - justify-content: center; - color: rgba(255, 255, 255, 0.65); - font-weight: 600; -} - -.scoreboard-preview__controls { - width: min(100%, 260px); - justify-self: center; - display: flex; - flex-direction: column; - gap: 6px; -} - -.scoreboard-preview__field { - margin: 0; -} - -.scoreboard-preview__character-field { - margin-top: 2px; -} - -.scoreboard-preview__field :deep(.q-field__control) { - min-height: 28px; - padding: 0; - background: transparent !important; - border-radius: 0; -} - -.scoreboard-preview__field :deep(.q-field__control:before), -.scoreboard-preview__field :deep(.q-field__control:after) { - border: 0; - border-bottom: 1px solid rgba(255, 255, 255, 0.34); -} - -.scoreboard-preview__field :deep(.q-field__native), -.scoreboard-preview__field :deep(.q-field__input), -.scoreboard-preview__field :deep(.q-field__label) { - color: rgba(255, 255, 255, 0.92); -} - -.scoreboard-preview__center { - display: flex; - flex-direction: column; - align-items: center; - align-self: stretch; - justify-content: flex-start; - padding-top: 2px; - gap: 10px; -} - -.scoreboard-preview__game-field { - width: min(100%, 240px); - margin-bottom: 56px; -} - -.scoreboard-preview__score-controls { - display: flex; - align-items: center; - justify-content: center; - gap: 18px; -} - -.scoreboard-preview__actions { - display: flex; - align-items: center; - gap: 10px; -} - -.scoreboard-preview__action-btn { - color: #fff; - opacity: 0.85; -} - -.scoreboard-preview__action-btn:hover { - opacity: 1; - text-shadow: 0 0 10px rgba(255, 255, 255, 0.45); -} - -.scoreboard-preview__score-side { - display: inline-flex; - flex-direction: column; - align-items: center; - gap: 4px; -} - -.scoreboard-preview__score-value { - min-width: 64px; - text-align: center; - font-size: clamp(4rem, 7vw, 5.6rem); - font-weight: 800; - line-height: 1; -} - -.scoreboard-preview__dash { - opacity: 0.7; - font-size: clamp(3rem, 5vw, 4rem); - font-weight: 700; -} - - @media (min-width: 1024px) { .scoreboard-preview { grid-template-columns: minmax(0, 1fr) minmax(320px, auto) minmax(0, 1fr); @@ -1303,28 +50,9 @@ watch( gap: 12px; } - .scoreboard-preview__center { + .scoreboard-preview :deep(.scoreboard-preview__center) { order: -1; justify-self: center; } - - .scoreboard-preview__image-wrap { - width: min(100%, 280px); - } - - .scoreboard-preview__side-inner { - grid-template-columns: 1fr; - align-items: flex-start; - justify-items: flex-start; - } - - .scoreboard-preview__side--right { - text-align: left; - } - - .scoreboard-preview__side--right .scoreboard-preview__side-inner { - grid-template-columns: 1fr; - } } - diff --git a/src/dashboard/scoreko-dev/composables/useCharacterGame.ts b/src/dashboard/scoreko-dev/composables/useCharacterGame.ts new file mode 100644 index 0000000..89ece5c --- /dev/null +++ b/src/dashboard/scoreko-dev/composables/useCharacterGame.ts @@ -0,0 +1,205 @@ +import { computed, ref, watch, type InjectionKey, type Ref } from 'vue'; +import { getCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters'; +import { useScoreboardStore } from '../stores/scoreboard'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const ALL_FIGHTING_GAME_OPTIONS = [ + '2XKO', + 'Mortal Kombat 1', + 'Street Fighter 6', + 'TEKKEN 8', + 'Guilty Gear -Strive-', + 'THE KING OF FIGHTERS XV', +].map((game) => ({ label: game, value: game })); + +export type CharacterOption = ReturnType[number]; + +// --------------------------------------------------------------------------- +// Injection key (type-safe provide/inject) +// --------------------------------------------------------------------------- + +export type CharacterGameContext = ReturnType; +export const CHARACTER_GAME_KEY: InjectionKey = Symbol('characterGame'); + +// --------------------------------------------------------------------------- +// Composable +// --------------------------------------------------------------------------- + +/** + * Manages game selection and character state for both sides. + * Must be called ONCE in the parent (ScoreboardPanel) and provided via + * CHARACTER_GAME_KEY so both PlayerSidePanel instances share the same state. + */ +export function useCharacterGame() { + const scoreboardStore = useScoreboardStore(); + + // Game selector + const gameInput = ref(''); + const fightingGameOptions = ref(ALL_FIGHTING_GAME_OPTIONS); + + // Per-side character state + const characterOptions = computed(() => getCharactersByGame(scoreboardStore.scoreboard.game)); + const leftCharacterOptions = ref([]); + const rightCharacterOptions = ref([]); + const leftCharacterInput = ref(''); + const rightCharacterInput = ref(''); + + // Remembers selected characters per game so swapping games restores them + const charactersByGame = ref>({}); + + // Character images for preview + const leftCharacterImage = computed(() => { + const match = characterOptions.value.find( + (o) => o.value === scoreboardStore.scoreboard.leftCharacter, + ); + return match?.image ?? ''; + }); + + const rightCharacterImage = computed(() => { + const match = characterOptions.value.find( + (o) => o.value === scoreboardStore.scoreboard.rightCharacter, + ); + return match?.image ?? ''; + }); + + // --------------------------------------------------------------------------- + // Filter handlers + // --------------------------------------------------------------------------- + + const onGameFilter = (value: string, update: (fn: () => void) => void) => { + update(() => { + const needle = value.toLowerCase().trim(); + fightingGameOptions.value = needle + ? ALL_FIGHTING_GAME_OPTIONS.filter((g) => g.label.toLowerCase().includes(needle)) + : ALL_FIGHTING_GAME_OPTIONS; + }); + }; + + const makeCharacterFilter = (target: Ref) => + (value: string, update: (fn: () => void) => void) => { + update(() => { + const needle = value.toLowerCase().trim(); + target.value = needle + ? characterOptions.value.filter((c) => c.label.toLowerCase().includes(needle)) + : characterOptions.value; + }); + }; + + const onLeftCharacterFilter = makeCharacterFilter(leftCharacterOptions); + const onRightCharacterFilter = makeCharacterFilter(rightCharacterOptions); + + // --------------------------------------------------------------------------- + // Watchers + // --------------------------------------------------------------------------- + + // Keep gameInput display value in sync + watch( + () => scoreboardStore.scoreboard.game, + (value) => { + const match = ALL_FIGHTING_GAME_OPTIONS.find((o) => o.value === value); + gameInput.value = match?.label ?? ''; + }, + { immediate: true }, + ); + + // Handle game change: persist previous characters, restore or apply defaults + watch( + () => scoreboardStore.scoreboard.game, + (newGame, previousGame) => { + if (previousGame) { + charactersByGame.value[previousGame] = { + leftCharacter: scoreboardStore.scoreboard.leftCharacter, + rightCharacter: scoreboardStore.scoreboard.rightCharacter, + }; + } + + const options = getCharactersByGame(newGame); + leftCharacterOptions.value = options; + rightCharacterOptions.value = options; + const allowed = new Set(options.map((o) => o.value)); + const saved = newGame ? charactersByGame.value[newGame] : undefined; + + const { leftCharacter: curLeft, rightCharacter: curRight } = scoreboardStore.scoreboard; + let nextLeft = saved?.leftCharacter ?? curLeft; + let nextRight = saved?.rightCharacter ?? curRight; + + if (!allowed.has(nextLeft)) nextLeft = ''; + if (!allowed.has(nextRight)) nextRight = ''; + + // Apply defaults only when neither side had a character yet + if ((!nextLeft || !nextRight) && (!curLeft || !curRight)) { + const defaults = getDefaultCharactersByGame(newGame); + if (defaults) { + if (!nextLeft) nextLeft = allowed.has(defaults.leftCharacter) ? defaults.leftCharacter : ''; + if (!nextRight) nextRight = allowed.has(defaults.rightCharacter) ? defaults.rightCharacter : ''; + } + } + + if (allowed.has(nextLeft)) { + scoreboardStore.scoreboard.leftCharacter = nextLeft; + } else if (!allowed.has(scoreboardStore.scoreboard.leftCharacter)) { + scoreboardStore.scoreboard.leftCharacter = ''; + leftCharacterInput.value = ''; + } + + if (allowed.has(nextRight)) { + scoreboardStore.scoreboard.rightCharacter = nextRight; + } else if (!allowed.has(scoreboardStore.scoreboard.rightCharacter)) { + scoreboardStore.scoreboard.rightCharacter = ''; + rightCharacterInput.value = ''; + } + }, + { immediate: true }, + ); + + // Keep left character display input and charactersByGame cache in sync + watch( + () => scoreboardStore.scoreboard.leftCharacter, + (value) => { + const match = characterOptions.value.find((o) => o.value === value); + leftCharacterInput.value = match?.label ?? ''; + const game = scoreboardStore.scoreboard.game; + if (game) { + charactersByGame.value[game] = { + leftCharacter: value, + rightCharacter: scoreboardStore.scoreboard.rightCharacter, + }; + } + }, + { immediate: true }, + ); + + // Keep right character display input and charactersByGame cache in sync + watch( + () => scoreboardStore.scoreboard.rightCharacter, + (value) => { + const match = characterOptions.value.find((o) => o.value === value); + rightCharacterInput.value = match?.label ?? ''; + const game = scoreboardStore.scoreboard.game; + if (game) { + charactersByGame.value[game] = { + leftCharacter: scoreboardStore.scoreboard.leftCharacter, + rightCharacter: value, + }; + } + }, + { immediate: true }, + ); + + return { + gameInput, + fightingGameOptions, + leftCharacterOptions, + rightCharacterOptions, + leftCharacterInput, + rightCharacterInput, + leftCharacterImage, + rightCharacterImage, + onGameFilter, + onLeftCharacterFilter, + onRightCharacterFilter, + }; +} diff --git a/src/dashboard/scoreko-dev/composables/useCountryFilter.ts b/src/dashboard/scoreko-dev/composables/useCountryFilter.ts new file mode 100644 index 0000000..0b865ea --- /dev/null +++ b/src/dashboard/scoreko-dev/composables/useCountryFilter.ts @@ -0,0 +1,36 @@ +import { computed, ref, watch } from 'vue'; +import { getCountryLabel, getCountryOptions } from '../../../shared/countries'; +import { locale } from '../i18n'; + +/** + * Manages filtered country options and the display input value + * for a single side of the scoreboard. + * + * @param getOverride - Getter returning the current country code override + */ +export function useCountryFilter(getOverride: () => string) { + const countryOptions = computed(() => getCountryOptions(locale.value)); + const countryInput = ref(''); + const filteredOptions = ref(countryOptions.value); + + // Keep filtered list in sync when locale changes + watch(countryOptions, (opts) => { + filteredOptions.value = opts; + }); + + // Keep display input in sync with the stored country code + watch(getOverride, (value) => { + countryInput.value = getCountryLabel(value, locale.value); + }, { immediate: true }); + + const onFilter = (value: string, update: (fn: () => void) => void) => { + update(() => { + const needle = value.toLowerCase().trim(); + filteredOptions.value = needle + ? countryOptions.value.filter((c) => c.label.toLowerCase().includes(needle)) + : countryOptions.value; + }); + }; + + return { countryInput, filteredOptions, onFilter }; +} diff --git a/src/dashboard/scoreko-dev/composables/usePlayerSide.ts b/src/dashboard/scoreko-dev/composables/usePlayerSide.ts new file mode 100644 index 0000000..67995b7 --- /dev/null +++ b/src/dashboard/scoreko-dev/composables/usePlayerSide.ts @@ -0,0 +1,346 @@ +import { computed, ref, watch, watchEffect } from 'vue'; +import { useScoreboardStore } from '../stores/scoreboard'; +import { usePlayersStore } from '../stores/players'; +import type { Schemas } from '../../../types'; +import { t } from '../i18n'; +import { useCountryFilter } from './useCountryFilter'; + +// --------------------------------------------------------------------------- +// Constants (exported so components can compare against them) +// --------------------------------------------------------------------------- + +export const CUSTOM_LEFT_PLAYER_ID = '__custom_left_player__'; +export const CUSTOM_RIGHT_PLAYER_ID = '__custom_right_player__'; + +// --------------------------------------------------------------------------- +// Pure helpers (no Vue reactivity) +// --------------------------------------------------------------------------- + +const normalizeName = (value: string) => value.trim().toLowerCase(); + +/** + * Generates a unique slug-based player ID that does not collide with + * existing player keys in the store. + */ +const createPlayerId = (name: string, players: Schemas.Players): string => { + const base = name + .trim() + .toLowerCase() + .normalize('NFD') + .replace(/[^\w\s-]/g, '') + .replace(/[\u0300-\u036f]/g, '') + .replace(/\s+/g, '-') || 'player'; + + let index = 1; + let candidate = base; + while (players[candidate]) { + index += 1; + candidate = `${base}-${index}`; + } + return candidate; +}; + +// --------------------------------------------------------------------------- +// Composable +// --------------------------------------------------------------------------- + +/** + * Encapsulates all reactive state and handlers for one side of the scoreboard + * (left or right). Call once per side inside the corresponding component. + */ +export function usePlayerSide(side: 'left' | 'right') { + const scoreboardStore = useScoreboardStore(); + const playersStore = usePlayersStore(); + + const isLeft = side === 'left'; + const CUSTOM_ID = isLeft ? CUSTOM_LEFT_PLAYER_ID : CUSTOM_RIGHT_PLAYER_ID; + + // --------------------------------------------------------------------------- + // Two-way computed bindings to the store (avoids left/right if-chains in + // the template and keeps mutation contained to the composable) + // --------------------------------------------------------------------------- + + const playerId = computed({ + get: () => (isLeft ? scoreboardStore.scoreboard.leftPlayerId : scoreboardStore.scoreboard.rightPlayerId), + set: (v) => { + if (isLeft) scoreboardStore.scoreboard.leftPlayerId = v; + else scoreboardStore.scoreboard.rightPlayerId = v; + }, + }); + + const nameOverride = computed({ + get: () => (isLeft ? scoreboardStore.scoreboard.leftNameOverride : scoreboardStore.scoreboard.rightNameOverride), + set: (v) => { + if (isLeft) scoreboardStore.scoreboard.leftNameOverride = v; + else scoreboardStore.scoreboard.rightNameOverride = v; + }, + }); + + const teamOverride = computed({ + get: () => (isLeft ? scoreboardStore.scoreboard.leftTeamOverride : scoreboardStore.scoreboard.rightTeamOverride), + set: (v) => { + if (isLeft) scoreboardStore.scoreboard.leftTeamOverride = v; + else scoreboardStore.scoreboard.rightTeamOverride = v; + }, + }); + + const countryOverride = computed({ + get: () => (isLeft ? scoreboardStore.scoreboard.leftCountryOverride : scoreboardStore.scoreboard.rightCountryOverride), + set: (v) => { + if (isLeft) scoreboardStore.scoreboard.leftCountryOverride = v; + else scoreboardStore.scoreboard.rightCountryOverride = v; + }, + }); + + // --------------------------------------------------------------------------- + // UI state + // --------------------------------------------------------------------------- + + const filter = ref(''); + const inputValue = ref(''); + const focused = ref(false); + + // Country filter (delegated to sub-composable) + const { + countryInput, + filteredOptions: filteredCountryOptions, + onFilter: onCountryFilter, + } = useCountryFilter(() => countryOverride.value); + + // --------------------------------------------------------------------------- + // Player options + // --------------------------------------------------------------------------- + + const allPlayerOptions = computed(() => { + const base = [{ label: t('scoreboardUnassigned'), value: '' }]; + const entries = Object.entries(playersStore.players) as [string, Schemas.Players[string]][]; + const mapped = entries.map(([id, player]) => ({ + value: id, + label: player.gamertag || id, + })); + return base.concat(mapped); + }); + + /** + * Player options filtered by the current search input. + * Prepends the custom player entry when the user has typed a new name. + */ + const playerOptions = computed(() => { + const needle = filter.value.toLowerCase(); + const options = needle + ? allPlayerOptions.value.filter((o) => o.label.toLowerCase().includes(needle)) + : allPlayerOptions.value; + + if (playerId.value === CUSTOM_ID && nameOverride.value.trim()) { + return [{ value: CUSTOM_ID, label: nameOverride.value }, ...options]; + } + return options; + }); + + const selectedPlayer = computed(() => playersStore.players[playerId.value]); + + const getPlayerLabel = (id: string): string => { + if (id === CUSTOM_ID) return nameOverride.value; + return allPlayerOptions.value.find((o) => o.value === id)?.label ?? ''; + }; + + const playerExistsByGamertag = (name: string): boolean => { + const normalized = normalizeName(name); + return Boolean(normalized) + && Object.values(playersStore.players).some( + (p) => normalizeName(p.gamertag || '') === normalized, + ); + }; + + // --------------------------------------------------------------------------- + // Derived state + // --------------------------------------------------------------------------- + + const displayName = computed( + () => nameOverride.value || getPlayerLabel(playerId.value), + ); + + /** True when the typed name is new and can be saved as a new player. */ + const canSave = computed( + () => Boolean(nameOverride.value.trim()) && !playerExistsByGamertag(nameOverride.value), + ); + + const teamChanged = computed(() => { + const player = selectedPlayer.value; + if (!player) return false; + return player.team !== teamOverride.value; + }); + + const countryChanged = computed(() => { + const player = selectedPlayer.value; + if (!player) return false; + return player.country !== countryOverride.value; + }); + + // Parentheses required: || and ?? cannot be mixed without them (TS5076) + const pendingGamertag = computed( + () => (nameOverride.value.trim() || selectedPlayer.value?.gamertag) ?? '', + ); + + const nameChanged = computed(() => { + const player = selectedPlayer.value; + if (!player) return false; + return player.gamertag !== pendingGamertag.value; + }); + + /** True when the name has changed and the new name doesn't collide. */ + const canSaveNameChange = computed( + () => nameChanged.value && !playerExistsByGamertag(pendingGamertag.value), + ); + + /** Whether the save icon should appear in the player name field. */ + const showsNameSave = computed(() => canSave.value || canSaveNameChange.value); + + // --------------------------------------------------------------------------- + // Actions + // --------------------------------------------------------------------------- + + const startCustomPlayer = () => { + const wasCustom = playerId.value === CUSTOM_ID; + playerId.value = CUSTOM_ID; + if (!wasCustom) { + teamOverride.value = ''; + countryOverride.value = ''; + } + }; + + const applyPlayerData = (id: string) => { + const player = playersStore.players[id]; + if (!player) return; + teamOverride.value = player.team ?? ''; + countryOverride.value = player.country ?? ''; + }; + + const onFilter = (val: string, update: (fn: () => void) => void) => { + update(() => { + filter.value = val; + if (!focused.value) return; + + // If the field is cleared while a custom player is selected, restore the name + if (!val.trim() && playerId.value === CUSTOM_ID) { + inputValue.value = nameOverride.value; + return; + } + + inputValue.value = val; + nameOverride.value = val; + if (val.trim()) startCustomPlayer(); + }); + }; + + const onFocus = () => { + focused.value = true; + inputValue.value = displayName.value; + }; + + const onBlur = () => { + focused.value = false; + filter.value = ''; + inputValue.value = displayName.value; + }; + + const onSelect = (id: string) => { + if (!id || !playersStore.players[id]) return; + focused.value = false; + nameOverride.value = ''; + filter.value = ''; + inputValue.value = getPlayerLabel(id); + applyPlayerData(id); + }; + + /** Save the typed name as a brand-new player entry. */ + const savePlayer = () => { + const gamertag = nameOverride.value.trim(); + if (!gamertag || playerExistsByGamertag(gamertag)) return; + const id = createPlayerId(gamertag, playersStore.players); + playersStore.upsertPlayer(id, { + gamertag, + name: '', + team: teamOverride.value, + country: countryOverride.value, + twitter: '', + }); + playerId.value = id; + nameOverride.value = ''; + inputValue.value = gamertag; + }; + + /** Persist a gamertag rename on an existing player. */ + const saveNameChange = () => { + const player = selectedPlayer.value; + if (!player || !canSaveNameChange.value) return; + playersStore.upsertPlayer(playerId.value, { ...player, gamertag: pendingGamertag.value }); + nameOverride.value = ''; + }; + + const saveTeamChange = () => { + const player = selectedPlayer.value; + if (!player) return; + playersStore.upsertPlayer(playerId.value, { ...player, team: teamOverride.value }); + }; + + const saveCountryChange = () => { + const player = selectedPlayer.value; + if (!player) return; + playersStore.upsertPlayer(playerId.value, { ...player, country: countryOverride.value }); + }; + + /** Dispatches to savePlayer or saveNameChange depending on context. */ + const onNameSave = () => { + if (canSave.value) { + savePlayer(); + return; + } + saveNameChange(); + }; + + // --------------------------------------------------------------------------- + // Watchers + // --------------------------------------------------------------------------- + + // Sync team/country fields when player selection changes + watch(playerId, (id) => applyPlayerData(id), { immediate: true }); + + // Keep the search input display value in sync unless the field is focused + watchEffect(() => { + if (!focused.value) { + inputValue.value = displayName.value; + } + }); + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + return { + // Store bindings (writable computed refs) + playerId, + nameOverride, + teamOverride, + countryOverride, + // UI state + inputValue, + countryInput, + filteredCountryOptions, + playerOptions, + // Derived state + displayName, + teamChanged, + countryChanged, + showsNameSave, + // Handlers + onFilter, + onFocus, + onBlur, + onSelect, + onNameSave, + saveTeamChange, + saveCountryChange, + onCountryFilter, + }; +} \ No newline at end of file