Add country dropdown and flags (#28)

* Add country dropdown and flags

* Fix build for country flags

* Improve country select filtering and scoreboard teams

* Fix country select display value

* Fix country select input display
This commit is contained in:
Pandipipas
2026-02-09 22:42:00 +01:00
committed by GitHub
parent 2dfd57786d
commit 547f9ab95f
7 changed files with 236 additions and 3 deletions
+45 -3
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { useHead } from '@unhead/vue';
import type { QTableColumn } from 'quasar';
import { computed, reactive, ref } from 'vue';
import { computed, reactive, ref, watch } from 'vue';
import type { Schemas } from '../../../types';
import { countryOptions, getCountryLabel } from '../../../shared/countries';
import { usePlayersStore } from '../stores/players';
useHead({ title: 'Players' });
@@ -38,11 +39,40 @@ const form = reactive<Player>({ ...emptyPlayer });
const columns: QTableColumn<PlayerRow>[] = [
{ name: 'gamertag', label: 'Gamertag', field: 'gamertag', sortable: true, align: 'left' },
{ name: 'team', label: 'Team', field: 'team', sortable: true, align: 'left' },
{ name: 'country', label: 'Country', field: 'country', sortable: true, align: 'left' },
{
name: 'country',
label: 'Country',
field: (row) => getCountryLabel(row.country),
sortable: true,
align: 'left',
},
{ name: 'twitter', label: 'Twitter', field: 'twitter', sortable: true, align: 'left' },
{ name: 'actions', label: 'Actions', field: (row) => row.id, sortable: false, align: 'right' },
];
const filteredCountryOptions = ref(countryOptions);
const countryInput = ref('');
const filterCountries = (value: string, update: (callback: () => void) => void) => {
update(() => {
const needle = value.toLowerCase().trim();
if (!needle) {
filteredCountryOptions.value = countryOptions;
return;
}
filteredCountryOptions.value = countryOptions.filter((country) =>
country.label.toLowerCase().includes(needle),
);
});
};
watch(
() => form.country,
(value) => {
countryInput.value = getCountryLabel(value);
},
{ immediate: true },
);
const generateId = () => {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
@@ -219,8 +249,20 @@ const handleImport = async (event: Event) => {
/>
</div>
<div class="col-12 col-md-6">
<QInput
<QSelect
v-model="form.country"
v-model:input-value="countryInput"
:options="filteredCountryOptions"
option-value="value"
option-label="label"
emit-value
map-options
use-input
input-debounce="0"
hide-selected
fill-input
@filter="filterCountries"
clearable
label="Country"
dense
outlined
+113
View File
@@ -2,6 +2,7 @@
import { useHead } from '@unhead/vue';
import { computed } from 'vue';
import { playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
import { resolveCountryCode } from '../../shared/countries';
import type { Schemas } from '../../types';
useHead({ title: 'Scoreboard' });
@@ -35,6 +36,53 @@ const rightName = computed(() => {
return player?.gamertag || 'Jugador 2';
});
const leftTeam = computed(() => {
const player = players.value[scoreboard.value.leftPlayerId];
return player?.team || '';
});
const rightTeam = computed(() => {
const player = players.value[scoreboard.value.rightPlayerId];
return player?.team || '';
});
const flagModules = import.meta.glob('/node_modules/flag-icons/flags/4x3/*.svg', {
eager: true,
import: 'default',
query: '?url',
}) as Record<string, string>;
const flagByCode = Object.fromEntries(
Object.entries(flagModules)
.map(([path, url]) => {
const filename = path.split('/').pop();
const code = filename?.replace('.svg', '');
if (!code) {
return null;
}
return [code.toLowerCase(), url] as const;
})
.filter((entry): entry is readonly [string, string] => Boolean(entry)),
);
const getFlagUrl = (country: string | undefined) => {
const code = resolveCountryCode(country);
if (!code) {
return '';
}
return flagByCode[code.toLowerCase()] ?? '';
};
const leftFlagUrl = computed(() => {
const player = players.value[scoreboard.value.leftPlayerId];
return getFlagUrl(player?.country);
});
const rightFlagUrl = computed(() => {
const player = players.value[scoreboard.value.rightPlayerId];
return getFlagUrl(player?.country);
});
const roundText = computed(() => scoreboard.value.round || 'Round');
</script>
@@ -69,20 +117,46 @@ const roundText = computed(() => scoreboard.value.round || 'Round');
<img src="./img/name1.svg" alt="" />
<div id="p1-name-text-wrapper" class="name-text-wrapper">
<span v-if="leftTeam" class="team-text">
{{ leftTeam }}
</span>
<span class="gamertag-text">
{{ leftName }}
</span>
</div>
<div
v-if="leftFlagUrl"
id="p1-flag-wrapper"
class="flag-wrapper"
>
<div class="flag-mask">
<img class="flag" :src="leftFlagUrl" alt="" />
</div>
</div>
</div>
<div id="p2-name-wrapper" class="name-wrapper">
<img src="./img/name2.svg" alt="" />
<div id="p2-name-text-wrapper" class="name-text-wrapper">
<span v-if="rightTeam" class="team-text">
{{ rightTeam }}
</span>
<span class="gamertag-text">
{{ rightName }}
</span>
</div>
<div
v-if="rightFlagUrl"
id="p2-flag-wrapper"
class="flag-wrapper"
>
<div class="flag-mask">
<img class="flag" :src="rightFlagUrl" alt="" />
</div>
</div>
</div>
</div>
</div>
@@ -107,6 +181,11 @@ const roundText = computed(() => scoreboard.value.round || 'Round');
var(--name-panel-height) * 0.5 - (var(--name-text-height) * 0.5)
);
--flag-width: 38px;
--flag-height: 26px;
--flag-offset-x: 16px;
--flag-offset-y: 12px;
--games-text-width: calc(var(--main-panel-width) * 0.11);
--games-text-height: calc(var(--main-panel-height) * 0.8);
--games-text-offset-x: calc(var(--main-panel-width) * 0.04);
@@ -233,6 +312,40 @@ img {
text-overflow: ellipsis;
}
.team-text {
color: #a5a5a5;
margin-right: 8px;
}
.flag-wrapper {
position: absolute;
top: var(--flag-offset-y);
height: var(--flag-height);
width: var(--flag-width);
z-index: 1;
}
#p1-flag-wrapper {
left: var(--flag-offset-x);
}
#p2-flag-wrapper {
right: var(--flag-offset-x);
}
.flag-mask {
width: 100%;
height: 100%;
border-radius: 4px;
overflow: hidden;
}
.flag {
width: 100%;
height: 100%;
object-fit: cover;
}
.games-text-wrapper {
position: absolute;
top: var(--games-text-offset-y);
+49
View File
@@ -0,0 +1,49 @@
import { getData, type CountryRecord } from 'country-list';
export interface CountryOption {
value: string;
label: string;
}
const baseCountries = getData();
export const countryOptions: CountryOption[] = baseCountries
.map((country: CountryRecord) => ({
value: country.code,
label: country.name,
}))
.sort((a: CountryOption, b: CountryOption) => a.label.localeCompare(b.label));
const countryByCode = new Map(
countryOptions.map((country) => [country.value.toUpperCase(), country.label]),
);
const countryByName = new Map(
countryOptions.map((country) => [country.label.toLowerCase(), country.value]),
);
export const resolveCountryCode = (value?: string) => {
if (!value) {
return '';
}
const trimmed = value.trim();
if (!trimmed) {
return '';
}
const upper = trimmed.toUpperCase();
if (countryByCode.has(upper)) {
return upper;
}
const byName = countryByName.get(trimmed.toLowerCase());
return byName ?? '';
};
export const getCountryLabel = (value?: string) => {
if (!value) {
return '';
}
const resolved = resolveCountryCode(value);
if (!resolved) {
return value;
}
return countryByCode.get(resolved) ?? value;
};
+8
View File
@@ -0,0 +1,8 @@
declare module 'country-list' {
export interface CountryRecord {
code: string;
name: string;
}
export const getData: () => CountryRecord[];
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />