mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
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:
Generated
+16
@@ -8,6 +8,10 @@
|
|||||||
"name": "scoreko-dev",
|
"name": "scoreko-dev",
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"country-list": "^2.4.1",
|
||||||
|
"flag-icons": "^7.5.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.0",
|
"@eslint/js": "^9.39.0",
|
||||||
"@quasar/extras": "^1.17.0",
|
"@quasar/extras": "^1.17.0",
|
||||||
@@ -5266,6 +5270,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/country-list": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/country-list/-/country-list-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-KhVV/UfUV3dSNpsWIqHTQxLpYDKPKz1UwkRjadt+YbX2PRhyCEihEoS5XgB7J7AMXpkicvl+tRHvkNI5wbji/g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -6523,6 +6533,12 @@
|
|||||||
"desandro-matches-selector": "^2.0.0"
|
"desandro-matches-selector": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/flag-icons": {
|
||||||
|
"version": "7.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
|
||||||
|
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/flat-cache": {
|
"node_modules/flat-cache": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||||
|
|||||||
@@ -88,5 +88,9 @@
|
|||||||
"vite-plugin-nodecg": {
|
"vite-plugin-nodecg": {
|
||||||
"vite": "$vite"
|
"vite": "$vite"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"country-list": "^2.4.1",
|
||||||
|
"flag-icons": "^7.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
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, watch } from 'vue';
|
||||||
import type { Schemas } from '../../../types';
|
import type { Schemas } from '../../../types';
|
||||||
|
import { countryOptions, getCountryLabel } from '../../../shared/countries';
|
||||||
import { usePlayersStore } from '../stores/players';
|
import { usePlayersStore } from '../stores/players';
|
||||||
|
|
||||||
useHead({ title: 'Players' });
|
useHead({ title: 'Players' });
|
||||||
@@ -38,11 +39,40 @@ const form = reactive<Player>({ ...emptyPlayer });
|
|||||||
const columns: QTableColumn<PlayerRow>[] = [
|
const columns: QTableColumn<PlayerRow>[] = [
|
||||||
{ name: 'gamertag', label: 'Gamertag', field: 'gamertag', sortable: true, align: 'left' },
|
{ name: 'gamertag', label: 'Gamertag', field: 'gamertag', sortable: true, align: 'left' },
|
||||||
{ name: 'team', label: 'Team', field: 'team', 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: 'twitter', label: 'Twitter', field: 'twitter', sortable: true, align: 'left' },
|
||||||
{ name: 'actions', label: 'Actions', field: (row) => row.id, sortable: false, align: 'right' },
|
{ 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 = () => {
|
const generateId = () => {
|
||||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
@@ -219,8 +249,20 @@ const handleImport = async (event: Event) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<QInput
|
<QSelect
|
||||||
v-model="form.country"
|
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"
|
label="Country"
|
||||||
dense
|
dense
|
||||||
outlined
|
outlined
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { useHead } from '@unhead/vue';
|
import { useHead } from '@unhead/vue';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
|
import { playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
|
||||||
|
import { resolveCountryCode } from '../../shared/countries';
|
||||||
import type { Schemas } from '../../types';
|
import type { Schemas } from '../../types';
|
||||||
|
|
||||||
useHead({ title: 'Scoreboard' });
|
useHead({ title: 'Scoreboard' });
|
||||||
@@ -35,6 +36,53 @@ const rightName = computed(() => {
|
|||||||
return player?.gamertag || 'Jugador 2';
|
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');
|
const roundText = computed(() => scoreboard.value.round || 'Round');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -69,20 +117,46 @@ const roundText = computed(() => scoreboard.value.round || 'Round');
|
|||||||
<img src="./img/name1.svg" alt="" />
|
<img src="./img/name1.svg" alt="" />
|
||||||
|
|
||||||
<div id="p1-name-text-wrapper" class="name-text-wrapper">
|
<div id="p1-name-text-wrapper" class="name-text-wrapper">
|
||||||
|
<span v-if="leftTeam" class="team-text">
|
||||||
|
{{ leftTeam }}
|
||||||
|
</span>
|
||||||
<span class="gamertag-text">
|
<span class="gamertag-text">
|
||||||
{{ leftName }}
|
{{ leftName }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div id="p2-name-wrapper" class="name-wrapper">
|
<div id="p2-name-wrapper" class="name-wrapper">
|
||||||
<img src="./img/name2.svg" alt="" />
|
<img src="./img/name2.svg" alt="" />
|
||||||
|
|
||||||
<div id="p2-name-text-wrapper" class="name-text-wrapper">
|
<div id="p2-name-text-wrapper" class="name-text-wrapper">
|
||||||
|
<span v-if="rightTeam" class="team-text">
|
||||||
|
{{ rightTeam }}
|
||||||
|
</span>
|
||||||
<span class="gamertag-text">
|
<span class="gamertag-text">
|
||||||
{{ rightName }}
|
{{ rightName }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</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)
|
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-width: calc(var(--main-panel-width) * 0.11);
|
||||||
--games-text-height: calc(var(--main-panel-height) * 0.8);
|
--games-text-height: calc(var(--main-panel-height) * 0.8);
|
||||||
--games-text-offset-x: calc(var(--main-panel-width) * 0.04);
|
--games-text-offset-x: calc(var(--main-panel-width) * 0.04);
|
||||||
@@ -233,6 +312,40 @@ img {
|
|||||||
text-overflow: ellipsis;
|
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 {
|
.games-text-wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: var(--games-text-offset-y);
|
top: var(--games-text-offset-y);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
declare module 'country-list' {
|
||||||
|
export interface CountryRecord {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getData: () => CountryRecord[];
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
Reference in New Issue
Block a user