mirror of
https://github.com/ericasch6/scrum-poker-internal.git
synced 2026-10-13 22:32:08 +00:00
232 lines
5.4 KiB
Vue
232 lines
5.4 KiB
Vue
<script setup lang="ts">
|
|
import { useHttp } from '@inertiajs/vue3';
|
|
import { Keyboard } from '@lucide/vue';
|
|
import { useEventListener } from '@vueuse/core';
|
|
import { watch } from 'vue';
|
|
|
|
import { store as storeVote } from '@/routes/room/vote';
|
|
import type { RoomVoteDeckEmits, RoomVoteDeckProps } from '@/types/room';
|
|
|
|
const props = defineProps<RoomVoteDeckProps>();
|
|
const emit = defineEmits<RoomVoteDeckEmits>();
|
|
|
|
const cardShortcuts = [
|
|
'1',
|
|
'2',
|
|
'3',
|
|
'4',
|
|
'5',
|
|
'6',
|
|
'7',
|
|
'8',
|
|
'9',
|
|
'0',
|
|
...Array.from({ length: 24 }, (_, index) =>
|
|
String.fromCharCode('a'.charCodeAt(0) + index),
|
|
),
|
|
];
|
|
|
|
const voteRequest = useHttp(() => storeVote(props.roomId), {
|
|
value: props.currentVote ?? '',
|
|
});
|
|
|
|
watch(
|
|
() => props.currentVote,
|
|
(value) => {
|
|
if (!voteRequest.processing) {
|
|
voteRequest.value = value ?? '';
|
|
}
|
|
},
|
|
);
|
|
|
|
function submitVote(value: string): void {
|
|
if (props.revealed || voteRequest.processing) {
|
|
return;
|
|
}
|
|
|
|
const previousVote = props.currentVote;
|
|
|
|
voteRequest.value = value;
|
|
emit('vote-changed', value);
|
|
void voteRequest.submit({
|
|
onError: () => {
|
|
voteRequest.value = previousVote ?? '';
|
|
emit('vote-changed', previousVote);
|
|
},
|
|
});
|
|
}
|
|
|
|
function shortcutForCard(index: number): string | null {
|
|
return cardShortcuts[index] ?? null;
|
|
}
|
|
|
|
function isEditableTarget(target: EventTarget | null): boolean {
|
|
if (!(target instanceof HTMLElement)) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
target.isContentEditable ||
|
|
['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName)
|
|
);
|
|
}
|
|
|
|
function handleKeyboardVote(event: KeyboardEvent): void {
|
|
if (
|
|
!props.presenterMode ||
|
|
props.revealed ||
|
|
voteRequest.processing ||
|
|
event.repeat ||
|
|
event.altKey ||
|
|
event.ctrlKey ||
|
|
event.metaKey ||
|
|
isEditableTarget(event.target)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const cardIndex = cardShortcuts.indexOf(event.key.toLowerCase());
|
|
const card = props.cards[cardIndex];
|
|
|
|
if (card === undefined) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
submitVote(card.key);
|
|
}
|
|
|
|
useEventListener('keydown', handleKeyboardVote);
|
|
</script>
|
|
|
|
<template>
|
|
<section class="mx-auto mt-6 max-w-4xl text-center md:mt-3 md:shrink-0">
|
|
<div class="mb-3 flex items-center justify-center gap-3">
|
|
<span class="h-px w-10 bg-white/10"></span>
|
|
<h2
|
|
class="text-sm font-semibold tracking-[0.16em] text-slate-300 uppercase"
|
|
>
|
|
{{ revealed ? 'Runde beendet' : 'Wähle deine Karte' }}
|
|
</h2>
|
|
<span class="h-px w-10 bg-white/10"></span>
|
|
</div>
|
|
|
|
<div
|
|
v-if="!revealed"
|
|
class="flex flex-wrap justify-center gap-2.5 sm:gap-3"
|
|
>
|
|
<button
|
|
v-for="(card, index) in cards"
|
|
:key="card.key"
|
|
type="button"
|
|
class="deck-card"
|
|
:class="{
|
|
'is-selected':
|
|
!presenterMode && voteRequest.value === card.key,
|
|
}"
|
|
:disabled="voteRequest.processing"
|
|
:aria-pressed="voteRequest.value === card.key"
|
|
:aria-label="`Karte ${card.label} wählen`"
|
|
@click="submitVote(card.key)"
|
|
>
|
|
<span class="card-label">{{ card.label }}</span>
|
|
<span
|
|
v-if="presenterMode && shortcutForCard(index)"
|
|
class="card-shortcut"
|
|
aria-hidden="true"
|
|
>
|
|
<Keyboard class="size-3" />
|
|
<kbd>{{ shortcutForCard(index) }}</kbd>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
<p
|
|
v-if="voteRequest.errors.value"
|
|
class="mt-3 text-sm text-rose-300"
|
|
role="alert"
|
|
>
|
|
{{ voteRequest.errors.value }}
|
|
</p>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.deck-card {
|
|
position: relative;
|
|
display: grid;
|
|
width: 3.75rem;
|
|
aspect-ratio: 3 / 4;
|
|
place-items: center;
|
|
border: 2px solid rgb(255 255 255 / 15%);
|
|
border-radius: 0.75rem;
|
|
background: #f8fafc;
|
|
color: #0f172a;
|
|
box-shadow: 0 0.3rem 0.7rem rgb(0 0 0 / 18%);
|
|
transition:
|
|
transform 160ms ease,
|
|
border-color 160ms ease,
|
|
background 160ms ease,
|
|
box-shadow 160ms ease;
|
|
}
|
|
|
|
.deck-card > .card-label {
|
|
font-size: 1.2rem;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.card-shortcut {
|
|
position: absolute;
|
|
bottom: 0.25rem;
|
|
left: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.2rem;
|
|
color: #64748b;
|
|
font-size: 0.6rem;
|
|
font-weight: 700;
|
|
line-height: 1;
|
|
transform: translateX(-50%);
|
|
}
|
|
|
|
.card-shortcut kbd {
|
|
font: inherit;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.deck-card:hover:not(:disabled) {
|
|
transform: translateY(-0.35rem);
|
|
border-color: rgb(103 232 249 / 80%);
|
|
}
|
|
|
|
.deck-card.is-selected {
|
|
transform: translateY(-0.5rem);
|
|
border-color: #fcd34d;
|
|
background: #fcd34d;
|
|
box-shadow: 0 0.35rem 0.8rem rgb(0 0 0 / 20%);
|
|
}
|
|
|
|
.deck-card:disabled {
|
|
cursor: wait;
|
|
opacity: 0.72;
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.deck-card {
|
|
width: 3.3rem;
|
|
}
|
|
}
|
|
|
|
@media (min-width: 768px) and (max-height: 800px) {
|
|
.deck-card {
|
|
width: 3.25rem;
|
|
}
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.deck-card {
|
|
transition: none;
|
|
}
|
|
}
|
|
</style>
|