Improve user experience quality of life

master
John Montagu, the 4th Earl of Sandvich 2024-12-07 17:21:57 -08:00
parent d3abf67d88
commit 42b7e603f0
Signed by: sandvich
GPG Key ID: 9A39BE37E602B22D
12 changed files with 239 additions and 173 deletions

View File

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterLink, RouterView } from "vue-router"; import { RouterLink, RouterView } from "vue-router";
import { useAuthStore } from "./stores/auth"; import { useAuthStore } from "./stores/auth";
import ProfileDropdown from "./components/ProfileDropdown.vue";
const baseUrl = window.location.origin; const baseUrl = window.location.origin;
@ -11,12 +12,23 @@ const authStore = useAuthStore();
<header> <header>
<div class="wrapper"> <div class="wrapper">
<nav> <nav>
<h1>availabili.tf</h1> <h1>
<RouterLink to="/">Home</RouterLink> <RouterLink class="header-link" to="/">availabili.tf</RouterLink>
<RouterLink to="/schedule">Schedule</RouterLink> </h1>
<div v-if="authStore.isLoggedIn"> <div class="nav-links">
<a
class="button"
href="https://github.com/HumanoidSandvichDispenser/availabili.tf"
v-tooltip="'View on GitHub'"
>
<button class="icon">
<i class="bi bi-github" />
</button>
</a>
<ProfileDropdown v-if="authStore.isLoggedIn" />
<!--button v-if="authStore.isLoggedIn" class="profile-button">
Welcome {{ authStore.username }} Welcome {{ authStore.username }}
</div> </button-->
<form <form
v-else v-else
action="https://steamcommunity.com/openid/login" action="https://steamcommunity.com/openid/login"
@ -29,8 +41,12 @@ const authStore = useAuthStore();
<input type="hidden" name="openid.ns" value="http://specs.openid.net/auth/2.0" /> <input type="hidden" name="openid.ns" value="http://specs.openid.net/auth/2.0" />
<input type="hidden" name="openid.mode" value="checkid_setup" /> <input type="hidden" name="openid.mode" value="checkid_setup" />
<input type="hidden" name="openid.return_to" :value="baseUrl + '/login'" /> <input type="hidden" name="openid.return_to" :value="baseUrl + '/login'" />
<button type="submit">Log in through Steam</button> <!--button type="submit">Log in through Steam</button-->
<button type="submit" class="sign-in-button">
<img src="https://community.fastly.steamstatic.com/public/images/signinthroughsteam/sits_01.png" />
</button>
</form> </form>
</div>
</nav> </nav>
</div> </div>
</header> </header>
@ -46,6 +62,10 @@ header {
max-height: 100vh; max-height: 100vh;
} }
a.header-link {
font-weight: 800;
}
.logo { .logo {
display: block; display: block;
margin: 0 auto 2rem; margin: 0 auto 2rem;
@ -53,28 +73,36 @@ header {
nav { nav {
display: flex; display: flex;
gap: 8px;
width: 100%; width: 100%;
font-size: 12px;
text-align: center; text-align: center;
margin: 0; margin: 0;
align-items: center; align-items: center;
justify-content: space-between;
}
nav .nav-links {
display: flex;
justify-content: end;
align-items: center;
font-size: 11pt;
gap: 1rem;
}
button.profile-button {
background-color: transparent;
} }
nav a.router-link-exact-active { nav a.router-link-exact-active {
color: var(--crust); color: var(--text);
background-color: var(--accent);
} }
nav a { nav a {
padding: 0.5rem 1rem;
color: var(--subtext-0); color: var(--subtext-0);
border-radius: 8px; border-radius: 8px;
} }
nav a:hover { nav a:hover {
color: var(--accent); background-color: transparent;
background-color: var(--accent-transparent);
} }
nav > h1 { nav > h1 {
@ -82,6 +110,12 @@ nav > h1 {
margin-right: 1rem; margin-right: 1rem;
} }
button.sign-in-button {
background-color: transparent;
border: none;
padding: 0;
}
@media (min-width: 1024px) { @media (min-width: 1024px) {
header { header {
display: flex; display: flex;
@ -102,7 +136,6 @@ nav > h1 {
nav { nav {
text-align: left; text-align: left;
margin-left: -1rem;
font-size: 1rem; font-size: 1rem;
padding: 1rem 0; padding: 1rem 0;

View File

@ -271,7 +271,7 @@ hr {
/*box-shadow: 0 0 4px var(--text);*/ /*box-shadow: 0 0 4px var(--text);*/
} }
[role="menu"] { [role="menu"], [role="listbox"] {
background-color: var(--base); background-color: var(--base);
border: 1px solid var(--overlay-0); border: 1px solid var(--overlay-0);
border-radius: 4px; border-radius: 4px;
@ -279,6 +279,16 @@ hr {
min-width: 8rem; min-width: 8rem;
} }
[role="listbox"] [role="option"] {
padding: 0.5rem 1rem;
cursor: pointer;
}
[role="listbox"] [role="option"][data-highlighted] {
background-color: var(--surface-0);
padding: 4px 10px;
}
[role="menu"] button { [role="menu"] button {
background-color: var(--base); background-color: var(--base);
width: 100%; width: 100%;
@ -297,17 +307,6 @@ hr {
[role="menu"] button > i.bi.margin { [role="menu"] button > i.bi.margin {
margin-right: 0.5em; margin-right: 0.5em;
} }
/*
div[role="menu"] div[role="menuitem"]:first-child button {
border-top-left-radius: var(--border-radius);
border-top-right-radius: var(--border-radius);
}
div[role="menu"] div[role="menuitem"]:last-child button {
border-bottom-left-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
}
*/
[role="menu"] button:hover { [role="menu"] button:hover {
background-color: var(--surface-0); background-color: var(--surface-0);

View File

@ -1,91 +1,53 @@
<script setup lang="ts"> <script setup lang="ts" generic="T extends AcceptableValue">
import { computed, defineModel, defineProps, ref } from "vue"; import { defineModel, defineProps, ref } from "vue";
import {
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
ComboboxPortal,
ComboboxTrigger,
ComboboxAnchor,
ComboboxViewport,
} from "radix-vue";
import type { AcceptableValue } from "node_modules/radix-vue/dist/shared/types";
const model = defineModel(); const selectedValue = defineModel<T>();
const props = defineProps({
options: Array<String>,
isDisabled: Boolean,
});
const isOpen = ref(false); const isOpen = ref(false);
const selectedOption = computed(() => props.options[model.value]);
function selectOption(index) { withDefaults(defineProps<{
model.value = index; values: T[];
isOpen.value = false; //mapper? (value: T): string;
} //keyMapper? (value: T): string;
display: string;
keyField: string;
}>(), {
//mapper: (value: T) => value.toString(),
});
</script> </script>
<template> <template>
<div :class="{ 'dropdown-container': true, 'is-open': isOpen }"> <ComboboxRoot
<button @click="isOpen = !isOpen" :disabled="isDisabled"> v-model="selectedValue"
{{ selectedOption }} v-model:open="isOpen"
<i class="bi bi-caret-down-fill"></i> defaultOpen
</button> >
<ul class="dropdown" v-if="isOpen" @blur="isOpen = false"> <ComboboxAnchor>
<li v-for="(option, i) in options" :key="i" @click="selectOption(i)"> <ComboboxInput />
<button :class="{ 'is-selected': i == model }"> <ComboboxTrigger>
{{ option }} hi
</button> </ComboboxTrigger>
</li> </ComboboxAnchor>
</ul>
</div> <ComboboxPortal>
<ComboboxContent position="popper">
<ComboboxViewport>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
</template> </template>
<style scoped> <style scoped>
.dropdown-container {
display: inline-block;
border-radius: 8px;
}
.dropdown-container button {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
text-align: left;
font-weight: 700;
font-size: 16px;
padding: 4px;
transition-duration: 200ms;
background-color: transparent;
cursor: pointer;
}
.dropdown-container button:hover {
background-color: var(--crust);
}
.dropdown-container.is-open ul.dropdown {
box-shadow: 1px 1px 8px var(--shadow);
}
ul.dropdown {
display: block;
background-color: var(--base);
position: absolute;
margin-top: 8px;
padding: 0;
z-index: 2;
border-radius: 8px;
overflow: none;
}
ul.dropdown > li {
list-style-type: none;
}
.dropdown li > button {
padding: 8px 16px;
font-weight: 500;
font-size: 14px;
border-radius: 0;
}
.dropdown li > button.is-selected {
background-color: var(--accent-transparent);
color: var(--accent);
}
</style> </style>

View File

@ -27,8 +27,8 @@ function disableIntegration() {
</script> </script>
<template> <template>
<h2>logs.tf Integration</h2> <h2>logs.tf Auto-Tracking</h2>
<p>Automatically track match history from logs.tf.</p> <p>Automatically fetch and track match history from logs.tf.</p>
<div v-if="model"> <div v-if="model">
<div class="form-group margin"> <div class="form-group margin">
<h3>logs.tf API key (optional)</h3> <h3>logs.tf API key (optional)</h3>

View File

@ -5,11 +5,12 @@ import { useRosterStore } from "../stores/roster";
const rosterStore = useRosterStore(); const rosterStore = useRosterStore();
const props = defineProps({ const props = withDefaults(defineProps<{
roleTitle: String, roleTitle: string,
player: Object as PropType<PlayerTeamRoleFlat>, player: PlayerTeamRoleFlat | undefined,
isRoster: Boolean, isRoster: boolean,
isRinger: Boolean, }>(), {
isRoster: false,
}); });
const isSelected = computed(() => { const isSelected = computed(() => {
@ -17,11 +18,9 @@ const isSelected = computed(() => {
return rosterStore.selectedRole == props.roleTitle; return rosterStore.selectedRole == props.roleTitle;
} }
if (props.isRinger) { const selectedPlayers = rosterStore.selectedPlayers;
return rosterStore.selectedPlayers[props.roleTitle]?.playtime == -1;
}
return Object.values(rosterStore.selectedPlayers).includes(props.player); return selectedPlayers[props.roleTitle]?.steamId == props.player?.steamId;
}); });
function onClick() { function onClick() {
@ -35,26 +34,14 @@ function onClick() {
// we are selecting the player // we are selecting the player
if (isSelected.value) { if (isSelected.value) {
rosterStore.selectPlayerForRole(undefined, props.roleTitle); rosterStore.selectPlayerForRole(undefined, props.roleTitle);
} else {
if (props.isRinger) {
const ringerPlayer: PlayerTeamRoleFlat = {
steamId: "0",
name: "Ringer",
role: props.roleTitle ?? "",
isMain: false,
availability: 1,
playtime: -1,
};
rosterStore.selectPlayerForRole(ringerPlayer, props.roleTitle);
} else { } else {
rosterStore.selectPlayerForRole(props.player, props.roleTitle); rosterStore.selectPlayerForRole(props.player, props.roleTitle);
} }
} }
}
}; };
const playtime = computed(() => { const playtime = computed(() => {
let hours = props.player?.playtime / 3600 ?? 0; let hours = props.player?.playtime ?? 0 / 3600;
return hours.toFixed(1); return hours.toFixed(1);
}); });
</script> </script>
@ -62,7 +49,7 @@ const playtime = computed(() => {
<template> <template>
<button :class="{ <button :class="{
'player-card': true, 'player-card': true,
'no-player': !player && !isRinger, 'no-player': !player,
'selected': isSelected, 'selected': isSelected,
'can-be-available': player?.availability == 1 'can-be-available': player?.availability == 1
}" @click="onClick"> }" @click="onClick">
@ -79,21 +66,12 @@ const playtime = computed(() => {
(alternate) (alternate)
</span> </span>
</span> </span>
<span v-if="playtime > 0"> <span v-if="Number(playtime) > 0">
{{ playtime }} hours {{ playtime }} hours
</span> </span>
</div> </div>
</span> </span>
</div> </div>
<div v-else-if="isRinger" class="role-info">
<span>
<h4 class="player-name">Ringer</h4>
<div class="subtitle">
<span>{{ rosterStore.roleNames[roleTitle] }}</span>
<!--span>nobody likes to play {{ roleTitle }}</span-->
</div>
</span>
</div>
<div v-else class="role-info"> <div v-else class="role-info">
<span> <span>
{{ rosterStore.roleNames[roleTitle] }} {{ rosterStore.roleNames[roleTitle] }}

View File

@ -131,9 +131,14 @@ const rightIndicator = computed(() => {
:availability="player.availability[1]" :availability="player.availability[1]"
/> />
</div> </div>
<a
class="player-name"
:href="`https://steamcommunity.com/profiles/${player.steamId}`"
>
<h3> <h3>
{{ player.username }} {{ player.username }}
</h3> </h3>
</a>
<svg-icon <svg-icon
v-if="player.isTeamLeader" v-if="player.isTeamLeader"
:class="[ :class="[
@ -256,6 +261,14 @@ const rightIndicator = computed(() => {
background-color: var(--green); background-color: var(--green);
} }
a.player-name {
color: unset;
}
a.player-name:hover {
background-color: unset;
}
.flex-middle { .flex-middle {
display: flex; display: flex;
gap: 8px; gap: 8px;

View File

@ -0,0 +1,63 @@
<script setup lang="ts">
import { useAuthStore } from "@/stores/auth";
import {
DropdownMenuRoot,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
} from "radix-vue";
import { RouterLink } from "vue-router";
const authStore = useAuthStore();
function logout() {
authStore.logout();
}
</script>
<template>
<DropdownMenuRoot>
<DropdownMenuTrigger className="profile-button">
{{ authStore.username }}
<i class="bi bi-chevron-down" />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent className="shadow">
<DropdownMenuItem>
<RouterLink class="button" :to="{ 'name': 'user-settings' }">
<button>
<i class="bi bi-gear margin" />
Settings
</button>
</RouterLink>
</DropdownMenuItem>
<DropdownMenuItem>
<RouterLink class="button" to="/teams">
<button>
<i class="bi bi-people margin" />
Teams
</button>
</RouterLink>
</DropdownMenuItem>
<DropdownMenuItem>
<button class="destructive" @click="logout">
<i class="bi bi-box-arrow-right margin" />
Log out
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>
<style scoped>
.profile-button {
background-color: transparent;
font-size: inherit;
}
.profile-button:hover {
background-color: var(--surface-0);
}
</style>

View File

@ -6,6 +6,7 @@ import { createPinia } from "pinia";
import VueSelect from "vue-select"; import VueSelect from "vue-select";
import { TooltipDirective } from "vue3-tooltip"; import { TooltipDirective } from "vue3-tooltip";
import "vue3-tooltip/tooltip.css"; import "vue3-tooltip/tooltip.css";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import App from "./App.vue"; import App from "./App.vue";
import router from "./router"; import router from "./router";

View File

@ -16,7 +16,7 @@ export const useRosterStore = defineStore("roster", () => {
// TODO: move roster state to a composable // TODO: move roster state to a composable
const neededRoles: Reactive<Array<String>> = reactive([ const neededRoles = ref([
"PocketScout", "PocketScout",
"FlankScout", "FlankScout",
"PocketSoldier", "PocketSoldier",
@ -95,7 +95,7 @@ export const useRosterStore = defineStore("roster", () => {
"Spy": "Spy", "Spy": "Spy",
}); });
function selectPlayerForRole(player: PlayerTeamRoleFlat, role: string) { function selectPlayerForRole(player: PlayerTeamRoleFlat | undefined, role: string) {
if (player && player.steamId) { if (player && player.steamId) {
const existingRole = Object.keys(selectedPlayers).find((selectedRole) => { const existingRole = Object.keys(selectedPlayers).find((selectedRole) => {
return selectedPlayers[selectedRole]?.steamId == player.steamId && return selectedPlayers[selectedRole]?.steamId == player.steamId &&

View File

@ -79,13 +79,10 @@ onMounted(async () => {
<span v-if="!hasAvailablePlayers && rosterStore.selectedRole"> <span v-if="!hasAvailablePlayers && rosterStore.selectedRole">
No players are currently available for this role. No players are currently available for this role.
</span> </span>
<h3 v-if="hasAvailablePlayers">Alternates</h3> <h3 v-if="hasAlternates">Alternates</h3>
<PlayerCard v-for="player in rosterStore.alternateRoles" <PlayerCard v-for="player in rosterStore.alternateRoles"
:player="player" :player="player"
:role-title="player.role" /> :role-title="player.role" />
<PlayerCard v-if="rosterStore.selectedRole"
is-ringer
:role-title="rosterStore.selectedRole" />
<div class="action-buttons"> <div class="action-buttons">
<button class="accent" @click="closeSelection"> <button class="accent" @click="closeSelection">
<i class="bi bi-check" /> <i class="bi bi-check" />

View File

@ -27,18 +27,24 @@ const hasAvailablePlayers = computed(() => {
</div> </div>
<div class="column"> <div class="column">
<h3 v-if="hasAvailablePlayers">Available</h3> <h3 v-if="hasAvailablePlayers">Available</h3>
<PlayerCard v-for="player in rosterStore.definitelyAvailableAll" <PlayerCard
v-for="player in rosterStore.definitelyAvailable"
:player="player" :player="player"
:role-title="player.role" /> :role-title="player.role"
:is-roster="false"
/>
<span v-if="!hasAvailablePlayers"> <span v-if="!hasAvailablePlayers">
No players are currently available for this role. No players are currently available for this role.
</span> </span>
</div> </div>
<div class="column"> <div class="column">
<h3 v-if="hasAvailablePlayers">Available if needed</h3> <h3 v-if="hasAvailablePlayers">Available if needed</h3>
<PlayerCard v-for="player in rosterStore.canBeAvailableAll" <PlayerCard
v-for="player in rosterStore.canBeAvailable"
:player="player" :player="player"
:role-title="player.role" /> :role-title="player.role"
:is-roster="false"
/>
</div> </div>
</div> </div>
</main> </main>

View File

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import DiscordIntegrationForm from "@/components/DiscordIntegrationForm.vue"; import DiscordIntegrationForm from "@/components/DiscordIntegrationForm.vue";
import IntegrationDetails from "@/components/IntegrationDetails.vue"; import IntegrationDetails from "@/components/IntegrationDetails.vue";
import LoaderContainer from "@/components/LoaderContainer.vue";
import LogsTfIntegrationForm from "@/components/LogsTfIntegrationForm.vue"; import LogsTfIntegrationForm from "@/components/LogsTfIntegrationForm.vue";
import { useTeamDetails } from "@/composables/team-details"; import { useTeamDetails } from "@/composables/team-details";
import { useTeamsStore } from "@/stores/teams"; import { useTeamsStore } from "@/stores/teams";
@ -11,20 +12,33 @@ const teamsStore = useTeamsStore();
const integrationsStore = useIntegrationsStore(); const integrationsStore = useIntegrationsStore();
const { teamId } = useTeamDetails(); const { teamId } = useTeamDetails();
const isLoading = ref(false);
//function createIntegration() { //function createIntegration() {
// integrationsStore.createIntegration(teamId.value, "discord"); // integrationsStore.createIntegration(teamId.value, "discord");
//} //}
onMounted(() => { onMounted(() => {
isLoading.value = true;
teamsStore.fetchTeam(teamId.value) teamsStore.fetchTeam(teamId.value)
.then(() => integrationsStore.getIntegrations(teamId.value)); .then(() => {
integrationsStore.getIntegrations(teamId.value)
.then(() => {
isLoading.value = false;
});
});
}); });
</script> </script>
<template> <template>
<div class="team-integrations"> <div class="team-integrations">
<div v-if="isLoading">
<LoaderContainer />
</div>
<template v-else>
<DiscordIntegrationForm v-model="integrationsStore.discordIntegration" /> <DiscordIntegrationForm v-model="integrationsStore.discordIntegration" />
<LogsTfIntegrationForm v-model="integrationsStore.logsTfIntegration" /> <LogsTfIntegrationForm v-model="integrationsStore.logsTfIntegration" />
</template>
</div> </div>
</template> </template>